Compare commits
2 Commits
f29b44da07
...
fdffd4617a
| Author | SHA1 | Date | |
|---|---|---|---|
| fdffd4617a | |||
| e1d7b7d532 |
@@ -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 주소와 원격 런타임 이미지 태그가 대상 환경과 일치하는지 확인합니다.
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.15",
|
"version": "1.1.16",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -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
@@ -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[]) => (
|
||||||
|
|||||||
@@ -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 |
Binary file not shown.
Binary file not shown.
@@ -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,13 +3404,8 @@ 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>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+167
-78
@@ -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}
|
||||||
|
|||||||
Vendored
+2
@@ -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
Reference in New Issue
Block a user