add sim mode..

This commit is contained in:
2026-08-20 13:22:27 +09:00
parent f29b44da07
commit e1d7b7d532
20 changed files with 1079 additions and 14828 deletions
+127 -2
View File
@@ -1,3 +1,128 @@
# lms-fe # Autonomy Studio LMS
lms 프론트엔드 Autonomy Studio LMS의 학생용 데스크톱 애플리케이션입니다. Electron 기반으로 동작하며, 학습 워크스페이스에서 ROS 2/NAV2 모듈 설치와 실행, 코드 편집, Git 연동, 터미널, 맵 및 시각화를 제공합니다.
로컬 PC 시뮬레이션과 원격 플랫폼 워크스페이스를 모두 지원합니다.
## 주요 기능
- 강좌, 학습 단계, 모듈 기반 워크스페이스 관리
- 로컬 및 원격 플랫폼의 모듈 설치, 빌드, 실행, 종료
- 코드 편집기, Git 소스 제어, 내장 터미널
- ROS 토픽 연결 상태와 모듈 간 발행/구독 관계 확인
- 2D/3D SLAM 맵 저장 및 주행 시각화
- Docker 기반 ROS 런타임과 SLAM ROS 브리지 관리
- 원격 플랫폼 에이전트와 연동한 워크스페이스 및 교보재 제어
## 프로젝트 구조
```text
src/
main/ Electron main process, Docker/워크스페이스/원격 제어
preload/ renderer에 노출하는 IPC API
renderer/src/ React 사용자 인터페이스
resources/
slam-ros-bridge/ SLAM 및 시각화용 ROS 브리지
bin/ 패키징에 포함되는 실행 파일
licenses/ 배포 라이선스
```
## 요구 사항
- Node.js와 npm
- Docker Engine
- Git
- Linux AppImage 패키징 시 필요한 시스템 의존성
GPU 가속을 사용할 경우에는 NVIDIA 드라이버 외에 Docker용 NVIDIA Container Toolkit이 필요합니다. GPU 가속은 Docker 컨테이너에 `--gpus all` 옵션을 전달하므로, Docker 런타임이 GPU를 인식하지 못하면 모듈 실행이 실패합니다.
## 개발 환경 실행
```bash
cp .env.example .env
npm ci
npm run dev
```
renderer 개발 서버는 기본적으로 `5176` 포트를 사용합니다.
## 환경 변수
`.env.example`을 복사해 로컬 환경에 맞게 설정합니다.
| 변수 | 설명 |
| --- | --- |
| `VITE_API_BASE_URL` | LMS 백엔드 API 주소 |
| `VITE_LAUNCHER_API_BASE_URL` | 런처/플랫폼 API 주소 |
| `LOCAL_AUTOWARE_BRIDGE_IMAGE` | 로컬 Autoware ROS 브리지 이미지 |
| `LOCAL_NAV2_BRIDGE_IMAGE` | 로컬 NAV2 ROS 브리지 이미지 |
| `LOCAL_NAV2_RUNTIME_IMAGE` | 로컬 NAV2 런타임 이미지 |
| `LOCAL_SLAM_BRIDGE_IMAGE` | 로컬 SLAM ROS 브리지 이미지 |
| `LOCAL_SLAM_RUNTIME_IMAGE` | 로컬 SLAM 런타임 이미지 |
| `JETSON_AGENT_URL` | 원격 Jetson 에이전트 주소 |
| `JETSON_AGENT_TOKEN` | 원격 Jetson 에이전트 인증 토큰 |
| `JETSON_AUTOWARE_BRIDGE_IMAGE` | 원격 Autoware ROS 브리지 이미지 |
| `JETSON_NAV2_BRIDGE_IMAGE` | 원격 NAV2 ROS 브리지 이미지 |
| `JETSON_NAV2_RUNTIME_IMAGE` | 원격 NAV2 런타임 이미지 |
`VITE_` 접두사 변수는 renderer 번들에 포함됩니다. 운영 AppImage를 만들 때는 운영 API 주소로 빌드해야 하며, 토큰이나 비밀번호 같은 민감한 값은 `VITE_` 변수에 넣으면 안 됩니다.
예시:
```bash
VITE_API_BASE_URL=https://workspace.autonomystudio.net/lms-api/api \
VITE_LAUNCHER_API_BASE_URL=https://portal-api.autonomystudio.net/api \
npm run build:linux
```
## 빌드
```bash
# Electron main, preload, renderer 빌드
npm run build
# Linux AppImage 생성
npm run build:linux
```
패키징 결과물은 `dist/`에 생성됩니다. AppImage에는 아이콘, SLAM ROS 브리지, 실행 도구, 라이선스 리소스가 함께 포함됩니다.
운영 배포용 AppImage는 관리자 웹의 빌드 변수와 동일한 API 주소, 동일한 소스 커밋을 기준으로 빌드해야 합니다. Kubernetes의 LMS 웹 배포 환경 변수는 Electron AppImage에 런타임으로 주입되지 않습니다.
## 워크스페이스와 맵
모듈, 빌드 결과물, 저장 맵은 선택한 사용자 워크스페이스 루트 아래에 관리됩니다. SLAM 맵은 기본적으로 다음 경로에 저장됩니다.
```text
<workspace>/maps
```
앱은 해당 워크스페이스를 Docker 컨테이너의 `/workspace`로 마운트하고, SLAM 브리지에는 `SAVED_MAPS_PATH=/workspace/maps`를 전달합니다.
## GPU 가속
GPU 가속을 켠 상태에서 다음과 비슷한 오류가 발생하면 Docker GPU 런타임이 설정되지 않은 상태입니다.
```text
failed to discover GPU vendor from CDI
```
NVIDIA Container Toolkit을 설치하고 Docker를 재시작한 뒤 아래 명령이 정상 동작하는지 확인합니다.
```bash
docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi
```
GPU 설정이 준비되지 않은 PC에서는 앱 설정에서 GPU 가속을 끈 상태로 사용합니다.
## 로그와 문제 확인
- 앱 renderer 로그는 Electron의 사용자 로그 경로에 `renderer.log`로 기록됩니다.
- 모듈 실행 실패는 워크스페이스 화면의 모듈 로그와 Docker 컨테이너 로그를 함께 확인합니다.
- 원격 플랫폼 문제는 에이전트 상태, Tailscale/Headscale 연결, ROS Domain ID, 원격 Docker 이미지를 순서대로 점검합니다.
## 보안 유의 사항
- `.env`는 저장소에 커밋하지 않습니다.
- 에이전트 토큰, API 키, 인증 정보는 빌드 변수 또는 별도 비밀 저장소로 관리합니다.
- 운영 배포 전에는 API 주소와 원격 런타임 이미지 태그가 대상 환경과 일치하는지 확인합니다.
+2 -1
View File
@@ -41,6 +41,7 @@ import uvicorn
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000"))) _POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000")))
_SENSOR_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("SENSOR_POINT_CLOUD_LIMIT", "4000")))
_CAMERA_FRAME_MAX_AGE_SECONDS = 3.0 _CAMERA_FRAME_MAX_AGE_SECONDS = 3.0
@@ -1254,7 +1255,7 @@ class SlamNode(Node):
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"]) payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
return payload return payload
def sensor_point_cloud_payload(self, limit: int = 10_000) -> Optional[dict]: def sensor_point_cloud_payload(self, limit: int = _SENSOR_POINT_CLOUD_LIMIT) -> Optional[dict]:
with self._lock: with self._lock:
cloud = self._fallback_point_cloud cloud = self._fallback_point_cloud
tf_snapshot = dict(self._tf) tf_snapshot = dict(self._tf)
+390 -24
View File
@@ -284,6 +284,52 @@ type SavedMapArtifactDownload = {
url: string url: string
} }
type PointCloudVoxel = {
maxZ: number
samples: number
}
type PointCloudLayoutCache = {
source: {
fileName: string
size: number
modifiedAtMs: number
}
layout: WorldLayout
}
type PgmImage = {
width: number
height: number
maxValue: number
pixels: Uint16Array
}
type PgmMapMetadata = {
resolution: number
originX: number
originY: number
negate: boolean
occupiedThreshold: number
}
type MapCollisionRectangle = {
startX: number
endX: number
startY: number
endY: number
}
// Build the Gazebo collision layer from the real LiDAR's horizontal sensing
// band. This rejects floor and ceiling points, then extrudes each occupied
// voxel down to the ground for a stable 2D navigation obstacle.
const pointCloudMinObstacleHeightMeters = 0.25
const pointCloudMaxObstacleHeightMeters = 1.2
const maxPointCloudCollisionObjects = 1_200
const pointCloudVoxelSizes = [0.16, 0.2, 0.25, 0.32, 0.4, 0.5]
const mapCollisionHeightMeters = 1.2
const maxMapCollisionObjects = 900
function defaultWorldLayout(): WorldLayout { function defaultWorldLayout(): WorldLayout {
return { return {
version: 1, version: 1,
@@ -335,6 +381,326 @@ function safeSavedMapFileName(input: string): string {
return fileName return fileName
} }
function savedMapPointCloudLayoutFileName(databaseFileName: string): string {
return safeSavedMapFileName(databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout.json'))
}
function savedMapPointCloudLayoutCacheFileName(databaseFileName: string): string {
return safeSavedMapFileName(databaseFileName.replace(/\.db$/i, '.gazebo-3d-layout-cache.json'))
}
function savedMapCollisionLayoutFileName(yamlFileName: string): string {
return safeSavedMapFileName(yamlFileName.replace(/\.yaml$/i, '.gazebo-layout.json'))
}
async function writeJsonAtomically(path: string, value: unknown): Promise<void> {
const tempPath = `${path}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
await writeFile(tempPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8')
await rename(tempPath, path)
}
function pgmWhitespace(byte: number | undefined): boolean {
return byte === 9 || byte === 10 || byte === 13 || byte === 32
}
function readPgmImage(contents: Buffer): PgmImage {
let offset = 0
const nextToken = (): string => {
while (offset < contents.length) {
if (contents[offset] === 35) {
while (offset < contents.length && contents[offset] !== 10) offset += 1
} else if (pgmWhitespace(contents[offset])) {
offset += 1
} else break
}
const start = offset
while (offset < contents.length && !pgmWhitespace(contents[offset]) && contents[offset] !== 35) offset += 1
return contents.subarray(start, offset).toString('ascii')
}
const format = nextToken()
const width = Number(nextToken())
const height = Number(nextToken())
const maxValue = Number(nextToken())
if (!['P2', 'P5'].includes(format) || !Number.isInteger(width) || !Number.isInteger(height)
|| width <= 0 || height <= 0 || width * height > 4_000_000
|| !Number.isInteger(maxValue) || maxValue <= 0 || maxValue > 65_535) {
throw new Error('저장된 PGM 맵 형식이 올바르지 않습니다.')
}
const pixels = new Uint16Array(width * height)
if (format === 'P2') {
for (let index = 0; index < pixels.length; index += 1) {
const value = Number(nextToken())
if (!Number.isInteger(value) || value < 0 || value > maxValue) throw new Error('저장된 PGM 맵 픽셀 데이터가 올바르지 않습니다.')
pixels[index] = value
}
return { width, height, maxValue, pixels }
}
if (!pgmWhitespace(contents[offset])) throw new Error('저장된 PGM 맵 헤더가 올바르지 않습니다.')
if (contents[offset] === 13 && contents[offset + 1] === 10) offset += 2
else offset += 1
const bytesPerPixel = maxValue < 256 ? 1 : 2
if (contents.length - offset < pixels.length * bytesPerPixel) throw new Error('저장된 PGM 맵 데이터가 불완전합니다.')
for (let index = 0; index < pixels.length; index += 1) {
pixels[index] = bytesPerPixel === 1
? contents[offset + index]
: (contents[offset + index * 2] << 8) | contents[offset + index * 2 + 1]
}
return { width, height, maxValue, pixels }
}
function readPgmMapMetadata(contents: string): PgmMapMetadata {
const scalar = (key: string, fallback: number): number => {
const match = contents.match(new RegExp(`^\\s*${key}\\s*:\\s*([^#\\r\\n]+)`, 'mi'))
const value = Number(match?.[1]?.trim())
return Number.isFinite(value) ? value : fallback
}
const origin = contents.match(/^\s*origin\s*:\s*\[\s*([-+\d.eE]+)\s*,\s*([-+\d.eE]+)/mi)
const originX = Number(origin?.[1])
const originY = Number(origin?.[2])
const resolution = scalar('resolution', NaN)
if (!Number.isFinite(resolution) || resolution <= 0 || !Number.isFinite(originX) || !Number.isFinite(originY)) {
throw new Error('저장된 맵 YAML의 해상도 또는 원점 정보가 올바르지 않습니다.')
}
return { resolution, originX, originY, negate: scalar('negate', 0) !== 0, occupiedThreshold: Math.min(1, Math.max(0, scalar('occupied_thresh', 0.65))) }
}
function mapCollisionRectangles(image: PgmImage, metadata: PgmMapMetadata, scale: number): MapCollisionRectangle[] {
const columns = Math.ceil(image.width / scale)
const rows = Math.ceil(image.height / scale)
const occupied = (x: number, y: number): boolean => {
const endY = Math.min(image.height, y + scale)
const endX = Math.min(image.width, x + scale)
for (let sourceY = y; sourceY < endY; sourceY += 1) {
for (let sourceX = x; sourceX < endX; sourceX += 1) {
const value = image.pixels[sourceY * image.width + sourceX] / image.maxValue
if ((metadata.negate ? value : 1 - value) >= metadata.occupiedThreshold) return true
}
}
return false
}
const rectangles: MapCollisionRectangle[] = []
let activeRuns = new Map<string, MapCollisionRectangle>()
for (let row = 0; row < rows; row += 1) {
const nextRuns = new Map<string, MapCollisionRectangle>()
let column = 0
while (column < columns) {
if (!occupied(column * scale, row * scale)) { column += 1; continue }
const startColumn = column
while (column < columns && occupied(column * scale, row * scale)) column += 1
const key = `${startColumn}:${column}`
const active = activeRuns.get(key)
nextRuns.set(key, active ? { ...active, endY: row + 1 } : { startX: startColumn, endX: column, startY: row, endY: row + 1 })
}
activeRuns.forEach((rectangle, key) => { if (!nextRuns.has(key)) rectangles.push(rectangle) })
activeRuns = nextRuns
}
activeRuns.forEach((rectangle) => rectangles.push(rectangle))
return rectangles
}
function createMapCollisionLayout(yamlContents: string, pgmContents: Buffer): WorldLayout {
const image = readPgmImage(pgmContents)
const metadata = readPgmMapMetadata(yamlContents)
let scale = 1
let rectangles = mapCollisionRectangles(image, metadata, scale)
while (rectangles.length > maxMapCollisionObjects && scale < 8) { scale *= 2; rectangles = mapCollisionRectangles(image, metadata, scale) }
if (rectangles.length > maxMapCollisionObjects) throw new Error('저장된 맵의 장애물이 너무 많아 시뮬레이션 충돌 환경으로 변환할 수 없습니다.')
const objects = rectangles.map((rectangle, index): WorldLayoutObject => {
const minX = metadata.originX + rectangle.startX * scale * metadata.resolution
const maxX = metadata.originX + Math.min(image.width, rectangle.endX * scale) * metadata.resolution
const minY = metadata.originY + (image.height - Math.min(image.height, rectangle.endY * scale)) * metadata.resolution
const maxY = metadata.originY + (image.height - rectangle.startY * scale) * metadata.resolution
return { id: `saved-map-obstacle-${index + 1}`, type: 'box', position: [(minX + maxX) / 2, (minY + maxY) / 2, mapCollisionHeightMeters / 2], rotation: [0, 0, 0], size: [Math.max(metadata.resolution, maxX - minX), Math.max(metadata.resolution, maxY - minY), mapCollisionHeightMeters] }
})
return normalizeWorldLayout({ version: 1, bounds: { width: 40, depth: 40 }, objects })
}
async function generateWorkspaceMapCollisionLayout(workspaceRoot: string | null | undefined, yamlPath: string, pgmPath: string): Promise<WorldLayout> {
const root = expandUserPath(workspaceRoot)
if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
const yamlFileName = savedMapArtifactFileName('yaml', yamlPath)
const pgmFileName = savedMapArtifactFileName('pgm', pgmPath)
const mapsDirectory = resolve(root, 'maps')
const yamlFilePath = resolve(mapsDirectory, yamlFileName)
const pgmFilePath = resolve(mapsDirectory, pgmFileName)
if (!yamlFilePath.startsWith(`${mapsDirectory}/`) || !pgmFilePath.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 맵 경로가 올바르지 않습니다.')
const layout = createMapCollisionLayout(await readFile(yamlFilePath, 'utf8'), await readFile(pgmFilePath))
await mkdir(mapsDirectory, { recursive: true })
await writeJsonAtomically(resolve(mapsDirectory, savedMapCollisionLayoutFileName(yamlFileName)), layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
function readExportedPointCloud(plyContents: Buffer): Array<[number, number, number]> {
const headerEnd = plyContents.indexOf(Buffer.from('end_header\n'))
if (headerEnd < 0 || headerEnd > 32_768) throw new Error('RTAB-Map 포인트 클라우드 헤더가 올바르지 않습니다.')
const headerLength = headerEnd + 'end_header\n'.length
const header = plyContents.subarray(0, headerLength).toString('ascii')
if (!header.includes('format binary_little_endian 1.0')) {
throw new Error('RTAB-Map 포인트 클라우드 형식을 읽을 수 없습니다.')
}
const vertexCount = Number(header.match(/^element vertex\s+(\d+)\s*$/m)?.[1])
// rtabmap-export produces x/y/z followed by five float fields.
const stride = 32
if (!Number.isInteger(vertexCount) || vertexCount <= 0 || plyContents.length < headerLength + vertexCount * stride) {
throw new Error('RTAB-Map 포인트 클라우드 데이터가 불완전합니다.')
}
const points: Array<[number, number, number]> = []
for (let index = 0; index < vertexCount; index += 1) {
const offset = headerLength + index * stride
const x = plyContents.readFloatLE(offset)
const y = plyContents.readFloatLE(offset + 4)
const z = plyContents.readFloatLE(offset + 8)
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z)) points.push([x, y, z])
}
return points
}
function createPointCloudCollisionLayout(points: Array<[number, number, number]>): WorldLayout {
const obstaclePoints = points.filter(([, , z]) => (
z >= pointCloudMinObstacleHeightMeters && z <= pointCloudMaxObstacleHeightMeters
))
if (!obstaclePoints.length) throw new Error('3D SLAM 맵에서 Gazebo 장애물로 변환할 포인트를 찾지 못했습니다.')
let voxels = new Map<string, PointCloudVoxel>()
let voxelSize = pointCloudVoxelSizes[pointCloudVoxelSizes.length - 1]
for (const candidateSize of pointCloudVoxelSizes) {
const nextVoxels = new Map<string, PointCloudVoxel>()
obstaclePoints.forEach(([x, y, z]) => {
const gridX = Math.floor(x / candidateSize)
const gridY = Math.floor(y / candidateSize)
const key = `${gridX}:${gridY}`
const current = nextVoxels.get(key)
if (current) {
current.maxZ = Math.max(current.maxZ, z)
current.samples += 1
} else {
nextVoxels.set(key, { maxZ: z, samples: 1 })
}
})
voxels = nextVoxels
voxelSize = candidateSize
if (voxels.size <= maxPointCloudCollisionObjects) break
}
if (voxels.size > maxPointCloudCollisionObjects) {
throw new Error('3D SLAM 맵의 장애물이 너무 많아 Gazebo 충돌 환경으로 변환할 수 없습니다.')
}
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
const objects = Array.from(voxels.entries())
.filter(([, voxel]) => voxel.samples >= 2)
.map(([key, voxel], index): WorldLayoutObject => {
const [gridX, gridY] = key.split(':').map(Number)
const centerX = (gridX + 0.5) * voxelSize
const centerY = (gridY + 0.5) * voxelSize
const floorZ = 0
const ceilingZ = Math.min(pointCloudMaxObstacleHeightMeters, voxel.maxZ + voxelSize / 2)
const height = Math.max(voxelSize, ceilingZ)
minX = Math.min(minX, centerX - voxelSize / 2)
minY = Math.min(minY, centerY - voxelSize / 2)
maxX = Math.max(maxX, centerX + voxelSize / 2)
maxY = Math.max(maxY, centerY + voxelSize / 2)
return {
id: `saved-3d-map-voxel-${index + 1}`,
type: 'box',
position: [centerX, centerY, floorZ + height / 2],
rotation: [0, 0, 0],
size: [voxelSize, voxelSize, height]
}
})
if (!objects.length || !Number.isFinite(minX) || !Number.isFinite(minY) || !Number.isFinite(maxX) || !Number.isFinite(maxY)) {
throw new Error('3D SLAM 맵의 유효한 장애물을 생성하지 못했습니다.')
}
const padding = 2
return normalizeWorldLayout({
version: 1,
bounds: {
width: Math.max(10, maxX - minX + padding * 2),
depth: Math.max(10, maxY - minY + padding * 2)
},
objects
})
}
async function ensureSlamRosBridgeImage(): Promise<void> {
const exists = await runCommand('docker', ['image', 'inspect', slamRosBridgeImage], undefined, undefined, 10_000)
.then(() => true)
.catch(() => false)
if (!exists) await ensureBridgeImage(slamRosBridgeImage, 'resources/slam-ros-bridge')
}
async function exportWorkspaceMapPointCloud(workspaceRoot: string, databaseFileName: string): Promise<Buffer> {
const exportDirectoryName = `.gazebo-3d-export-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
const mapsDirectory = resolve(workspaceRoot, 'maps')
const exportDirectory = resolve(mapsDirectory, exportDirectoryName)
const exportedPly = resolve(exportDirectory, 'gazebo-map_cloud.ply')
await mkdir(exportDirectory, { recursive: true })
try {
await ensureSlamRosBridgeImage()
const userId = typeof process.getuid === 'function' ? process.getuid() : 1000
const groupId = typeof process.getgid === 'function' ? process.getgid() : 1000
await runCommand('docker', [
'run', '--rm', '--user', `${userId}:${groupId}`,
'-v', `${workspaceRoot}:/workspace`,
'--entrypoint', 'bash',
slamRosBridgeImage,
'-lc', [
'source /opt/ros/humble/setup.bash',
`rtabmap-export --cloud --scan --voxel 0.08 --output gazebo-map --output_dir /workspace/maps/${exportDirectoryName} /workspace/maps/${databaseFileName}`
].join(' && ')
], undefined, undefined, 180_000)
return await readFile(exportedPly)
} finally {
await rm(exportDirectory, { recursive: true, force: true }).catch(() => undefined)
}
}
async function generateWorkspacePointCloudCollisionLayout(
workspaceRoot: string | null | undefined,
databasePath: string,
): Promise<WorldLayout> {
const root = expandUserPath(workspaceRoot)
if (!root) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
const databaseFileName = savedMapArtifactFileName('database', databasePath)
const mapsDirectory = resolve(root, 'maps')
const databaseFilePath = resolve(mapsDirectory, databaseFileName)
if (!databaseFilePath.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.')
const layoutPath = resolve(mapsDirectory, savedMapPointCloudLayoutFileName(databaseFileName))
const cachePath = resolve(mapsDirectory, savedMapPointCloudLayoutCacheFileName(databaseFileName))
const sourceStat = await stat(databaseFilePath)
try {
const cached = JSON.parse(await readFile(cachePath, 'utf8')) as Partial<PointCloudLayoutCache>
if (cached.source?.fileName === databaseFileName
&& cached.source.size === sourceStat.size
&& cached.source.modifiedAtMs === sourceStat.mtimeMs
&& cached.layout) {
const layout = normalizeWorldLayout(cached.layout)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
} catch {
// Create the cached layout below when it does not exist or is stale.
}
const layout = createPointCloudCollisionLayout(readExportedPointCloud(
await exportWorkspaceMapPointCloud(root, databaseFileName)
))
await mkdir(mapsDirectory, { recursive: true })
await writeJsonAtomically(layoutPath, layout)
await writeJsonAtomically(cachePath, {
source: { fileName: databaseFileName, size: sourceStat.size, modifiedAtMs: sourceStat.mtimeMs },
layout,
} satisfies PointCloudLayoutCache)
await writeJsonAtomically(worldLayoutPath(root), layout)
return layout
}
function savedMapArtifactFileName(name: SavedMapArtifactName, path: string): string { function savedMapArtifactFileName(name: SavedMapArtifactName, path: string): string {
const fileName = safeSavedMapFileName(path) const fileName = safeSavedMapFileName(path)
const expectedExtension = name === 'yaml' ? '.yaml' : name === 'pgm' ? '.pgm' : '.db' const expectedExtension = name === 'yaml' ? '.yaml' : name === 'pgm' ? '.pgm' : '.db'
@@ -1600,29 +1966,15 @@ async function startRemoteVisualizationBridge(
.map(([key, value]) => `-e ${quoteShellSingle(`${key}=${value}`)}`) .map(([key, value]) => `-e ${quoteShellSingle(`${key}=${value}`)}`)
.join(' ') .join(' ')
let image = resolveVisualizationBridgeImage(stack, environmentProfile?.bridge, environmentProfile?.runtimeImages) let image = resolveVisualizationBridgeImage(stack, environmentProfile?.bridge, environmentProfile?.runtimeImages)
if (stack === 'slam3d' && image === slamRosBridgeImage) { const remoteJetsonBridgeImage = environmentProfile?.runtimeImages
const jetson3dMapSaveBridgeInstalled = await runSshOutput( ?.find((artifact) => (
target, /^slam-ros-bridge:jetson_orin(?:[-:].*)?$/i.test(artifact?.image?.trim() ?? '')
'docker image inspect slam-ros-bridge:jetson_orin-3d-map-save >/dev/null 2>&1 && printf yes || true', && Boolean(artifact?.url?.trim())
10_000, ))
) ?.image
if (jetson3dMapSaveBridgeInstalled.trim() === 'yes') { ?.trim()
image = 'slam-ros-bridge:jetson_orin-3d-map-save' if (stack !== 'autoware' && image === slamRosBridgeImage && remoteJetsonBridgeImage) {
} else { image = remoteJetsonBridgeImage
const jetson3dBridgeInstalled = await runSshOutput(
target,
'docker image inspect slam-ros-bridge:jetson_orin-3d >/dev/null 2>&1 && printf yes || true',
10_000,
)
if (jetson3dBridgeInstalled.trim() === 'yes') image = 'slam-ros-bridge:jetson_orin-3d'
}
} else if (stack !== 'autoware' && image === slamRosBridgeImage) {
const jetsonBridgeInstalled = await runSshOutput(
target,
'docker image inspect slam-ros-bridge:jetson_orin >/dev/null 2>&1 && printf yes || true',
10_000,
)
if (jetsonBridgeInstalled.trim() === 'yes') image = 'slam-ros-bridge:jetson_orin'
} }
const imageArtifact = environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image) const imageArtifact = environmentProfile?.runtimeImages?.find((artifact) => artifact?.image?.trim() === image)
if (imageArtifact?.url?.trim()) { if (imageArtifact?.url?.trim()) {
@@ -1631,11 +1983,19 @@ async function startRemoteVisualizationBridge(
`IMAGE=${quoteShellSingle(image)}`, `IMAGE=${quoteShellSingle(image)}`,
`URL=${quoteShellSingle(imageArtifact.url.trim())}`, `URL=${quoteShellSingle(imageArtifact.url.trim())}`,
`FILE_NAME=${quoteShellSingle(safeArtifactFileName(imageArtifact.url.trim(), imageArtifact.fileName))}`, `FILE_NAME=${quoteShellSingle(safeArtifactFileName(imageArtifact.url.trim(), imageArtifact.fileName))}`,
`EXPECTED_SHA256=${quoteShellSingle(imageArtifact.checksumSha256?.trim().toLowerCase() ?? '')}`,
'if docker image inspect "$IMAGE" >/dev/null 2>&1; then exit 0; fi', 'if docker image inspect "$IMAGE" >/dev/null 2>&1; then exit 0; fi',
'CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', 'CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
'mkdir -p "$CACHE"', 'mkdir -p "$CACHE"',
'ARCHIVE="$CACHE/$FILE_NAME"', 'ARCHIVE="$CACHE/$FILE_NAME"',
'if [ -f "$ARCHIVE" ] && [ -n "$EXPECTED_SHA256" ]; then',
' [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" = "$EXPECTED_SHA256" ] || rm -f "$ARCHIVE"',
'fi',
'if [ ! -f "$ARCHIVE" ]; then curl -fL "$URL" -o "$ARCHIVE"; fi', 'if [ ! -f "$ARCHIVE" ]; then curl -fL "$URL" -o "$ARCHIVE"; fi',
'if [ -n "$EXPECTED_SHA256" ] && [ "$(sha256sum "$ARCHIVE" | awk \'{print $1}\')" != "$EXPECTED_SHA256" ]; then',
' echo "시각화 이미지 체크섬이 일치하지 않습니다: $FILE_NAME" >&2',
' exit 1',
'fi',
'case "$ARCHIVE" in', 'case "$ARCHIVE" in',
' *.tar.zst|*.tzst) zstd -dc "$ARCHIVE" | docker load ;;', ' *.tar.zst|*.tzst) zstd -dc "$ARCHIVE" | docker load ;;',
' *) docker load -i "$ARCHIVE" ;;', ' *) docker load -i "$ARCHIVE" ;;',
@@ -5415,7 +5775,7 @@ function registerLocalWorkspaceHandlers(): void {
const targetPath = worldLayoutPath(workspaceRoot) const targetPath = worldLayoutPath(workspaceRoot)
const normalized = normalizeWorldLayout(layout) const normalized = normalizeWorldLayout(layout)
await mkdir(dirname(targetPath), { recursive: true }) await mkdir(dirname(targetPath), { recursive: true })
await writeFile(targetPath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8') await writeJsonAtomically(targetPath, normalized)
return normalized return normalized
}) })
ipcMain.handle('local-workspace:maps:read-artifacts', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactPath[]) => ( ipcMain.handle('local-workspace:maps:read-artifacts', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactPath[]) => (
@@ -5424,6 +5784,12 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('local-workspace:maps:sync', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactDownload[]) => ( ipcMain.handle('local-workspace:maps:sync', (_event, workspaceRoot: string | null | undefined, artifacts: SavedMapArtifactDownload[]) => (
syncWorkspaceSavedMapArtifacts(workspaceRoot, artifacts) syncWorkspaceSavedMapArtifacts(workspaceRoot, artifacts)
)) ))
ipcMain.handle('local-workspace:maps:generate-collision-layout', (_event, workspaceRoot: string | null | undefined, yamlPath: string, pgmPath: string) => (
generateWorkspaceMapCollisionLayout(workspaceRoot, yamlPath, pgmPath)
))
ipcMain.handle('local-workspace:maps:generate-point-cloud-collision-layout', (_event, workspaceRoot: string | null | undefined, databasePath: string) => (
generateWorkspacePointCloudCollisionLayout(workspaceRoot, databasePath)
))
ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules))
ipcMain.handle('remote-workspace:ros-graph', (_event, target: RemoteWorkspaceTarget) => collectRemoteRosGraph(target)) ipcMain.handle('remote-workspace:ros-graph', (_event, target: RemoteWorkspaceTarget) => collectRemoteRosGraph(target))
ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ( ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => (
+3 -1
View File
@@ -219,7 +219,9 @@ contextBridge.exposeInMainWorld('localWorkspace', {
}, },
maps: { maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('local-workspace:maps:read-artifacts', workspaceRoot, artifacts), readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('local-workspace:maps:read-artifacts', workspaceRoot, artifacts),
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('local-workspace:maps:sync', workspaceRoot, artifacts) sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('local-workspace:maps:sync', workspaceRoot, artifacts),
generateCollisionLayout: (workspaceRoot: string | undefined, yamlPath: string, pgmPath: string) => ipcRenderer.invoke('local-workspace:maps:generate-collision-layout', workspaceRoot, yamlPath, pgmPath),
generatePointCloudCollisionLayout: (workspaceRoot: string | undefined, databasePath: string) => ipcRenderer.invoke('local-workspace:maps:generate-point-cloud-collision-layout', workspaceRoot, databasePath)
}, },
visualization: { visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'), status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+134 -223
View File
@@ -239,7 +239,6 @@
#downloadMap, #downloadMap,
#pathToggle, #pathToggle,
#sensorToggle, #sensorToggle,
#splatToggle,
#poseToggle, #poseToggle,
#goalToggle, #goalToggle,
#obstacleToggle, #obstacleToggle,
@@ -258,7 +257,6 @@
#downloadMap, #downloadMap,
#pathToggle, #pathToggle,
#sensorToggle, #sensorToggle,
#splatToggle,
#poseToggle, #poseToggle,
#goalToggle, #goalToggle,
#obstacleToggle { #obstacleToggle {
@@ -269,25 +267,6 @@
align-items:center; align-items:center;
gap:6px; gap:6px;
} }
#splatToggle {
display:inline-flex;
align-items:center;
justify-content:center;
position:absolute;
top:16px;
left:16px;
z-index:10;
width:14px;
min-width:14px;
min-height:14px;
height:14px;
padding:0;
border-color:rgba(203,213,225,.3);
border-radius:2px;
background:rgba(148,163,184,.3);
box-shadow:none;
}
#splatToggle:hover { background:rgba(148,163,184,.5); border-color:rgba(226,232,240,.45); }
#cameraPresetSelect { #cameraPresetSelect {
min-width:86px; min-width:86px;
padding:0 24px 0 9px; padding:0 24px 0 9px;
@@ -301,7 +280,6 @@
#downloadMap:hover, #downloadMap:hover,
#pathToggle:hover, #pathToggle:hover,
#sensorToggle:hover, #sensorToggle:hover,
#splatToggle:hover,
#poseToggle:hover, #poseToggle:hover,
#goalToggle:hover, #goalToggle:hover,
#obstacleToggle:hover, #obstacleToggle:hover,
@@ -332,10 +310,6 @@
border-color:rgba(103,232,249,.55); border-color:rgba(103,232,249,.55);
color:#fff; color:#fff;
} }
#splatToggle.active {
background:rgba(148,163,184,.62);
border-color:rgba(226,232,240,.58);
}
#goalToggle.active { #goalToggle.active {
background:rgba(22,163,74,.62); background:rgba(22,163,74,.62);
border-color:rgba(134,239,172,.55); border-color:rgba(134,239,172,.55);
@@ -388,13 +362,11 @@
.editor-mode #rosTopicStatusWrap { .editor-mode #rosTopicStatusWrap {
display:none; display:none;
} }
#splatToggle { display:none; }
.nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; } .nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; }
.nav2-navigation #downloadWrap { display:none; } .nav2-navigation #downloadWrap { display:none; }
.nav2-navigation #topLeftControls { display:block; } .nav2-navigation #topLeftControls { display:block; }
.nav2-navigation #keyGuide { display:none; } .nav2-navigation #keyGuide { display:none; }
.nav2-navigation #rosTopicStatusWrap { bottom:16px; } .nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.nav2-navigation.real-runtime #splatToggle { display:inline-flex; }
.real-runtime #topLeftControls { display:none; } .real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; } .real-runtime #keyGuide { display:none; }
</style> </style>
@@ -403,7 +375,6 @@
<div id="topLeftControls"> <div id="topLeftControls">
<button id="obstacleToggle" title="벽 배치" aria-label="벽 배치" aria-pressed="false">Wall</button> <button id="obstacleToggle" title="벽 배치" aria-label="벽 배치" aria-pressed="false">Wall</button>
</div> </div>
<button id="splatToggle" title="3D 맵 표시" aria-label="3D 맵 표시" aria-pressed="false"></button>
<div id="topRightControls"> <div id="topRightControls">
<div id="downloadWrap"> <div id="downloadWrap">
<button id="downloadMap" title="맵 저장" aria-label="맵 저장" disabled><span>SAVE</span></button> <button id="downloadMap" title="맵 저장" aria-label="맵 저장" disabled><span>SAVE</span></button>
@@ -452,8 +423,7 @@
{ {
"imports": { "imports": {
"three": "../vendor/three/build/three.module.js", "three": "../vendor/three/build/three.module.js",
"three/addons/": "../vendor/three/examples/jsm/", "three/addons/": "../vendor/three/examples/jsm/"
"@mkkellogg/gaussian-splats-3d": "./vendor/gaussian-splats-3d.module.js"
} }
} }
</script> </script>
@@ -464,7 +434,6 @@ import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js'; import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'; import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js'; import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey'); const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey');
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
@@ -479,6 +448,7 @@ const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
// regular visualization sessions never poll `/camera-frame`. // regular visualization sessions never poll `/camera-frame`.
const ENABLE_CAMERA_PREVIEW = false; const ENABLE_CAMERA_PREVIEW = false;
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map';
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
const MKMINI_WORLD_SINGLE_PILLAR = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world'; const MKMINI_WORLD_SINGLE_PILLAR = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world';
const MKMINI_RENDER_LENGTH = 0.84; const MKMINI_RENDER_LENGTH = 0.84;
@@ -502,7 +472,6 @@ const mapSaveOverlay = document.getElementById('mapSaveOverlay');
const downloadMapButton = document.getElementById('downloadMap'); const downloadMapButton = document.getElementById('downloadMap');
const pathToggleButton = document.getElementById('pathToggle'); const pathToggleButton = document.getElementById('pathToggle');
const sensorToggleButton = document.getElementById('sensorToggle'); const sensorToggleButton = document.getElementById('sensorToggle');
const splatToggleButton = document.getElementById('splatToggle');
const poseToggleButton = document.getElementById('poseToggle'); const poseToggleButton = document.getElementById('poseToggle');
const goalToggleButton = document.getElementById('goalToggle'); const goalToggleButton = document.getElementById('goalToggle');
const obstacleToggleButton = document.getElementById('obstacleToggle'); const obstacleToggleButton = document.getElementById('obstacleToggle');
@@ -513,7 +482,6 @@ const rosTopicStatusToggle = document.getElementById('rosTopicStatusToggle');
const rosTopicStatusList = document.getElementById('rosTopicStatusList'); const rosTopicStatusList = document.getElementById('rosTopicStatusList');
const EDITOR_MODE = params.get('editor') === '1'; const EDITOR_MODE = params.get('editor') === '1';
const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam'; const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam';
const SHOW_REMOTE_NAVIGATION_ROOM = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
// A navigation session must keep the selected saved map as its fixed // A navigation session must keep the selected saved map as its fixed
// reference. The runtime /map topic may later emit a partial local update. // reference. The runtime /map topic may later emit a partial local update.
const LOCK_NAVIGATION_MAP = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation'; const LOCK_NAVIGATION_MAP = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
@@ -601,10 +569,10 @@ const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.05, 1
// Three.js OrbitControls 기본 조작이 자연스럽게 동작하도록 사선 시점에서 시작합니다. // Three.js OrbitControls 기본 조작이 자연스럽게 동작하도록 사선 시점에서 시작합니다.
camera.position.set(4, 6, 5); camera.position.set(4, 6, 5);
camera.up.set(0, 1, 0); camera.up.set(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias:!SHOW_REMOTE_NAVIGATION_ROOM }); const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(innerWidth, innerHeight); renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(SHOW_REMOTE_NAVIGATION_ROOM ? 1 : Math.min(devicePixelRatio, 1.25)); renderer.setPixelRatio(Math.min(devicePixelRatio, 1.25));
renderer.shadowMap.enabled = !SHOW_REMOTE_NAVIGATION_ROOM; renderer.shadowMap.enabled = true;
renderer.domElement.tabIndex = 0; renderer.domElement.tabIndex = 0;
document.body.appendChild(renderer.domElement); document.body.appendChild(renderer.domElement);
@@ -615,7 +583,7 @@ controls.mouseButtons = {
RIGHT: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.DOLLY,
}; };
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault()); renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
controls.enableDamping = !SHOW_REMOTE_NAVIGATION_ROOM; controls.enableDamping = true;
controls.dampingFactor = 0.08; controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0); controls.target.set(0, 0, 0);
camera.lookAt(controls.target); camera.lookAt(controls.target);
@@ -658,15 +626,6 @@ gazeboWorldRoot.add(runtimeLayoutObjects);
const navigationMapRoot = new THREE.Group(); const navigationMapRoot = new THREE.Group();
navigationMapRoot.name = 'navigation-occupancy-map'; navigationMapRoot.name = 'navigation-occupancy-map';
sdfRoot.add(navigationMapRoot); sdfRoot.add(navigationMapRoot);
let remoteNavigationSplatViewer = null;
let remoteNavigationSplatScene = null;
let remoteNavigationSplatTransform = null;
let remoteNavigationSplatEnabled = false;
let remoteNavigationSplatLoading = null;
// 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다.
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.6822845855746674, 1.3107954323633903, -0.8021416967574936);
const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469);
const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587);
const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null; const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
let pointCloudLayer = null; let pointCloudLayer = null;
let hasGlobalMapPointCloud = false; let hasGlobalMapPointCloud = false;
@@ -894,6 +853,10 @@ let lastMkMiniSteeringUpdateMs = null;
let lastMkMiniMotionSample = null; let lastMkMiniMotionSample = null;
let hasSimulatorMkMiniSteering = false; let hasSimulatorMkMiniSteering = false;
let laserGroup = null; let laserGroup = null;
let laserFillMesh = null;
let laserLineSegments = null;
let laserOpenSpaceLineSegments = null;
let laserFrontBeam = null;
let slamSocket = null; let slamSocket = null;
let controlSocket = null; let controlSocket = null;
let latestSlamState = null; let latestSlamState = null;
@@ -908,6 +871,10 @@ let navigationMapLocked = false;
let navigationMapFocused = false; let navigationMapFocused = false;
let latestScanMetadata = null; let latestScanMetadata = null;
let latestScanRanges = null; let latestScanRanges = null;
let lastLaserRenderAtMs = 0;
let lastMiniMapRenderAtMs = 0;
let pendingMiniMapState = null;
let miniMapRenderTimer = null;
let pointCloudRequestInFlight = false; let pointCloudRequestInFlight = false;
let pointCloudRefreshTimer = null; let pointCloudRefreshTimer = null;
let savedMapPointCloudLoaded = false; let savedMapPointCloudLoaded = false;
@@ -1432,70 +1399,10 @@ function createMkMiniFrontWheelPivots(model) {
return pivots; return pivots;
} }
async function loadRemoteNavigationRoom() {
const viewer = new GaussianSplats3D.Viewer({
selfDrivenMode: false,
threeScene: scene,
renderer,
camera,
useBuiltInControls: false,
ignoreDevicePixelRatio: true,
gpuAcceleratedSort: false,
sharedMemoryForWorkers: false,
integerBasedSort: true,
halfPrecisionCovariancesOnGPU: true,
dynamicScene: true,
renderMode: GaussianSplats3D.RenderMode.Always,
sceneRevealMode: GaussianSplats3D.SceneRevealMode.Instant,
antialiased: false,
sphericalHarmonicsDegree: 0,
enableOptionalEffects: false,
inMemoryCompressionLevel: 2,
});
await viewer.addSplatScene('./assets/room_0731_navigation.ply', {
position: remoteNavigationSplatBasePosition.toArray(),
rotation: remoteNavigationSplatBaseQuaternion.toArray(),
scale: remoteNavigationSplatBaseScale.toArray(),
splatAlphaRemovalThreshold: 0,
showLoadingUI: false,
});
remoteNavigationSplatViewer = viewer;
remoteNavigationSplatScene = viewer.splatMesh.getScene(0);
remoteNavigationSplatTransform = null;
updateRemoteNavigationRoomTransform();
}
async function setRemoteNavigationSplatVisible(visible) {
if (!SHOW_REMOTE_NAVIGATION_ROOM) return;
splatToggleButton.disabled = true;
try {
if (visible && !remoteNavigationSplatViewer) {
if (!remoteNavigationSplatLoading) {
remoteNavigationSplatLoading = loadRemoteNavigationRoom();
}
await remoteNavigationSplatLoading;
}
remoteNavigationSplatEnabled = visible;
if (remoteNavigationSplatScene) {
remoteNavigationSplatScene.visible = visible;
if (visible) {
remoteNavigationSplatTransform = null;
updateRemoteNavigationRoomTransform();
}
}
remoteNavigationSplatViewer?.forceRenderNextFrame();
} catch (error) {
console.warn('remote navigation room render failed', error);
remoteNavigationSplatEnabled = false;
} finally {
splatToggleButton.disabled = false;
splatToggleButton.classList.toggle('active', remoteNavigationSplatEnabled);
splatToggleButton.setAttribute('aria-pressed', String(remoteNavigationSplatEnabled));
}
}
const LASER_HOLE_R = 0.14; const LASER_HOLE_R = 0.14;
const LASER_RENDER_INTERVAL_MS = 100;
const LASER_OPEN_SPACE_RAY_STRIDE = 4;
const MINI_MAP_RENDER_INTERVAL_MS = 100;
const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : -0.032; const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : -0.032;
const LASER_OFFSET_Y = 0; const LASER_OFFSET_Y = 0;
// Fallback height when an explicit ROS scan pose is unavailable. // Fallback height when an explicit ROS scan pose is unavailable.
@@ -1507,9 +1414,12 @@ function setRobotPose(pose) {
targetRobotPose = { targetRobotPose = {
x: Number(pose.x ?? targetRobotPose.x), x: Number(pose.x ?? targetRobotPose.x),
y: Number(pose.y ?? targetRobotPose.y), y: Number(pose.y ?? targetRobotPose.y),
z: Number(pose.z ?? targetRobotPose.z), // Gazebo navigation is a ground-plane simulation. A saved 3D map can
roll: Number(pose.roll ?? targetRobotPose.roll), // include an elevated RTAB-Map pose, but that must not lift the rendered
pitch: Number(pose.pitch ?? targetRobotPose.pitch), // vehicle after the learner supplies a 2D initial pose.
z: REAL_RUNTIME ? Number(pose.z ?? targetRobotPose.z) : 0,
roll: REAL_RUNTIME ? Number(pose.roll ?? targetRobotPose.roll) : 0,
pitch: REAL_RUNTIME ? Number(pose.pitch ?? targetRobotPose.pitch) : 0,
yaw: Number(pose.yaw ?? targetRobotPose.yaw), yaw: Number(pose.yaw ?? targetRobotPose.yaw),
}; };
updateMkMiniSteeringTarget(pose); updateMkMiniSteeringTarget(pose);
@@ -1750,11 +1660,72 @@ function laserDirectionFromPose(pose, angle) {
return new THREE.Vector3(Math.cos(angle), Math.sin(angle), 0).applyQuaternion(q).normalize(); return new THREE.Vector3(Math.cos(angle), Math.sin(angle), 0).applyQuaternion(q).normalize();
} }
function ensureLaserScanLayer() {
if (laserGroup) {
laserGroup.visible = true;
return;
}
laserGroup = new THREE.Group();
laserGroup.name = 'live-laser-scan';
const fillGeometry = new THREE.BufferGeometry();
laserFillMesh = new THREE.Mesh(
fillGeometry,
new THREE.MeshBasicMaterial({
color: 0x005cff,
transparent: true,
opacity: 0.6,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
laserFillMesh.frustumCulled = false;
const lineGeometry = new THREE.BufferGeometry();
laserLineSegments = new THREE.LineSegments(
lineGeometry,
new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 }),
);
laserLineSegments.frustumCulled = false;
const openSpaceGeometry = new THREE.BufferGeometry();
laserOpenSpaceLineSegments = new THREE.LineSegments(
openSpaceGeometry,
new THREE.LineBasicMaterial({ color: 0x6f9dff, transparent: true, opacity: 0.28 }),
);
laserOpenSpaceLineSegments.frustumCulled = false;
laserFrontBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.006, 0.006, 1, 16),
new THREE.MeshBasicMaterial({ color: 0x005cff }),
);
laserFrontBeam.frustumCulled = false;
laserGroup.add(laserFillMesh, laserLineSegments, laserOpenSpaceLineSegments, laserFrontBeam);
sdfRoot.add(laserGroup);
}
function updateLaserGeometry(geometry, values) {
let position = geometry.getAttribute('position');
if (!position || position.array.length < values.length) {
const capacity = Math.max(1024, Math.ceil(values.length * 1.25));
position = new THREE.BufferAttribute(new Float32Array(capacity), 3);
position.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('position', position);
}
position.array.set(values);
position.needsUpdate = true;
geometry.setDrawRange(0, values.length / 3);
}
function clearLaserScan() { function clearLaserScan() {
if (!laserGroup) return; if (!laserGroup) return;
sdfRoot.remove(laserGroup); laserGroup.visible = false;
disposeObject(laserGroup); laserFillMesh.visible = false;
laserGroup = null; laserLineSegments.visible = false;
laserOpenSpaceLineSegments.visible = false;
laserFrontBeam.visible = false;
} }
function setNavigationSensorVisible(visible) { function setNavigationSensorVisible(visible) {
@@ -1767,6 +1738,7 @@ function setNavigationSensorVisible(visible) {
window.clearInterval(sensorPointCloudRefreshTimer); window.clearInterval(sensorPointCloudRefreshTimer);
sensorPointCloudRefreshTimer = null; sensorPointCloudRefreshTimer = null;
} }
lastLaserRenderAtMs = 0;
clearLaserScan(); clearLaserScan();
clearSensorPointCloud(); clearSensorPointCloud();
drawMiniMap(latestSlamState); drawMiniMap(latestSlamState);
@@ -1775,7 +1747,7 @@ function setNavigationSensorVisible(visible) {
if (SHOW_SENSOR_POINT_CLOUD) { if (SHOW_SENSOR_POINT_CLOUD) {
void refreshSensorPointCloud(); void refreshSensorPointCloud();
if (!sensorPointCloudRefreshTimer) { if (!sensorPointCloudRefreshTimer) {
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500); sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
} }
} }
if (latestScanMetadata && latestScanRanges) { if (latestScanMetadata && latestScanRanges) {
@@ -1796,6 +1768,11 @@ function drawLaserScan(scan, scanPose = (
return; return;
} }
if (!scan?.ranges?.length) return; if (!scan?.ranges?.length) return;
// The state socket and scan binary socket can deliver the same scan nearly
// together. Rebuilding transparent scan geometry twice stalls the viewer.
const now = performance.now();
if (now - lastLaserRenderAtMs < LASER_RENDER_INTERVAL_MS) return;
lastLaserRenderAtMs = now;
clearLaserScan(); clearLaserScan();
const sourcePose = scanPose || currentRobotPose; const sourcePose = scanPose || currentRobotPose;
@@ -1830,7 +1807,7 @@ function drawLaserScan(scan, scanPose = (
endpoints.push({ start, point, hit: valid }); endpoints.push({ start, point, hit: valid });
if (valid) { if (valid) {
linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
} else if (showOpenSpaceRays) { } else if (showOpenSpaceRays && i % LASER_OPEN_SPACE_RAY_STRIDE === 0) {
// Match Gazebo's ray visualization: no-return rays remain visible, but // Match Gazebo's ray visualization: no-return rays remain visible, but
// deliberately subdued so detected obstacles stay the primary signal. // deliberately subdued so detected obstacles stay the primary signal.
openSpaceLinePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); openSpaceLinePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
@@ -1850,32 +1827,13 @@ function drawLaserScan(scan, scanPose = (
fillPoints.push(next.start.x, next.start.y, next.start.z); fillPoints.push(next.start.x, next.start.y, next.start.z);
} }
const grp = new THREE.Group(); ensureLaserScanLayer();
const fillMat = new THREE.MeshBasicMaterial({ updateLaserGeometry(laserFillMesh.geometry, fillPoints);
color: 0x005cff, laserFillMesh.visible = fillPoints.length > 0;
transparent: true, updateLaserGeometry(laserLineSegments.geometry, linePoints);
opacity: 0.6, laserLineSegments.visible = linePoints.length > 0;
side: THREE.DoubleSide, updateLaserGeometry(laserOpenSpaceLineSegments.geometry, openSpaceLinePoints);
depthWrite: false, laserOpenSpaceLineSegments.visible = openSpaceLinePoints.length > 0;
});
const lineMat = new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 });
if (fillPoints.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(fillPoints, 3));
grp.add(new THREE.Mesh(geo, fillMat));
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePoints, 3));
grp.add(new THREE.LineSegments(lineGeo, lineMat));
if (openSpaceLinePoints.length) {
const openSpaceGeo = new THREE.BufferGeometry();
openSpaceGeo.setAttribute('position', new THREE.Float32BufferAttribute(openSpaceLinePoints, 3));
grp.add(new THREE.LineSegments(
openSpaceGeo,
new THREE.LineBasicMaterial({ color: 0x6f9dff, transparent: true, opacity: 0.28 }),
));
}
const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc))); const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc)));
const front = endpoints[frontIndex]; const front = endpoints[frontIndex];
@@ -1889,22 +1847,20 @@ function drawLaserScan(scan, scanPose = (
); );
const len = delta.length(); const len = delta.length();
if (len > 0.01) { if (len > 0.01) {
const beam = new THREE.Mesh( laserFrontBeam.position.set(
new THREE.CylinderGeometry(0.006, 0.006, len, 16),
new THREE.MeshBasicMaterial({ color: 0x005cff })
);
beam.position.set(
(front.start.x + front.point.x) / 2, (front.start.x + front.point.x) / 2,
(front.start.y + front.point.y) / 2, (front.start.y + front.point.y) / 2,
(front.start.z + front.point.z) / 2 (front.start.z + front.point.z) / 2
); );
beam.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize()); laserFrontBeam.scale.set(1, len, 1);
grp.add(beam); laserFrontBeam.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
laserFrontBeam.visible = true;
} else {
laserFrontBeam.visible = false;
} }
} else {
laserFrontBeam.visible = false;
} }
laserGroup = grp;
sdfRoot.add(grp);
} }
function updatePointGeometry(geometry, points) { function updatePointGeometry(geometry, points) {
@@ -1923,6 +1879,13 @@ function updatePointGeometry(geometry, points) {
positions[count++] = z; positions[count++] = z;
} }
if (count === 0) {
geometry.deleteAttribute('position');
geometry.boundingBox = null;
geometry.boundingSphere = null;
return;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions.slice(0, count), 3)); geometry.setAttribute('position', new THREE.BufferAttribute(positions.slice(0, count), 3));
geometry.computeBoundingSphere(); geometry.computeBoundingSphere();
} }
@@ -2061,6 +2024,19 @@ async function refreshCameraFrame() {
} }
function drawMiniMap(state) { function drawMiniMap(state) {
pendingMiniMapState = state;
if (miniMapRenderTimer) return;
const elapsed = performance.now() - lastMiniMapRenderAtMs;
const wait = Math.max(0, MINI_MAP_RENDER_INTERVAL_MS - elapsed);
miniMapRenderTimer = window.setTimeout(() => {
miniMapRenderTimer = null;
lastMiniMapRenderAtMs = performance.now();
drawMiniMapNow(pendingMiniMapState);
}, wait);
}
function drawMiniMapNow(state) {
if (!miniCtx) return; if (!miniCtx) return;
const w = miniMap.width; const w = miniMap.width;
const h = miniMap.height; const h = miniMap.height;
@@ -2247,7 +2223,6 @@ function updateNavigationMapTransform() {
// estimate and must not move the static world geometry. // estimate and must not move the static world geometry.
gazeboWorldRoot.position.set(0, 0, 0); gazeboWorldRoot.position.set(0, 0, 0);
gazeboWorldRoot.rotation.set(0, 0, 0); gazeboWorldRoot.rotation.set(0, 0, 0);
updateRemoteNavigationRoomTransform();
return; return;
} }
gazeboWorldRoot.position.set(0, 0, 0); gazeboWorldRoot.position.set(0, 0, 0);
@@ -2263,61 +2238,6 @@ function updateNavigationMapTransform() {
0, 0,
); );
navigationMapRoot.rotation.set(0, 0, -yaw); navigationMapRoot.rotation.set(0, 0, -yaw);
updateRemoteNavigationRoomTransform();
}
function updateRemoteNavigationRoomTransform() {
if (!remoteNavigationSplatEnabled || !remoteNavigationSplatScene || NAV2_VIEW_MODE !== 'navigation') return;
if (navigationSceneUsesMapFrame()) {
if (remoteNavigationSplatTransform?.frame === 'map') return;
remoteNavigationSplatScene.position.copy(remoteNavigationSplatBasePosition);
remoteNavigationSplatScene.quaternion.copy(remoteNavigationSplatBaseQuaternion);
remoteNavigationSplatScene.scale.copy(remoteNavigationSplatBaseScale);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = { frame: 'map' };
return;
}
const transform = navigationMapToOdom || { x:0, y:0, yaw:0 };
if (remoteNavigationSplatTransform
&& remoteNavigationSplatTransform.frame === 'odom'
&& Math.abs(remoteNavigationSplatTransform.x - (transform.x || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.y - (transform.y || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.yaw - (transform.yaw || 0)) < 0.01) {
return;
}
const yaw = transform.yaw || 0;
const c = Math.cos(yaw);
const s = Math.sin(yaw);
// The Gaussian scene is authored in map coordinates. Compose the same
// map <- odom inverse used by the occupancy-map root before each render.
const mapToOdomMatrix = new THREE.Matrix4().compose(
new THREE.Vector3(
-c * (transform.x || 0) - s * (transform.y || 0),
0,
-s * (transform.x || 0) + c * (transform.y || 0),
),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -yaw),
new THREE.Vector3(1, 1, 1),
);
const sceneMatrix = mapToOdomMatrix.multiply(new THREE.Matrix4().compose(
remoteNavigationSplatBasePosition,
remoteNavigationSplatBaseQuaternion,
remoteNavigationSplatBaseScale,
));
sceneMatrix.decompose(
remoteNavigationSplatScene.position,
remoteNavigationSplatScene.quaternion,
remoteNavigationSplatScene.scale,
);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = {
frame: 'odom',
x: transform.x || 0,
y: transform.y || 0,
yaw,
};
} }
function renderNavigationOccupancyMap() { function renderNavigationOccupancyMap() {
@@ -2615,11 +2535,6 @@ sensorToggleButton.addEventListener('click', () => {
focusViewer(); focusViewer();
}); });
splatToggleButton.addEventListener('click', () => {
void setRemoteNavigationSplatVisible(!remoteNavigationSplatEnabled);
focusViewer();
});
cameraPreviewToggle?.addEventListener('click', () => { cameraPreviewToggle?.addEventListener('click', () => {
const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false; const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false;
cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed)); cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed));
@@ -3161,7 +3076,7 @@ function applySlamState(state) {
updateNavigationPlan(state?.plan); updateNavigationPlan(state?.plan);
// Remote navigation is rendered in the map frame, matching the saved map, // Remote navigation is rendered in the map frame, matching the saved map,
// room model, AMCL pose, route and goal. Local Gazebo remains odom-based. // room model, AMCL pose, route and goal. Local Gazebo remains odom-based.
const visualizationPose = (SHOW_POINT_CLOUD || (REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation' && state?.pose_3d)) const visualizationPose = (REAL_RUNTIME && (SHOW_POINT_CLOUD || (NAV2_VIEW_MODE === 'navigation' && state?.pose_3d)))
? (state?.pose_3d || state?.pose) ? (state?.pose_3d || state?.pose)
: (NAV2_VIEW_MODE === 'navigation' : (NAV2_VIEW_MODE === 'navigation'
? (navigationSceneUsesMapFrame() ? (state?.pose_map || state?.pose) : state?.pose) ? (navigationSceneUsesMapFrame() ? (state?.pose_map || state?.pose) : state?.pose)
@@ -3230,7 +3145,7 @@ if (SHOW_POINT_CLOUD) {
} }
if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) { if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
void refreshSensorPointCloud(); void refreshSensorPointCloud();
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500); sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
} }
if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) { if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) {
void refreshCameraFrame(); void refreshCameraFrame();
@@ -3240,6 +3155,7 @@ if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) {
window.addEventListener('beforeunload', () => { window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer); if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer); if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer); if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl); if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
}); });
@@ -3459,7 +3375,7 @@ window.addEventListener('blur', () => {
// A static Gazebo model can contain assets that the browser cannot render. // A static Gazebo model can contain assets that the browser cannot render.
// Keep the ROS stream available even when the decorative world mesh fails. // Keep the ROS stream available even when the decorative world mesh fails.
const worldSceneTasks = CUSTOM_WORLD || REAL_RUNTIME const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
? [] ? []
: [buildFromSdf().catch((error) => console.warn('world model render failed', error))]; : [buildFromSdf().catch((error) => console.warn('world model render failed', error))];
const remoteNavigationSceneTasks = []; const remoteNavigationSceneTasks = [];
@@ -3488,12 +3404,7 @@ function animate(){
updateSmoothedRobotPose(); updateSmoothedRobotPose();
updateMkMiniFrontWheelSteering(); updateMkMiniFrontWheelSteering();
controls.update(); controls.update();
if (remoteNavigationSplatViewer && remoteNavigationSplatEnabled) {
remoteNavigationSplatViewer.update();
remoteNavigationSplatViewer.render();
} else {
renderer.render(scene, camera); renderer.render(scene, camera);
}
} }
animate(); animate();
</script> </script>
File diff suppressed because it is too large Load Diff
+167 -78
View File
@@ -44,7 +44,7 @@ import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleT
import ModuleListView from './features/workspace/ModuleListView' import ModuleListView from './features/workspace/ModuleListView'
import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView' import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView'
import WorldEditorModal from './features/workspace/WorldEditorModal' import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption, type WorkspaceMapScope } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -407,7 +407,7 @@ type WorkspaceVisualizationHost = {
viewerUrl: string viewerUrl: string
onStart: () => Promise<void> onStart: () => Promise<void>
onTerminate: () => Promise<void> onTerminate: () => Promise<void>
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void
onToggleLarge: () => void onToggleLarge: () => void
onMinimize: () => void onMinimize: () => void
onClose: () => void onClose: () => void
@@ -435,6 +435,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentConfigOverride, platformEnvironmentConfigOverride,
platformOptions, platformOptions,
workspaceVisible, workspaceVisible,
visualizationContainerRef,
skipInitialLoading, skipInitialLoading,
visualizationOpen, visualizationOpen,
onVisualizationOpenChange, onVisualizationOpenChange,
@@ -484,6 +485,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentConfigOverride?: Record<string, unknown> | null platformEnvironmentConfigOverride?: Record<string, unknown> | null
platformOptions: PlatformOption[] platformOptions: PlatformOption[]
workspaceVisible: boolean workspaceVisible: boolean
visualizationContainerRef: React.RefObject<HTMLDivElement | null>
skipInitialLoading?: boolean skipInitialLoading?: boolean
visualizationOpen: boolean visualizationOpen: boolean
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean> onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
@@ -801,12 +803,16 @@ function LocalPcWorkspaceMockup({
const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected` const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected`
const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation` const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation`
const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform` const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform`
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => ( const [selectedSimulationMapKey, setSelectedSimulationMapKey] = useState(() => (
window.localStorage.getItem(sessionMapStorageKey) window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey) ?? window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey)
?? 'nav2-turtlebot3-world' ?? 'nav2-turtlebot3-world'
)) ))
const [selectedPlatformMapKey, setSelectedPlatformMapKey] = useState(() => (
window.localStorage.getItem(platformMapStorageKey)
?? window.localStorage.getItem(sessionMapStorageKey)
?? 'nav2-platform-slam-2d'
))
const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([]) const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([])
const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false) const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false)
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0) const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
@@ -814,14 +820,14 @@ function LocalPcWorkspaceMockup({
const configuredMaps = (curriculumWeek.maps ?? []) const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2') .filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
.map((map) => { .map((map) => {
if (map.key === 'nav2-turtlebot3-world') return { ...map, description: '기본 장애물 환경' } if (map.key === 'nav2-turtlebot3-world') return { ...map, scope: 'simulation' as const, description: '기본 장애물 환경' }
if (map.key === 'nav2-turtlebot3-house') return { ...map, description: '방과 복도로 구성된 실내 환경' } if (map.key === 'nav2-turtlebot3-house') return { ...map, scope: 'simulation' as const, description: '방과 복도로 구성된 실내 환경' }
return map return { ...map, scope: 'simulation' as const }
}) })
const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom') const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap
? [...configuredMaps] ? [...configuredMaps]
: [...configuredMaps, customMap] : [...configuredMaps, { ...customMap, scope: 'simulation' as const }]
const platformSlamMaps: WorkspaceMapOption[] = [ const platformSlamMaps: WorkspaceMapOption[] = [
{ {
key: 'nav2-platform-slam-2d', key: 'nav2-platform-slam-2d',
@@ -832,6 +838,7 @@ function LocalPcWorkspaceMockup({
viewerModel: 'turtlebot3_world', viewerModel: 'turtlebot3_world',
remoteSlam: true, remoteSlam: true,
runtimeKind: 'PLATFORM_2D', runtimeKind: 'PLATFORM_2D',
scope: 'platform',
}, },
{ {
key: 'nav2-platform-slam-3d', key: 'nav2-platform-slam-3d',
@@ -842,9 +849,26 @@ function LocalPcWorkspaceMockup({
viewerModel: 'turtlebot3_world', viewerModel: 'turtlebot3_world',
remoteSlam: true, remoteSlam: true,
runtimeKind: 'PLATFORM_3D', runtimeKind: 'PLATFORM_3D',
scope: 'platform',
}, },
] ]
const maps = [...baseMaps, ...platformSlamMaps, ...savedSlamMaps] const persistedMaps = savedSlamMaps.map((map) => ({
...map,
scope: map.runtimeKind === 'SIMULATION' ? 'simulation' as const : 'platform' as const,
}))
const simulationCopiesOfPlatformMaps = savedSlamMaps
.filter((map) => map.runtimeKind === 'PLATFORM_2D' || map.runtimeKind === 'PLATFORM_3D')
.map((map) => ({
...map,
key: `${map.key}:simulation`,
description: map.runtimeKind === 'PLATFORM_3D'
? '저장된 3D SLAM 결과를 복셀 Gazebo 환경에서 사용합니다.'
: '저장된 2D SLAM 결과 맵을 Gazebo 환경에서 사용합니다.',
worldFile: 'empty_world.world',
scope: 'simulation' as const,
gazeboWorldFromMap: true,
}))
const maps = [...baseMaps, ...platformSlamMaps, ...persistedMaps, ...simulationCopiesOfPlatformMaps]
const keys = new Set<string>() const keys = new Set<string>()
return maps.filter((map) => { return maps.filter((map) => {
if (keys.has(map.key)) return false if (keys.has(map.key)) return false
@@ -852,48 +876,54 @@ function LocalPcWorkspaceMockup({
return true return true
}) })
}, [curriculumWeek.maps, savedSlamMaps]) }, [curriculumWeek.maps, savedSlamMaps])
const simulationWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => map.scope === 'simulation')
), [sessionWorldMaps])
const platformWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => map.scope === 'platform')
), [sessionWorldMaps])
useEffect(() => { useEffect(() => {
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey) const legacyMapKey = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey) const normalizeMapKey = (key: string | null, scope: WorkspaceMapScope): string | null => {
?? window.localStorage.getItem(platformMapStorageKey) if (key === 'nav2-platform-slam') return scope === 'platform' ? 'nav2-platform-slam-2d' : null
const normalizedStoredMapKey = storedMapKey === 'nav2-platform-slam' return key
? 'nav2-platform-slam-2d' }
: storedMapKey const resolveSelection = (
const requestedMapKey = selectedWorldMapKey === 'nav2-platform-slam' scope: WorkspaceMapScope,
? 'nav2-platform-slam-2d' current: string,
: selectedWorldMapKey || normalizedStoredMapKey storageKey: string,
if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return maps: WorkspaceMapOption[],
setSelectedWorldMapKey((current) => { fallback: string,
const next = sessionWorldMaps.some((map) => map.key === current) ): string => {
? current const stored = normalizeMapKey(window.localStorage.getItem(storageKey) ?? legacyMapKey, scope)
: sessionWorldMaps.some((map) => map.key === normalizedStoredMapKey) const requested = normalizeMapKey(current, scope) ?? stored
? normalizedStoredMapKey! if (!savedSlamMapsLoaded && requested?.startsWith('saved-slam-')) return current
: sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world' const next = maps.some((map) => map.key === requested)
window.localStorage.setItem(sessionMapStorageKey, next) ? requested!
: maps.some((map) => map.key === stored)
? stored!
: maps.find((map) => map.key === fallback)?.key ?? maps[0]?.key ?? fallback
window.localStorage.setItem(storageKey, next)
return next return next
}) }
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey]) setSelectedSimulationMapKey((current) => resolveSelection(
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0] 'simulation', current, simulationMapStorageKey, simulationWorldMaps, 'nav2-turtlebot3-world',
))
setSelectedPlatformMapKey((current) => resolveSelection(
'platform', current, platformMapStorageKey, platformWorldMaps, 'nav2-platform-slam-2d',
))
}, [platformMapStorageKey, platformWorldMaps, savedSlamMapsLoaded, sessionMapStorageKey, simulationMapStorageKey, simulationWorldMaps])
const selectedWorldMapKey = remoteWorkspace ? selectedPlatformMapKey : selectedSimulationMapKey
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
?? (remoteWorkspace ? platformWorldMaps[0] : simulationWorldMaps[0])
const prepareVisualizationSavedMapRef = useRef<( const prepareVisualizationSavedMapRef = useRef<(
() => Promise<{ savedMapDatabasePath?: string }> () => Promise<{ savedMapDatabasePath?: string }>
) | null>(null) ) | null>(null)
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D' const savedMapRunsInSimulation = !remoteWorkspace
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D' && selectedWorldMap?.saved === true
|| selectedWorldMap?.remoteSlam === true && selectedWorldMap.gazeboWorldFromMap === true
const simulationWorldMaps = useMemo(() => ( const saved3dMapUses2dSimulationNavigation = savedMapRunsInSimulation
sessionWorldMaps.filter((map) => ( && selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
map.runtimeKind !== 'PLATFORM_2D'
&& map.runtimeKind !== 'PLATFORM_3D'
&& !map.remoteSlam
))
), [sessionWorldMaps])
const platformWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => (
map.runtimeKind === 'PLATFORM_2D'
|| map.runtimeKind === 'PLATFORM_3D'
|| map.remoteSlam
))
), [sessionWorldMaps])
const visualizationProfiles = useMemo(() => ( const visualizationProfiles = useMemo(() => (
[ [
...(curriculumWeek.environmentProfiles ?? []), ...(curriculumWeek.environmentProfiles ?? []),
@@ -1040,18 +1070,18 @@ function LocalPcWorkspaceMockup({
bridgeLocation: visualizationBridgeLocation, bridgeLocation: visualizationBridgeLocation,
bridgeWorkspaceRoot: remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview, bridgeWorkspaceRoot: remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`, sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key, mapKey: selectedWorldMap.gazeboWorldFromMap
? 'nav2-saved-gazebo-map'
: selectedWorldMap.saved
? selectedWorldMap.sourceMapKey
: selectedWorldMap.key,
platform: visualizationPlatform, platform: visualizationPlatform,
threeDimensional: Boolean( threeDimensional: Boolean(
selectedWorldMap.runtimeKind === 'PLATFORM_3D' selectedWorldMap.runtimeKind === 'PLATFORM_3D'
), ),
savedMapDatabasePath: selectedWorldMap.databasePath, savedMapDatabasePath: selectedWorldMap.databasePath,
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {}, prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D' visualizationRuntime: remoteWorkspace ? 'real' : 'simulation',
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap.remoteSlam
? 'real'
: 'simulation',
worldLayoutVersion, worldLayoutVersion,
onTitlebarNotice onTitlebarNotice
}) })
@@ -1178,7 +1208,7 @@ function LocalPcWorkspaceMockup({
databasePath, databasePath,
databaseUrl: map.databaseUrl ?? undefined, databaseUrl: map.databaseUrl ?? undefined,
runtimeKind: map.runtimeKind runtimeKind: map.runtimeKind
?? (map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'), ?? (/^nav2-platform-slam(?:-2d)?$/i.test(map.sourceMapKey) ? 'PLATFORM_2D' : 'SIMULATION'),
} }
})) }))
} catch (error) { } catch (error) {
@@ -2001,7 +2031,7 @@ function LocalPcWorkspaceMockup({
} }
} }
const getPreviewContainerSize = (): { width: number; height: number } => { const getPreviewContainerSize = (): { width: number; height: number } => {
const rect = workspaceRootRef.current?.getBoundingClientRect() const rect = visualizationContainerRef.current?.getBoundingClientRect()
return { return {
width: rect?.width ?? window.innerWidth, width: rect?.width ?? window.innerWidth,
height: rect?.height ?? window.innerHeight height: rect?.height ?? window.innerHeight
@@ -2105,24 +2135,39 @@ function LocalPcWorkspaceMockup({
window.addEventListener('mousemove', onMove) window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp) window.addEventListener('mouseup', onUp)
} }
const startPreviewDrag = (event: React.MouseEvent<HTMLDivElement>): void => { const startPreviewDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
if (previewLarge) return if (previewLarge || event.button !== 0) return
event.preventDefault() event.preventDefault()
const dragTarget = event.currentTarget
const pointerId = event.pointerId
const startX = event.clientX const startX = event.clientX
const startY = event.clientY const startY = event.clientY
const startPosition = previewPosition const startPosition = previewPosition
const onMove = (moveEvent: MouseEvent): void => { const cleanup = (): void => {
dragTarget.removeEventListener('pointermove', onMove)
dragTarget.removeEventListener('pointerup', finishDrag)
dragTarget.removeEventListener('pointercancel', finishDrag)
dragTarget.removeEventListener('lostpointercapture', finishDrag)
}
const onMove = (moveEvent: PointerEvent): void => {
if (moveEvent.pointerId !== pointerId) return
setPreviewPosition(clampPreviewPosition({ setPreviewPosition(clampPreviewPosition({
x: Math.max(12, startPosition.x + moveEvent.clientX - startX), x: Math.max(12, startPosition.x + moveEvent.clientX - startX),
y: Math.max(48, startPosition.y + moveEvent.clientY - startY) y: Math.max(48, startPosition.y + moveEvent.clientY - startY)
})) }))
} }
const onUp = (): void => { const finishDrag = (finishEvent: PointerEvent): void => {
window.removeEventListener('mousemove', onMove) if (finishEvent.pointerId !== pointerId) return
window.removeEventListener('mouseup', onUp) cleanup()
if (dragTarget.hasPointerCapture(pointerId)) {
dragTarget.releasePointerCapture(pointerId)
} }
window.addEventListener('mousemove', onMove) }
window.addEventListener('mouseup', onUp) dragTarget.setPointerCapture(pointerId)
dragTarget.addEventListener('pointermove', onMove)
dragTarget.addEventListener('pointerup', finishDrag)
dragTarget.addEventListener('pointercancel', finishDrag)
dragTarget.addEventListener('lostpointercapture', finishDrag)
} }
const startPreviewResize = (event: React.MouseEvent<HTMLDivElement>, direction: VisualizationResizeDirection): void => { const startPreviewResize = (event: React.MouseEvent<HTMLDivElement>, direction: VisualizationResizeDirection): void => {
if (previewLarge) return if (previewLarge) return
@@ -2464,8 +2509,9 @@ function LocalPcWorkspaceMockup({
{ name: 'pgm' as const, fileName: pgmFileName, url: selectedWorldMap.pgmUrl }, { name: 'pgm' as const, fileName: pgmFileName, url: selectedWorldMap.pgmUrl },
] ]
let databasePath: string | undefined let databasePath: string | undefined
let databaseFileName: string | undefined
if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) { if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) {
const databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : '' databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : ''
if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) { if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) {
throw new Error('저장된 3D SLAM 데이터가 완전하지 않습니다.') throw new Error('저장된 3D SLAM 데이터가 완전하지 않습니다.')
} }
@@ -2483,12 +2529,29 @@ function LocalPcWorkspaceMockup({
artifacts, artifacts,
) )
} }
if (!remoteWorkspace && selectedWorldMap.gazeboWorldFromMap) {
if (selectedWorldMap.runtimeKind === 'PLATFORM_3D') {
if (!databaseFileName) throw new Error('시뮬레이션할 3D SLAM 데이터베이스가 없습니다.')
await window.localWorkspace.maps.generatePointCloudCollisionLayout(clonePathPreview, databaseFileName)
} else {
await window.localWorkspace.maps.generateCollisionLayout(clonePathPreview, yamlFileName, pgmFileName)
}
}
return { return {
savedMapYamlPath: `/workspace/maps/${yamlFileName}`, savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
savedMapDatabasePath: databasePath, savedMapDatabasePath: databasePath,
} }
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation]) }, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
prepareVisualizationSavedMapRef.current = prepareSavedMap prepareVisualizationSavedMapRef.current = prepareSavedMap
const prepareModuleSavedMap = useCallback(async () => {
const preparedMap = await prepareSavedMap()
// The saved 3D database is retained for visualization and Gazebo-world
// generation. Local simulation itself localizes against the exported 2D
// occupancy map using the ordinary /scan pipeline.
return saved3dMapUses2dSimulationNavigation
? { ...preparedMap, savedMapDatabasePath: undefined }
: preparedMap
}, [prepareSavedMap, saved3dMapUses2dSimulationNavigation])
const vehicleRuntimeProfiles = useMemo(() => { const vehicleRuntimeProfiles = useMemo(() => {
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>() const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
platformOptions.forEach((platform) => { platformOptions.forEach((platform) => {
@@ -2522,10 +2585,12 @@ function LocalPcWorkspaceMockup({
workspaceActive: workspaceVisible, workspaceActive: workspaceVisible,
sourceSettings: runtimeSourceSettings, sourceSettings: runtimeSourceSettings,
rosNetwork: moduleRosNetworkConfig, rosNetwork: moduleRosNetworkConfig,
simulatorWorldFile: selectedWorldMap.worldFile, simulatorWorldFile: savedMapRunsInSimulation ? 'empty_world.world' : selectedWorldMap.worldFile,
savedMapYamlPath: selectedWorldMap.saved ? selectedWorldMap.yamlPath : null, savedMapYamlPath: selectedWorldMap.saved ? selectedWorldMap.yamlPath : null,
savedMapDatabasePath: selectedWorldMap.saved ? selectedWorldMap.databasePath : null, savedMapDatabasePath: saved3dMapUses2dSimulationNavigation
prepareSavedMap, ? null
: selectedWorldMap.saved ? selectedWorldMap.databasePath : null,
prepareSavedMap: prepareModuleSavedMap,
bulkExcludedModuleKeys: parkedModuleKeys, bulkExcludedModuleKeys: parkedModuleKeys,
activeRuntimeImages: moduleRuntimeImages, activeRuntimeImages: moduleRuntimeImages,
vehicleRuntimeProfiles, vehicleRuntimeProfiles,
@@ -2548,12 +2613,23 @@ function LocalPcWorkspaceMockup({
return () => onRegisterRunningModuleNames?.(null) return () => onRegisterRunningModuleNames?.(null)
}, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange]) }, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange])
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => { const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
window.localStorage.setItem(sessionMapStorageKey, map.key) const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
setSelectedWorldMapKey(map.key) const storageKey = scope === 'platform' ? platformMapStorageKey : simulationMapStorageKey
if (map.custom && map.layout && !remoteWorkspace) { const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
window.localStorage.setItem(storageKey, map.key)
if (scope === 'platform') {
setSelectedPlatformMapKey(map.key)
} else {
setSelectedSimulationMapKey(map.key)
}
if (map.custom && map.layout && scope === 'simulation' && !remoteWorkspace) {
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout) await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
setWorldLayoutVersion((version) => version + 1) setWorldLayoutVersion((version) => version + 1)
} }
if (!appliesToActiveWorkspace) {
onTitlebarNotice(`[${map.name}] 맵이 ${scope === 'platform' ? '플랫폼' : '시뮬레이션'} 환경에 선택되었습니다.`, 'info')
return
}
if (map.saved) { if (map.saved) {
setNav2VisualizationMode('navigation') setNav2VisualizationMode('navigation')
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2') setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
@@ -2695,7 +2771,16 @@ function LocalPcWorkspaceMockup({
if (restoredActiveModule) { if (restoredActiveModule) {
setModuleWorkspaceSnapshots(restored.moduleSnapshots) setModuleWorkspaceSnapshots(restored.moduleSnapshots)
restoreModuleSnapshot(restoredActiveModule, restored.moduleSnapshots[restoredActiveModule.name]) restoreModuleSnapshot(restoredActiveModule, restored.moduleSnapshots[restoredActiveModule.name])
setActiveIdeActivity(['search', 'logs', 'git', 'ros-connections'].includes(restored.activeIdeActivity) ? restored.activeIdeActivity : 'workspace') }
if (restored.activeIdeActivity === 'examples') {
openExampleCode()
} else if (['modules', 'ros-connections', 'settings'].includes(restored.activeIdeActivity)) {
setActiveIdeActivity(restored.activeIdeActivity)
} else if (restoredActiveModule) {
setActiveIdeActivity(['workspace', 'search', 'logs', 'git'].includes(restored.activeIdeActivity) ? restored.activeIdeActivity : 'workspace')
} else {
setActiveIdeActivity('modules')
} }
restoredWorkspaceUiRef.current = null restoredWorkspaceUiRef.current = null
@@ -3581,7 +3666,6 @@ function LocalPcWorkspaceMockup({
dark={dark} dark={dark}
modules={availableModules} modules={availableModules}
remoteWorkspace={remoteWorkspace} remoteWorkspace={remoteWorkspace}
platformName={platformName}
rosDomainId={effectiveRosDomainId} rosDomainId={effectiveRosDomainId}
onRefreshModules={() => loadAvailableModules({ silent: true })} onRefreshModules={() => loadAvailableModules({ silent: true })}
loadRosGraph={() => ( loadRosGraph={() => (
@@ -3834,10 +3918,10 @@ function LocalPcWorkspaceMockup({
{worldMapSelectorOpen && ( {worldMapSelectorOpen && (
<WorldMapSelectorModal <WorldMapSelectorModal
weekNumber={weekNumber} weekNumber={weekNumber}
initialScope={isPlatformWorldMap ? 'platform' : 'simulation'} initialScope={remoteWorkspace ? 'platform' : 'simulation'}
simulationMaps={simulationWorldMaps} simulationMaps={simulationWorldMaps}
platformMaps={platformWorldMaps} platformMaps={platformWorldMaps}
selectedMapKey={selectedWorldMapKey} selectedMapKeys={{ simulation: selectedSimulationMapKey, platform: selectedPlatformMapKey }}
onClose={() => setWorldMapSelectorOpen(false)} onClose={() => setWorldMapSelectorOpen(false)}
onCreate={() => { onCreate={() => {
if (remoteWorkspace) { if (remoteWorkspace) {
@@ -3849,8 +3933,11 @@ function LocalPcWorkspaceMockup({
onDelete={(map) => void deleteSavedSlamMap(map)} onDelete={(map) => void deleteSavedSlamMap(map)}
onSelect={(map) => { onSelect={(map) => {
setWorldMapSelectorOpen(false) setWorldMapSelectorOpen(false)
if (map.key === selectedWorldMapKey) return const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
if (availableModules.some((module) => module.state === 'running')) { const currentMapKey = scope === 'platform' ? selectedPlatformMapKey : selectedSimulationMapKey
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
if (map.key === currentMapKey) return
if (appliesToActiveWorkspace && availableModules.some((module) => module.state === 'running')) {
void applyWorldMapSelection(map) void applyWorldMapSelection(map)
setPendingWorldMapRestart(map) setPendingWorldMapRestart(map)
return return
@@ -3948,6 +4035,7 @@ function LocalPcWorkspaceMockup({
} }
export default function App(): React.JSX.Element { export default function App(): React.JSX.Element {
const appRootRef = useRef<HTMLDivElement>(null)
const restoredStudioSessionRef = useRef<StudioRuntimeSessionState | null>(readStudioRuntimeSessionState()) const restoredStudioSessionRef = useRef<StudioRuntimeSessionState | null>(readStudioRuntimeSessionState())
const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({}) const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({})
const curriculumLoadRequestRef = useRef(0) const curriculumLoadRequestRef = useRef(0)
@@ -5606,7 +5694,7 @@ export default function App(): React.JSX.Element {
}, [bootLoadingMessage]) }, [bootLoadingMessage])
return ( return (
<div className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]"> <div ref={appRootRef} className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]">
<div className="flex h-full w-full min-w-[1180px] flex-col overflow-hidden bg-[#0a0b0e]"> <div className="flex h-full w-full min-w-[1180px] flex-col overflow-hidden bg-[#0a0b0e]">
<TitleBar <TitleBar
user={authSession?.user ?? null} user={authSession?.user ?? null}
@@ -5710,6 +5798,7 @@ export default function App(): React.JSX.Element {
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride} platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
platformOptions={platformOptions} platformOptions={platformOptions}
workspaceVisible={localPcLayoutOpen} workspaceVisible={localPcLayoutOpen}
visualizationContainerRef={appRootRef}
skipInitialLoading={skipWorkspaceInitialLoading} skipInitialLoading={skipWorkspaceInitialLoading}
visualizationOpen={workspaceVisualizationOpen} visualizationOpen={workspaceVisualizationOpen}
onVisualizationOpenChange={setWorkspaceVisualizationOpen} onVisualizationOpenChange={setWorkspaceVisualizationOpen}
+2
View File
@@ -274,6 +274,8 @@ interface Window {
maps: { maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>> readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }> sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
generateCollisionLayout: (workspaceRoot: string | undefined, yamlPath: string, pgmPath: string) => Promise<WorldLayout>
generatePointCloudCollisionLayout: (workspaceRoot: string | undefined, databasePath: string) => Promise<WorldLayout>
} }
visualization: Window['visualization'] visualization: Window['visualization']
git: Window['git'] git: Window['git']
@@ -1,4 +1,4 @@
import { X } from 'lucide-react' import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import type React from 'react' import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm' import ModuleDeleteConfirm from './ModuleDeleteConfirm'
@@ -11,6 +11,8 @@ import { createParameterId, parseModuleParameters } from './moduleParameters'
import type { ModuleParameterPair, ModuleRow } from './types' import type { ModuleParameterPair, ModuleRow } from './types'
import { WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal' import { WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal'
const MODULE_GUIDE_DISMISSED_STORAGE_KEY = 'autonomy-studio.module-guide.dismissed'
type ModuleListViewProps = { type ModuleListViewProps = {
dark: boolean dark: boolean
modules: ModuleRow[] modules: ModuleRow[]
@@ -84,6 +86,7 @@ export default function ModuleListView({
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null) const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null) const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false) const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
const [moduleGuideOpen, setModuleGuideOpen] = useState(false)
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null) const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null) const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null) const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null)
@@ -97,6 +100,14 @@ export default function ModuleListView({
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__' const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => {
try {
setModuleGuideOpen(window.localStorage.getItem(MODULE_GUIDE_DISMISSED_STORAGE_KEY) !== 'true')
} catch {
setModuleGuideOpen(true)
}
}, [])
useEffect(() => { useEffect(() => {
if (!parkingHeightStorageKey) { if (!parkingHeightStorageKey) {
setParkingHeight(232) setParkingHeight(232)
@@ -200,6 +211,7 @@ export default function ModuleListView({
if (moduleAddDialogOpen) { if (moduleAddDialogOpen) {
event.preventDefault() event.preventDefault()
setModuleAddDialogOpen(false) setModuleAddDialogOpen(false)
return
} }
} }
@@ -278,8 +290,16 @@ export default function ModuleListView({
) : filteredModuleRows.length === 0 ? ( ) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} /> <ModuleListEmptyState dark={dark} />
) : filteredModuleRows.map((row) => renderModuleRow(row, false))} ) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
{!loading && allowCustomModuleCreation && ( {!loading && (
<div className={['flex justify-end px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}> <div className={['flex items-center justify-between px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
<button
type="button"
onClick={() => setModuleGuideOpen(true)}
className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
>
모듈 가이드
</button>
{allowCustomModuleCreation && (
<button <button
type="button" type="button"
onClick={() => setModuleAddDialogOpen(true)} onClick={() => setModuleAddDialogOpen(true)}
@@ -287,6 +307,7 @@ export default function ModuleListView({
> >
+ 모듈 추가 + 모듈 추가
</button> </button>
)}
</div> </div>
)} )}
</div> </div>
@@ -362,10 +383,172 @@ export default function ModuleListView({
}} }}
/> />
)} )}
{moduleGuideOpen && (
<ModuleGuideDialog
dark={dark}
onDismiss={() => {
try {
window.localStorage.setItem(MODULE_GUIDE_DISMISSED_STORAGE_KEY, 'true')
} catch {
// Keep the guide closed for the current session when storage is unavailable.
}
setModuleGuideOpen(false)
}}
/>
)}
</section> </section>
) )
} }
const moduleGuidePages = [
{
environment: '로컬 환경',
imageAlt: '로컬 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-local.png',
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'],
['Perception', 'sim_mode:=true'],
['Localization', 'localization_mode:=amcl']
]
},
{
environment: '원격 플랫폼',
imageAlt: '원격 플랫폼 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-remote-platform.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=2d'],
['Perception', 'sim_mode:=false'],
['Localization', 'localization_mode:=amcl']
]
},
{
environment: '원격 플랫폼 3D',
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-remote-3d.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=3d']
]
}
] as const
function ModuleGuideDialog({
dark,
onDismiss
}: {
dark: boolean
onDismiss: () => void
}): React.JSX.Element {
const [pageIndex, setPageIndex] = useState(0)
const page = moduleGuidePages[pageIndex]
const atFirstPage = pageIndex === 0
const atLastPage = pageIndex === moduleGuidePages.length - 1
return (
<div
className="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5"
role="dialog"
aria-modal="true"
aria-label="모듈 가이드"
>
<section
className={['relative w-full max-w-[780px] overflow-hidden rounded-lg px-7 pb-7 pt-[52px] shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'bg-[#353535] text-white' : 'bg-white text-[#111520]'].join(' ')}
>
<button
type="button"
onClick={onDismiss}
className={['absolute right-[14px] top-3 z-10 grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
aria-label="닫기"
title="닫기"
>
<X size={16} aria-hidden="true" />
</button>
<div className={['aspect-[16/8] overflow-hidden rounded-md border', dark ? 'border-white/8 bg-[#1d1d1d]' : 'border-[#e2e6ee] bg-[#f5f7fa]'].join(' ')}>
{page.imageSrc && <img src={page.imageSrc} alt={page.imageAlt} className="h-full w-full object-cover" />}
</div>
<div className="mt-5 min-h-[266px] pb-3">
<div className={dark ? 'border-b border-white/10 pb-3' : 'border-b border-[#dfe4ec] pb-3'}>
<h2 className="text-[18px] font-bold">{page.environment}</h2>
</div>
<div className="mt-4 grid grid-cols-3 gap-4">
<section>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>SLAM</h3>
<ul className={dark ? 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-white/78' : 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-[#445065]'}>
{page.slamModules.map((module) => <li key={module}>{module}</li>)}
</ul>
</section>
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>자율주행</h3>
<ul className={dark ? 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-white/78' : 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-[#445065]'}>
{page.navigationModules.map((module) => <li key={module}>{module}</li>)}
</ul>
</section>
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>파라미터</h3>
<div className="mt-2 grid gap-2">
{page.parameters.map(([label, value]) => (
<fieldset key={label} className={dark ? 'min-w-0 rounded-md border border-white/12 px-2 pb-1.5' : 'min-w-0 rounded-md border border-[#d7dee9] px-2 pb-1.5'}>
<legend className={dark ? 'ml-1 px-1 font-sans text-[10px] font-bold text-white/56' : 'ml-1 px-1 font-sans text-[10px] font-bold text-[#6d7788]'}>{label}</legend>
<code className={dark ? 'block pt-1 break-all text-[10px] leading-4 text-white/82' : 'block pt-1 break-all text-[10px] leading-4 text-[#374151]'}>{value}</code>
</fieldset>
))}
</div>
</section>
</div>
</div>
<footer className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<button
type="button"
disabled={atFirstPage}
onClick={() => setPageIndex((current) => Math.max(0, current - 1))}
className={['inline-flex h-9 w-fit cursor-pointer items-center gap-1 rounded-md px-3 text-[12px] font-bold transition disabled:cursor-not-allowed disabled:opacity-35', dark ? 'bg-white/14 text-white hover:bg-white/22' : 'bg-[#eef1f6] text-[#445065] hover:bg-[#e1e6ee]'].join(' ')}
>
<ChevronLeft size={15} aria-hidden="true" />
이전
</button>
<div className="flex items-center justify-center gap-1.5" aria-label={`가이드 ${pageIndex + 1} / ${moduleGuidePages.length}`}>
{moduleGuidePages.map((guidePage, index) => (
<button
key={guidePage.title}
type="button"
onClick={() => setPageIndex(index)}
className={[
'h-1.5 w-1.5 cursor-pointer rounded-full transition',
index === pageIndex ? 'bg-[#2f8dff]' : (dark ? 'bg-white/18 hover:bg-white/35' : 'bg-[#c9ced9] hover:bg-[#9ea7b7]')
].join(' ')}
aria-label={`${index + 1}페이지`}
aria-current={index === pageIndex ? 'page' : undefined}
/>
))}
</div>
<button
type="button"
onClick={() => {
if (atLastPage) {
onDismiss()
return
}
setPageIndex((current) => Math.min(moduleGuidePages.length - 1, current + 1))
}}
className="inline-flex h-9 w-fit cursor-pointer justify-self-end items-center gap-1 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
>
{atLastPage ? '닫기' : '다음'}
{atLastPage ? <X size={14} aria-hidden="true" /> : <ChevronRight size={15} aria-hidden="true" />}
</button>
</footer>
</section>
</div>
)
}
export type CustomModuleInput = { export type CustomModuleInput = {
name: string name: string
category: string category: string
@@ -12,7 +12,6 @@ type RosModule = {
module: ModuleRow module: ModuleRow
publishers: TopicRole[] publishers: TopicRole[]
subscribers: TopicRole[] subscribers: TopicRole[]
runtime: 'local' | 'remote'
} }
type RosConnection = { type RosConnection = {
@@ -41,7 +40,6 @@ type WorkspaceRosConnectionsViewProps = {
dark: boolean dark: boolean
modules: ModuleRow[] modules: ModuleRow[]
remoteWorkspace: boolean remoteWorkspace: boolean
platformName: string
rosDomainId: string rosDomainId: string
onRefreshModules: () => Promise<void> onRefreshModules: () => Promise<void>
loadRosGraph: () => Promise<RosGraphSnapshot> loadRosGraph: () => Promise<RosGraphSnapshot>
@@ -116,12 +114,6 @@ function moduleTopics(module: ModuleRow): Pick<RosModule, 'publishers' | 'subscr
return { publishers: [], subscribers: [] } return { publishers: [], subscribers: [] }
} }
function resolveRuntime(module: ModuleRow, remoteWorkspace: boolean): RosModule['runtime'] {
if (module.runtimeTarget === 'LOCAL') return 'local'
if (module.runtimeTarget === 'PLATFORM') return 'remote'
return remoteWorkspace ? 'remote' : 'local'
}
function getModuleNodeName(module: ModuleRow): string { function getModuleNodeName(module: ModuleRow): string {
return `/${module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}` return `/${module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`
} }
@@ -338,14 +330,13 @@ export default function WorkspaceRosConnectionsView({
dark, dark,
modules, modules,
remoteWorkspace, remoteWorkspace,
platformName,
rosDomainId, rosDomainId,
onRefreshModules, onRefreshModules,
loadRosGraph, loadRosGraph,
}: WorkspaceRosConnectionsViewProps): React.JSX.Element { }: WorkspaceRosConnectionsViewProps): React.JSX.Element {
const runningRosModules = useMemo<RosModule[]>(() => modules const runningRosModules = useMemo<RosModule[]>(() => modules
.filter((module) => module.state === 'running') .filter((module) => module.state === 'running')
.map((module) => ({ module, runtime: resolveRuntime(module, remoteWorkspace), ...moduleTopics(module) })), [modules, remoteWorkspace]) .map((module) => ({ module, ...moduleTopics(module) })), [modules])
const [selectedModuleKeys, setSelectedModuleKeys] = useState<Record<string, boolean>>({}) const [selectedModuleKeys, setSelectedModuleKeys] = useState<Record<string, boolean>>({})
const [rosGraph, setRosGraph] = useState<RosGraphSnapshot>({ topics: [] }) const [rosGraph, setRosGraph] = useState<RosGraphSnapshot>({ topics: [] })
const [rosGraphLoading, setRosGraphLoading] = useState(false) const [rosGraphLoading, setRosGraphLoading] = useState(false)
@@ -406,22 +397,16 @@ export default function WorkspaceRosConnectionsView({
} }
}, [selectedConnection, selectedTopicName]) }, [selectedConnection, selectedTopicName])
const localModules = runningRosModules.filter((module) => module.runtime === 'local')
const remoteModules = runningRosModules.filter((module) => module.runtime === 'remote')
const primaryRuntime = remoteWorkspace ? 'remote' : 'local'
const primaryModules = primaryRuntime === 'remote' ? remoteModules : localModules
const secondaryModules = primaryRuntime === 'remote' ? localModules : remoteModules
const runningModules = runningRosModules.length const runningModules = runningRosModules.length
const activeConnections = connections.filter((connection) => connection.active).length const activeConnections = connections.filter((connection) => connection.active).length
const toggleModuleConnection = (module: ModuleRow): void => { const toggleModuleConnection = (module: ModuleRow): void => {
const key = module.folderName ?? module.name const key = module.folderName ?? module.name
setSelectedModuleKeys((current) => ({ ...current, [key]: current[key] === false })) setSelectedModuleKeys((current) => ({ ...current, [key]: current[key] === false }))
} }
const setRuntimeModuleSelection = (runtime: RosModule['runtime'], selected: boolean): void => { const setWorkspaceModuleSelection = (selected: boolean): void => {
const runtimeModules = runtime === 'local' ? localModules : remoteModules
setSelectedModuleKeys((current) => { setSelectedModuleKeys((current) => {
const next = { ...current } const next = { ...current }
runtimeModules.forEach(({ module }) => { runningRosModules.forEach(({ module }) => {
next[module.folderName ?? module.name] = selected next[module.folderName ?? module.name] = selected
}) })
return next return next
@@ -499,15 +484,15 @@ export default function WorkspaceRosConnectionsView({
style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }} style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }}
> >
<div className="min-h-0 overflow-auto"> <div className="min-h-0 overflow-auto">
<div className="grid min-h-full min-w-[700px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)_minmax(200px,0.9fr)]"> <div className="grid min-h-full min-w-[520px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)]">
<RuntimeLane <RuntimeLane
dark={dark} dark={dark}
title={primaryRuntime === 'remote' ? '원격 플랫폼' : '로컬 PC'} title={remoteWorkspace ? '원격 플랫폼' : '로컬 PC'}
modules={primaryModules} modules={runningRosModules}
selectedModuleKeys={selectedModuleKeys} selectedModuleKeys={selectedModuleKeys}
onToggleModule={toggleModuleConnection} onToggleModule={toggleModuleConnection}
onSelectAll={() => setRuntimeModuleSelection(primaryRuntime, true)} onSelectAll={() => setWorkspaceModuleSelection(true)}
onClearSelection={() => setRuntimeModuleSelection(primaryRuntime, false)} onClearSelection={() => setWorkspaceModuleSelection(false)}
/> />
<section className={['min-h-0 border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}> <section className={['min-h-0 border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
@@ -562,15 +547,6 @@ export default function WorkspaceRosConnectionsView({
</div> </div>
</section> </section>
<RuntimeLane
dark={dark}
title={primaryRuntime === 'remote' ? '로컬 PC' : '원격 플랫폼'}
modules={secondaryModules}
selectedModuleKeys={selectedModuleKeys}
onToggleModule={toggleModuleConnection}
onSelectAll={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', true)}
onClearSelection={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', false)}
/>
</div> </div>
</div> </div>
@@ -1,5 +1,6 @@
import type { StudioSourceSettings } from '../platforms/types' import type { StudioSourceSettings } from '../platforms/types'
import type { PlatformOption } from '../platforms/types' import type { PlatformOption } from '../platforms/types'
import { useEffect, useRef, useState } from 'react'
import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls' import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls'
type Props = { type Props = {
@@ -29,6 +30,25 @@ function WorkspaceSettingsView({
onUpdateSourceSetting, onUpdateSourceSetting,
onSaveSourceSettings onSaveSourceSettings
}: Props): React.JSX.Element { }: Props): React.JSX.Element {
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
const saveAcknowledgedTimerRef = useRef<number | null>(null)
useEffect(() => () => {
if (saveAcknowledgedTimerRef.current !== null) {
window.clearTimeout(saveAcknowledgedTimerRef.current)
}
}, [])
const handleSave = (): void => {
if (saveDisabled || saveAcknowledged) return
setSaveAcknowledged(true)
onSaveSourceSettings()
saveAcknowledgedTimerRef.current = window.setTimeout(() => {
setSaveAcknowledged(false)
saveAcknowledgedTimerRef.current = null
}, 1000)
}
return ( return (
<section <section
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')} className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
@@ -84,6 +104,17 @@ function WorkspaceSettingsView({
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)} onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
dark={dark} dark={dark}
/> />
{sourceSettingsDraft.gpuEnabled ? (
<div className={[
'relative ml-3 rounded-md border px-3 py-2 text-[11px] font-semibold leading-relaxed',
'before:absolute before:-top-1 before:left-4 before:size-2 before:rotate-45 before:border-l before:border-t',
dark
? 'border-amber-300/20 bg-amber-300/[0.08] text-amber-100/80 before:border-amber-300/20 before:bg-[#252525]'
: 'border-amber-300/50 bg-amber-50 text-amber-900/75 before:border-amber-300/50 before:bg-amber-50'
].join(' ')}>
NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.
</div>
) : null}
</div> </div>
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}> <div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
@@ -175,11 +206,11 @@ function WorkspaceSettingsView({
<div className="mt-8 flex justify-end"> <div className="mt-8 flex justify-end">
<button <button
type="button" type="button"
disabled={saveDisabled} disabled={saveDisabled || saveAcknowledged}
onClick={onSaveSourceSettings} onClick={handleSave}
className="h-9 rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]" className="h-9 rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]"
> >
저장 {saveAcknowledged ? '저장됨' : '저장'}
</button> </button>
</div> </div>
</> </>
@@ -17,7 +17,7 @@ type WorkspaceVisualizationPanelProps = {
running: boolean running: boolean
busy: boolean busy: boolean
viewerUrl: string viewerUrl: string
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void
onToggleLarge: () => void onToggleLarge: () => void
onMinimize: () => void onMinimize: () => void
onClose: () => void onClose: () => void
@@ -78,9 +78,9 @@ export default function WorkspaceVisualizationPanel({
}} }}
> >
<div <div
onMouseDown={onDragStart} onPointerDown={onDragStart}
className={[ className={[
'flex h-9 items-center justify-between border-b px-3', 'flex h-9 select-none touch-none items-center justify-between border-b px-3',
large ? 'cursor-default' : 'cursor-move', large ? 'cursor-default' : 'cursor-move',
dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#f3f5f9]' dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#f3f5f9]'
].join(' ')} ].join(' ')}
@@ -93,6 +93,7 @@ export default function WorkspaceVisualizationPanel({
<button <button
type="button" type="button"
aria-label="최소화" aria-label="최소화"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={onMinimize} onClick={onMinimize}
className={[iconButtonClass, 'group relative'].join(' ')} className={[iconButtonClass, 'group relative'].join(' ')}
@@ -105,6 +106,7 @@ export default function WorkspaceVisualizationPanel({
<button <button
type="button" type="button"
aria-label={large ? '원래 크기' : '최대화'} aria-label={large ? '원래 크기' : '최대화'}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={onToggleLarge} onClick={onToggleLarge}
className={[iconButtonClass, 'group relative'].join(' ')} className={[iconButtonClass, 'group relative'].join(' ')}
@@ -117,6 +119,7 @@ export default function WorkspaceVisualizationPanel({
<button <button
type="button" type="button"
aria-label={running ? '시각화 종료' : '닫기'} aria-label={running ? '시각화 종료' : '닫기'}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()}
onClick={() => { onClick={() => {
if (running) setTerminateConfirmOpen(true) if (running) setTerminateConfirmOpen(true)
@@ -2,6 +2,7 @@ import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X }
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
export type SavedMapRuntimeKind = 'SIMULATION' | 'PLATFORM_2D' | 'PLATFORM_3D' export type SavedMapRuntimeKind = 'SIMULATION' | 'PLATFORM_2D' | 'PLATFORM_3D'
export type WorkspaceMapScope = 'simulation' | 'platform'
export type WorkspaceMapOption = { export type WorkspaceMapOption = {
key: string key: string
@@ -23,6 +24,8 @@ export type WorkspaceMapOption = {
databaseUrl?: string databaseUrl?: string
previewPgm?: string previewPgm?: string
runtimeKind?: SavedMapRuntimeKind runtimeKind?: SavedMapRuntimeKind
scope?: WorkspaceMapScope
gazeboWorldFromMap?: boolean
layout?: unknown layout?: unknown
remoteSlam?: boolean remoteSlam?: boolean
} }
@@ -67,7 +70,11 @@ export function WorkspaceMapPreview({
borderless?: boolean borderless?: boolean
}): React.JSX.Element { }): React.JSX.Element {
const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const saved = Boolean(map.saved || map.remoteSlam) const slamLabel = map.saved
? null
: map.runtimeKind === 'PLATFORM_3D'
? '3D SLAM'
: '2D SLAM'
const canvasRef = useRef<HTMLCanvasElement>(null) const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => { useEffect(() => {
@@ -181,7 +188,7 @@ export function WorkspaceMapPreview({
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" /> <span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</> </>
))} ))}
{saved && <span className={['absolute inset-x-0 bottom-0 bg-[#0f172a]/75 text-center font-extrabold tracking-[0.08em] text-white', compact ? 'py-px text-[6px]' : 'py-0.5 text-[8px]'].join(' ')}>{map.runtimeKind === 'PLATFORM_3D' ? '3D SLAM' : 'SLAM'}</span>} {slamLabel && <span className={['absolute inset-x-0 bottom-0 bg-[#0f172a]/75 text-center font-extrabold tracking-[0.08em] text-white', compact ? 'py-px text-[6px]' : 'py-0.5 text-[8px]'].join(' ')}>{slamLabel}</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}> <span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" /> <Icon className="size-3" />
</span> </span>
@@ -194,16 +201,17 @@ type WorldMapSelectorModalProps = {
initialScope?: 'simulation' | 'platform' initialScope?: 'simulation' | 'platform'
simulationMaps: WorkspaceMapOption[] simulationMaps: WorkspaceMapOption[]
platformMaps: WorkspaceMapOption[] platformMaps: WorkspaceMapOption[]
selectedMapKey: string selectedMapKeys: Record<WorkspaceMapScope, string>
onSelect: (map: WorkspaceMapOption) => void onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void onCreate?: () => void
onDelete?: (map: WorkspaceMapOption) => void onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void onClose: () => void
} }
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element { export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKeys, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope) const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope)
const maps = scope === 'simulation' ? simulationMaps : platformMaps const maps = scope === 'simulation' ? simulationMaps : platformMaps
const selectedMapKey = selectedMapKeys[scope]
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap) const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
@@ -217,7 +225,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
return ( return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택"> <div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl"> <section className="flex w-full max-w-[560px] max-h-[82vh] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4"> <header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div> <div>
<h2 className="text-[16px] font-bold">맵 설정</h2> <h2 className="text-[16px] font-bold">맵 설정</h2>
@@ -231,7 +239,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button> <button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button>
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button> <button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
</div> </div>
<div className="thin-y-scrollbar min-h-[320px] max-h-[320px] space-y-2 overflow-y-auto p-5"> <div className="thin-y-scrollbar min-h-[400px] max-h-[52vh] space-y-2 overflow-y-auto p-5">
{maps.map((map) => { {maps.map((map) => {
const pending = map.key === pendingMap?.key const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey const selected = map.key === selectedMapKey
@@ -244,11 +244,12 @@ export function useWorkspaceVisualization({
if (visualizationBusy) return if (visualizationBusy) return
if (mode !== nav2VisualizationMode) setNav2VisualizationMode(mode) if (mode !== nav2VisualizationMode) setNav2VisualizationMode(mode)
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중') setVisualizationStatusMessage(prepareSavedMap ? '맵 환경 준비 중' : '시작 중')
try { try {
// Download the selected map artifacts before starting the bridge. The // Download the selected map artifacts before starting the bridge. The
// bridge only receives workspace-local paths, never an R2 URL. // bridge only receives workspace-local paths, never an R2 URL.
const preparedMap = await prepareSavedMap?.() const preparedMap = await prepareSavedMap?.()
setVisualizationStatusMessage('시작 중')
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
const status = bridgeRunsOnPlatform const status = bridgeRunsOnPlatform
? bridgeTarget?.host ? bridgeTarget?.host
File diff suppressed because one or more lines are too long