macos-native: clean branch for cloning and building the Tauri desktop app
Orphan branch with no history — includes the Tauri shell (src-tauri/) and vendored Three.js (vendor/) that were never pushed before, so a fresh clone can actually build the native app. Drops the large .pcd sample captures and editor cruft (.obsidian, .DS_Store); they aren't needed to build or run the app (point clouds load via drag-and-drop, not bundling).
@@ -0,0 +1,2 @@
|
|||||||
|
# Auto detect text files and perform LF normalization
|
||||||
|
* text=auto
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# Point cloud captures — not needed to build/run the app (drag-and-drop loads them)
|
||||||
|
*.pcd
|
||||||
|
|
||||||
|
# OS / editor cruft
|
||||||
|
.DS_Store
|
||||||
|
.obsidian/
|
||||||
|
|
||||||
|
# Rust build output and the staged web copy (regenerated by stage-web.sh)
|
||||||
|
/src-tauri/target/
|
||||||
|
/src-tauri/web/
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
# PCD Viewer 가이드
|
||||||
|
|
||||||
|
FAST-LIVO2 대시보드입니다. 두 가지로 씁니다.
|
||||||
|
|
||||||
|
1. **실시간** — FAST-LIVO2가 도는 것을 브라우저에서 그대로 봅니다.
|
||||||
|
누적되는 맵, 궤적, 현재 포즈, 카메라 영상, 상태 텔레메트리.
|
||||||
|
2. **사후 계측** — 저장된 `.pcd`를 열어 **거리 / 포지션(ΔXYZ)**을 측정합니다.
|
||||||
|
실시간으로 쌓고 있는 맵 위에서도 똑같이 측정됩니다.
|
||||||
|
|
||||||
|
**GPS를 입혀 위경도를 표시하는 과정**은 8절에 정리돼 있습니다(아직 미구현).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 파일 위치
|
||||||
|
|
||||||
|
| 항목 | 경로 |
|
||||||
|
|---|---|
|
||||||
|
| 뷰어 코드 | `~/Documents/pcd viwer/pcd_viewer.html` |
|
||||||
|
| 포인트 클라우드 | `~/Documents/pcd viwer/garam_good_lite.pcd` |
|
||||||
|
| 이 문서 | `~/Documents/pcd viwer/PCD_Viewer_가이드.md` |
|
||||||
|
|
||||||
|
뷰어는 **단일 HTML 파일**이며, Three.js(0.160)를 CDN(unpkg)에서 불러옵니다.
|
||||||
|
빌드도 설치도 없습니다. 실시간 기능도 라이브러리 없이 rosbridge 프로토콜을
|
||||||
|
직접 말합니다(roslibjs 불필요).
|
||||||
|
|
||||||
|
> `~/Downloads/pcd_viewer.html`에 예전 버전이 남아 있습니다. **Documents 쪽이 최신**입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 실행 — 뷰어 띄우기
|
||||||
|
|
||||||
|
`file://`로 직접 열면 브라우저 보안정책(CORS) 때문에 `.pcd`를 못 읽습니다.
|
||||||
|
**반드시 로컬 HTTP 서버를 띄워** 접속해야 합니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/"pcd viwer"
|
||||||
|
python3 -m http.server 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
브라우저에서:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost:8000/pcd_viewer.html
|
||||||
|
```
|
||||||
|
|
||||||
|
한 줄로 띄우고 바로 열기(macOS):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/Documents/"pcd viwer" && python3 -m http.server 8000 & sleep 1 && open "http://localhost:8000/pcd_viewer.html"
|
||||||
|
```
|
||||||
|
|
||||||
|
서버 종료:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
lsof -ti:8000 | xargs kill
|
||||||
|
```
|
||||||
|
|
||||||
|
> CDN을 쓰므로 **인터넷 연결**이 필요합니다(Three.js 로드용).
|
||||||
|
> 뷰어는 켤 때 같은 폴더의 `all_raw_points.pcd`를 자동으로 찾습니다.
|
||||||
|
> 없으면 조용히 "파일 열기" 안내로 넘어가며, **파일 없이 바로 실시간 연결**을 해도 됩니다.
|
||||||
|
|
||||||
|
### 클라우드 여는 방법 세 가지
|
||||||
|
- 같은 폴더에 `all_raw_points.pcd`를 두면 자동 로드
|
||||||
|
- `파일 열기` 버튼
|
||||||
|
- `.pcd` 파일을 창에 **드래그 앤 드롭**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 실행 — 실시간 (ROS 2 Humble)
|
||||||
|
|
||||||
|
FAST-LIVO2가 도는 그 컴퓨터에서 rosbridge를 띄우면, 브라우저가 토픽을 직접 구독합니다.
|
||||||
|
**RViz는 필요 없습니다** — 이 대시보드가 RViz 자리를 대신합니다.
|
||||||
|
|
||||||
|
### 3-1. rosbridge 설치·실행
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ros-humble-rosbridge-suite
|
||||||
|
ros2 launch rosbridge_server rosbridge_websocket_launch.xml # :9090, rosapi 포함
|
||||||
|
```
|
||||||
|
|
||||||
|
`rosapi` 노드가 함께 떠야 합니다(위 launch에 포함). 토픽 목록을 읽는 데 씁니다.
|
||||||
|
|
||||||
|
### 3-2. 연결
|
||||||
|
|
||||||
|
1. FAST-LIVO2 실행
|
||||||
|
2. 뷰어 좌측 **LIVE** 칸에 주소 입력 → `연결`
|
||||||
|
- 같은 PC에서 볼 때: `ws://localhost:9090`
|
||||||
|
- 다른 PC에서 볼 때: `ws://<호스트IP>:9090`
|
||||||
|
3. 연결되면 **토픽 4칸이 자동으로 채워집니다** (CLOUD / ODOM / PATH / IMAGE)
|
||||||
|
|
||||||
|
### 3-3. 토픽은 이름이 아니라 **타입**으로 잡습니다
|
||||||
|
|
||||||
|
연결 직후 `/rosapi/topics`로 전체 목록을 받아, 슬롯별로 **메시지 타입이 맞는 토픽만**
|
||||||
|
드롭다운에 올리고 하나를 자동 선택합니다. 이름은 우선순위 힌트로만 씁니다.
|
||||||
|
|
||||||
|
| 슬롯 | 받는 타입 | 이름 우선순위 |
|
||||||
|
|---|---|---|
|
||||||
|
| CLOUD | `sensor_msgs/msg/PointCloud2` | `cloud_registered`, `cloud_map`, `laser_map` |
|
||||||
|
| ODOM | `nav_msgs/msg/Odometry` | `aft_mapped`, `odom` |
|
||||||
|
| PATH | `nav_msgs/msg/Path` | `path` |
|
||||||
|
| IMAGE | `sensor_msgs/msg/CompressedImage` | `image`, `rgb`, `color` |
|
||||||
|
|
||||||
|
자동 선택이 틀렸으면 드롭다운에서 바꾸면 즉시 재구독합니다. `사용 안 함`으로 끌 수도 있습니다.
|
||||||
|
**해당 타입의 토픽이 하나도 없으면 그렇다고 말합니다** — 그때는 FAST-LIVO2가 실제로 돌고 있는지,
|
||||||
|
토픽이 발행 중인지(`ros2 topic list -t`)부터 확인하세요.
|
||||||
|
|
||||||
|
> **카메라가 안 보이면**: 지금은 `CompressedImage`만 받습니다.
|
||||||
|
> FAST-LIVO2가 raw `sensor_msgs/msg/Image`로 낸다면 IMAGE 칸이 비어 있습니다. 우회:
|
||||||
|
> ```bash
|
||||||
|
> ros2 run image_transport republish raw in:=/origin_img compressed out:=/origin_img/compressed
|
||||||
|
> ```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 화면 구성
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────┐
|
||||||
|
│ PCD VIEWER FAST-LIVO2 LIVE ■ 연결됨 │ PTS │ SOURCE │ ← 레일: 상태
|
||||||
|
├────────────┬─────────────────────────────────────────────┤
|
||||||
|
│ LIVE │ │
|
||||||
|
│ TELEMETRY │ 3D 뷰포트 │
|
||||||
|
│ MODE │ │
|
||||||
|
│ DISPLAY │ ┌────────────┐ │
|
||||||
|
│ ACTIONS │ │ CAMERA │ │
|
||||||
|
│ (조작 힌트) │ └────────────┘ │
|
||||||
|
└────────────┴─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **레일(상단)**: LIVE 연결 상태 · PTS(현재 점 개수) · SOURCE(파일명 또는 토픽명)
|
||||||
|
- **LIVE**: rosbridge 주소, 토픽 바인딩 4칸, 복셀 누적 설정
|
||||||
|
- **TELEMETRY**: 토픽별 수신 주파수, 복셀 유지율, 누적 버퍼 사용량
|
||||||
|
- **MODE**: 탐색 / 거리 / 포지션 + 측정 결과 리드아웃
|
||||||
|
- **DISPLAY**: 점 크기, 원본 색상
|
||||||
|
- **ACTIONS**: 파일 열기 / 뷰 맞춤 / 초기화
|
||||||
|
- **CAMERA(우하단)**: 카메라 토픽이 붙어 있을 때만 나타납니다
|
||||||
|
|
||||||
|
### 색에 대한 약속
|
||||||
|
UI는 전부 무채색입니다. **색이 보이면 그건 마커라는 뜻**입니다.
|
||||||
|
|
||||||
|
| 색 | 의미 |
|
||||||
|
|---|---|
|
||||||
|
| 🔴 적 | 거리 측정의 점 1 |
|
||||||
|
| 🟢 녹 | 거리 측정의 점 2 / 포지션 모드의 측정점 |
|
||||||
|
| 🟠 앰버 | 포지션 모드의 기준점 |
|
||||||
|
| ⚪ 흰색 | 측정선·거리 라벨, 현재 센서 포즈 |
|
||||||
|
| 회색 | 궤적(PATH), 기준점↔측정점 연결선 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 사용법
|
||||||
|
|
||||||
|
### 마우스 조작
|
||||||
|
- **좌클릭 드래그**: 회전
|
||||||
|
- **우클릭 드래그**: 이동(팬)
|
||||||
|
- **휠**: 확대/축소
|
||||||
|
- `F` 또는 `뷰 맞춤`: 클라우드 전체가 보이게 카메라 리셋
|
||||||
|
- `Esc` 또는 `초기화`: 측정 전부 삭제
|
||||||
|
|
||||||
|
> 실시간으로 맵이 자라는 동안에는 **자동으로 뷰를 맞춰줍니다.**
|
||||||
|
> 단, 카메라를 한 번이라도 건드리면 그때부터는 자동 조정을 하지 않습니다(원하는 시점 유지).
|
||||||
|
> 다시 전체를 보려면 `F`.
|
||||||
|
|
||||||
|
### 거리 측정
|
||||||
|
1. **MODE → 거리** 선택
|
||||||
|
2. 점 두 개를 차례로 클릭
|
||||||
|
3. 두 점 사이에 흰 선과 거리(m)가 표시되고, 리드아웃에 소수 4자리까지 나옵니다
|
||||||
|
4. 세 번째 클릭 또는 `초기화`로 리셋
|
||||||
|
|
||||||
|
### 포지션 (기준점 대비 ΔXYZ)
|
||||||
|
1. **MODE → 포지션** 선택
|
||||||
|
2. **첫 클릭** = 기준점(앰버) 설정
|
||||||
|
3. **이후 클릭** = 기준점 대비 `ΔX / ΔY / ΔZ` + 직선거리 표시
|
||||||
|
4. `초기화`로 리셋
|
||||||
|
|
||||||
|
> 모드는 셋 중 하나입니다. **탐색**에서는 클릭해도 점이 찍히지 않습니다(회전만).
|
||||||
|
> 측정은 **실시간 맵 위에서도 똑같이** 동작합니다.
|
||||||
|
|
||||||
|
### 표시
|
||||||
|
- **점 크기** 슬라이더
|
||||||
|
- **원본 색상** — 끄면 클라우드가 중성 회색으로 바뀝니다. 색 없는 클라우드면 비활성화됩니다.
|
||||||
|
|
||||||
|
### 복셀 누적 (LIVE 칸, 기본 켜짐 / 5cm)
|
||||||
|
실시간으로 들어오는 스캔은 서로 크게 겹칩니다. **한 복셀에 점 하나만** 남겨서
|
||||||
|
메모리가 세션 시간이 아니라 **공간 크기**에 비례하게 만듭니다. 자세한 원리는 6-2절.
|
||||||
|
|
||||||
|
- **복셀 크기**를 바꾸면 **이미 쌓인 점도 새 격자로 다시 색인**합니다.
|
||||||
|
키우면 그 자리에서 솎이고, 줄이면 버려진 점은 못 되살리지만 이후 스캔부터 촘촘해집니다.
|
||||||
|
- **끄면** 원본 그대로 쌓습니다. 짧고 고밀도인 계측에만 쓰세요 — 버퍼가 빨리 찹니다.
|
||||||
|
- TELEMETRY의 **복셀** 행에 `5cm · 유지 18.0%`처럼 유지율이 나옵니다.
|
||||||
|
유지율이 높게 유지되면(예: 60% 이상) 복셀이 너무 작다는 뜻이니 키우세요.
|
||||||
|
|
||||||
|
> 복셀은 **누적 방식**이지 표시 옵션이 아닙니다. 파일로 연 클라우드에는 적용되지 않습니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 실시간이 동작하는 방식
|
||||||
|
|
||||||
|
### 6-1. 전송
|
||||||
|
rosbridge v2 프로토콜(`subscribe` / `unsubscribe` / `call_service` / `publish`)을 WebSocket으로
|
||||||
|
직접 말합니다. 메시지는 **JSON**이고, `PointCloud2.data`와 `CompressedImage.data` 같은
|
||||||
|
`uint8[]` 필드는 **base64**로 옵니다.
|
||||||
|
|
||||||
|
- base64는 바이너리의 약 **1.33배**입니다. localhost·LAN이면 문제없습니다.
|
||||||
|
원격에서 무거우면 CBOR 구독으로 올리는 게 다음 수순입니다.
|
||||||
|
- 이미지만 10Hz로 throttle합니다. **스캔은 throttle하지 않습니다** — 버리면 맵이 손실되니까.
|
||||||
|
|
||||||
|
### 6-2. 누적 버퍼
|
||||||
|
600만 점짜리 GPU 버퍼를 **미리 한 번** 잡아두고, 들어오는 스캔을 뒤에 이어 붙입니다.
|
||||||
|
|
||||||
|
- 스캔마다 객체를 만들지 않으므로 드로우 콜은 **끝까지 하나**입니다.
|
||||||
|
- 매 프레임 전체를 올리지 않고, **새로 붙은 구간만** GPU로 올립니다.
|
||||||
|
- 600만 점(약 90MB)이 차면 **"가득 참"이라고 말하고 누적을 멈춥니다.** 조용히 버리지 않습니다.
|
||||||
|
- 더 필요하면 `pcd_viewer.html`의 `LIVE_CAP` 값을 올리세요(메모리는 점당 약 15바이트).
|
||||||
|
|
||||||
|
### 6-3. 복셀 누적 — 왜 필요한가
|
||||||
|
`/cloud_registered`는 **매 스캔을 통째로** 내보냅니다. 같은 벽을 100번 보면 같은 벽이 100번 옵니다.
|
||||||
|
그대로 쌓으면 버퍼가 **1분 남짓**에 찹니다(스캔당 1만 점 × 10Hz 기준).
|
||||||
|
|
||||||
|
그래서 점이 들어올 때마다 좌표를 복셀 격자로 양자화하고, **이미 점이 있는 복셀이면 버립니다.**
|
||||||
|
결과적으로 누적량이 **"얼마나 오래 돌았나"가 아니라 "얼마나 넓은 공간을 봤나"**에 비례합니다.
|
||||||
|
|
||||||
|
- 복셀 키는 축당 17비트로 패킹한 정수 하나입니다(±65,536 복셀 = 5cm에서 ±3.2km).
|
||||||
|
이 범위를 벗어난 점은 버리지 않고 중복 검사 없이 그냥 넣습니다.
|
||||||
|
- 유지율은 TELEMETRY의 **복셀** 행에서 실시간으로 보입니다.
|
||||||
|
|
||||||
|
### 6-4. PointCloud2 해석
|
||||||
|
`fields`의 offset을 그대로 따라 읽습니다. `x/y/z`(float32)는 필수,
|
||||||
|
색은 `rgb`(packed) → 없으면 `intensity`(회색 계조) → 없으면 균일 회색 순으로 씁니다.
|
||||||
|
`is_dense: false`인 클라우드의 NaN 점은 건너뜁니다.
|
||||||
|
|
||||||
|
### 6-5. 텔레메트리는 실측만
|
||||||
|
`CLOUD / ODOM / IMAGE`의 Hz는 **메시지 도착 간격**으로 계산합니다(EMA).
|
||||||
|
**2초간 조용하면 그럴듯한 값 대신 `—`로 떨어집니다.** 값이 보이면 실제로 오고 있다는 뜻입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 거리값이 계산되는 원리
|
||||||
|
|
||||||
|
핵심: **PCD의 각 점은 이미 미터 단위의 실측 3D 좌표를 가진다.**
|
||||||
|
|
||||||
|
### 7-1. PCD가 담고 있는 것
|
||||||
|
`garam_good_lite.pcd` 헤더:
|
||||||
|
```
|
||||||
|
FIELDS x y z rgb
|
||||||
|
TYPE F F F U
|
||||||
|
POINTS 2518822
|
||||||
|
DATA binary
|
||||||
|
```
|
||||||
|
모든 점이 `(x, y, z)` float 좌표 + 색상(rgb)을 가집니다.
|
||||||
|
이 좌표는 센서 기준 원점에서의 **실제 물리 위치(미터)**입니다.
|
||||||
|
사진(픽셀)과 달리 스케일이 이미 현실 단위라서 거리 측정이 가능합니다.
|
||||||
|
실시간 `PointCloud2`도 동일합니다 — 그래서 라이브 맵에서도 그대로 측정됩니다.
|
||||||
|
|
||||||
|
### 7-2. 클릭 → 점 찾기 (피킹)
|
||||||
|
1. 클릭 위치를 정규화 좌표(-1~1)로 변환
|
||||||
|
2. 카메라에서 그 픽셀을 지나는 **광선(ray)** 생성
|
||||||
|
3. 수백만 점 중 광선에 **수직으로 가장 가까운 점**(`distanceToRay` 최소)을 선택
|
||||||
|
→ 화면상 "커서 아래 점"
|
||||||
|
4. 그 점의 **저장된 원본 좌표** `(x, y, z)`를 읽음
|
||||||
|
|
||||||
|
> 피킹 반경은 카메라 거리에 비례합니다. 멀리서 찍으면 엉뚱한 점을 잡기 쉬우니
|
||||||
|
> **확대해서 클릭**하세요. 5픽셀 이상 움직이면 클릭이 아니라 드래그로 처리됩니다.
|
||||||
|
|
||||||
|
### 7-3. 두 점 사이 거리 = 3D 유클리드 거리
|
||||||
|
점 A `(x₁,y₁,z₁)`, 점 B `(x₂,y₂,z₂)`:
|
||||||
|
|
||||||
|
```
|
||||||
|
d = √[ (x₂-x₁)² + (y₂-y₁)² + (z₂-z₁)² ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **거리 모드**: 위 식의 `d`
|
||||||
|
- **포지션 모드의 ΔX/ΔY/ΔZ**: 제곱근 씌우기 전의 축별 성분 `(x₂-x₁)` 등
|
||||||
|
|
||||||
|
정확도는 (1) 센서 좌표 자체의 정밀도, (2) 피킹이 의도한 점을 잡았는지에 달립니다.
|
||||||
|
좌표 단위가 미터라는 가정만 맞으면 결과도 미터입니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. GPS를 입혀 위경도를 표시하는 과정 (미구현)
|
||||||
|
|
||||||
|
### 8-1. 왜 바로는 안 되는가
|
||||||
|
FAST-LIVO2 출력 포인트는 **로컬 좌표계(미터)**입니다.
|
||||||
|
- 원점 = SLAM을 시작한 위치
|
||||||
|
- 축 = 시작 시점의 자세 기준 (IMU 중력 정렬 → **Z축은 중력 반대 방향**, 스케일 ≈ 1m)
|
||||||
|
- 즉 `(x, y, z)`는 "출발점 대비 상대 위치"일 뿐, 지구상 위치 정보는 없음
|
||||||
|
|
||||||
|
위경도로 바꾸려면 **로컬 좌표계를 지구 좌표계에 정렬**(지오레퍼런싱)해야 합니다.
|
||||||
|
|
||||||
|
### 8-2. 구해야 하는 변환
|
||||||
|
실질 미지수는 두 가지:
|
||||||
|
1. **기준 원점의 위경도** `(lat₀, lon₀, alt₀)`
|
||||||
|
2. **헤딩(yaw 회전)** — 로컬 X/Y축이 동/북 대비 돌아간 각도
|
||||||
|
|
||||||
|
(Z는 이미 중력 정렬되어 있어 roll/pitch는 거의 0)
|
||||||
|
|
||||||
|
변환 흐름:
|
||||||
|
```
|
||||||
|
로컬 (x,y,z)
|
||||||
|
│ 회전 + 평행이동
|
||||||
|
▼
|
||||||
|
ENU (동, 북, 상 / 미터)
|
||||||
|
│ 측지 변환 (ENU → ECEF → geodetic)
|
||||||
|
▼
|
||||||
|
(위도, 경도, 고도)
|
||||||
|
```
|
||||||
|
ENU↔위경도 변환은 표준 공식이며, 작은 영역이면 근사식으로도 충분합니다.
|
||||||
|
|
||||||
|
### 8-3. 변환을 구하는 방법 (정확도 순)
|
||||||
|
|
||||||
|
| 방법 | 필요 데이터 | 정확도 |
|
||||||
|
|---|---|---|
|
||||||
|
| **A. 시간동기 GPS↔궤적 정합** | FAST-LIVO2 타임스탬프 궤적(pose) + GPS 로그(lat/lon/alt) | GPS 품질 의존. RTK면 cm급, 일반 GPS면 수 m |
|
||||||
|
| **B. 기준점 수동 지정** | 맵 내 한 점의 실제 위경도 + 진행 방향(헤딩) | 헤딩 정확도에 민감, 수 m~수십 m |
|
||||||
|
| **C. SLAM 단계 GPS 융합** | GPS 팩터를 그래프에 넣어 재최적화(LIO-SAM/GLIM 등) | 가장 일관적·정확 |
|
||||||
|
|
||||||
|
- **방법 A**가 일반적: 궤적의 각 시점 위치와 같은 시각 GPS의 ENU 위치를 짝지어,
|
||||||
|
둘을 가장 잘 겹치는 **강체변환(또는 7-DOF 유사변환)**을
|
||||||
|
최소제곱(Umeyama/Kabsch)으로 계산. 충분한 이동과 **회전(코너)**이 있어야
|
||||||
|
헤딩이 잘 잡힘.
|
||||||
|
- FAST-LIVO2는 GPS를 직접 융합하지 않으므로, GPS는 **사후 정합** 또는
|
||||||
|
**외부 그래프 최적화**로 붙여야 함.
|
||||||
|
|
||||||
|
### 8-4. 뷰어 쪽 구현 방향
|
||||||
|
변환만 정해지면 뷰어 추가는 간단합니다.
|
||||||
|
- 클릭 시 이미 로컬 `(x,y,z)`를 읽고 있으므로, 거기에 변환을 적용해
|
||||||
|
**위/경도/고도를 리드아웃에 함께 표시**.
|
||||||
|
- 점마다 위경도를 굽지 않고, `{ 원점 lat/lon/alt, 헤딩, 스케일 }` 설정 한 벌만
|
||||||
|
저장해두고 클릭 시 실시간 변환 → 가볍고 정확.
|
||||||
|
- 실시간에도 그대로 적용됩니다. 대시보드에 `GEO` 슬롯(예: `sensor_msgs/msg/NavSatFix`)을
|
||||||
|
하나 더 두면 궤적↔GPS 정합을 세션 중에 계산할 수도 있습니다.
|
||||||
|
|
||||||
|
### 8-5. 진행을 위해 필요한 것
|
||||||
|
- **궤적 파일 + GPS 로그**가 있으면 → 방법 A로 변환 계산 + 뷰어 위경도 표시
|
||||||
|
(포맷: TUM/KITTI pose, NMEA, CSV 등)
|
||||||
|
- **기준점 위경도 + 헤딩만 알면** → 방법 B로 설정값 입력 UI 추가
|
||||||
|
- 우선 **위경도 표시 틀만** 먼저 넣고 설정값은 나중에 채워도 됨
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 문제가 생기면
|
||||||
|
|
||||||
|
| 증상 | 확인할 것 |
|
||||||
|
|---|---|
|
||||||
|
| 화면이 검고 "뷰어를 불러오지 못했습니다" | 인터넷 연결(Three.js CDN). HTTP 서버로 열었는지 |
|
||||||
|
| `.pcd`가 안 열림 | `file://`로 연 건 아닌지. 8000 포트 서버로 접속 |
|
||||||
|
| `연결 실패` | 해당 주소에 rosbridge가 떠 있는지. 원격이면 방화벽 9090 |
|
||||||
|
| `rosapi 응답이 없습니다` | rosbridge에 rosapi 노드가 함께 떠 있는지(launch 파일 확인) |
|
||||||
|
| 토픽 목록이 비어 있음 | FAST-LIVO2가 실제로 발행 중인지 — `ros2 topic list -t` |
|
||||||
|
| CAMERA가 안 뜸 | 토픽이 raw `Image`일 수 있음 → 3-3의 republish 참고 |
|
||||||
|
| Hz가 `—` | 그 토픽이 2초 넘게 조용하다는 뜻(정상 표시). 발행 측 확인 |
|
||||||
|
| `버퍼 가득 참` | **복셀 크기를 키우세요**(즉시 솎임). 복셀이 꺼져 있으면 켜기. 그래도 부족하면 `LIVE_CAP` 상향 |
|
||||||
|
| 맵이 너무 성김 | 복셀 크기를 줄이세요. 이미 버린 점은 안 돌아오지만 이후 스캔부터 촘촘해집니다 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 요약
|
||||||
|
|
||||||
|
> **실시간**: rosbridge로 토픽을 직접 구독 → 스캔을 **복셀 단위로 솎아** 한 버퍼에 누적 →
|
||||||
|
> RViz 없이 브라우저에서 봄. 누적량은 돌린 시간이 아니라 본 공간 크기에 비례.
|
||||||
|
> 텔레메트리는 실측만 표시하고, 조용하면 조용하다고 말함.
|
||||||
|
>
|
||||||
|
> **계측**: PCD = 실측 3D 좌표(미터)의 집합 → 클릭으로 두 점 좌표를 집어 → 피타고라스(3D)로 거리 계산.
|
||||||
|
> 파일이든 라이브 맵이든 동일.
|
||||||
|
>
|
||||||
|
> **위경도**: 별도 GPS 데이터로 로컬↔지구 좌표 변환(지오레퍼런싱)을 한 번 구해두면 표시 가능. 아직 미구현.
|
||||||
@@ -0,0 +1,252 @@
|
|||||||
|
# ROS2 PC 이관 체크리스트
|
||||||
|
|
||||||
|
이 대시보드는 **Mac에서 만들고, 가짜(mock) rosbridge 서버로 검증**했습니다.
|
||||||
|
FAST-LIVO2가 실제로 도는 ROS 2 Humble PC로 옮겨서 마무리해야 할 것들을 정리합니다.
|
||||||
|
|
||||||
|
사용법 자체는 `PCD_Viewer_가이드.md`에 있습니다. 이 문서는 **이관과 남은 작업**만 다룹니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 지금 어디까지 확실한가
|
||||||
|
|
||||||
|
정직하게 갈라두면 실기에서 삽질이 줄어듭니다.
|
||||||
|
|
||||||
|
| 항목 | 상태 |
|
||||||
|
|---|---|
|
||||||
|
| UI·측정(거리/포지션)·파일 로드 | ✅ 실제 PCD로 확인 |
|
||||||
|
| rosbridge 프로토콜, 토픽 자동 바인딩, 누적, 카메라, 텔레메트리 | ⚠️ **목 서버로만 확인** — 실기 미검증 |
|
||||||
|
| 라이브 맵 위 측정 | ⚠️ 목 서버 데이터로 확인 |
|
||||||
|
| 복셀 다운샘플 누적 + 재색인 | ⚠️ 목 서버로 확인 (25초 세션에서 유지율 41%→20%로 수렴) |
|
||||||
|
| 오프라인(CDN 차단) 구동 | ✅ unpkg 차단 상태로 확인 (3절) |
|
||||||
|
| 실제 FAST-LIVO2 토픽 이름·타입·필드 | ❌ **미확인 — 4절에서 기록할 것** |
|
||||||
|
| 실제 환경에서의 복셀 유지율·적정 크기 | ❌ 미확인 — 실기에서 6절 보고 조정 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 옮길 파일
|
||||||
|
|
||||||
|
```
|
||||||
|
pcd_viewer.html ← 대시보드 본체 (이거 하나면 동작)
|
||||||
|
PCD_Viewer_가이드.md ← 사용법
|
||||||
|
ROS2_이관_체크리스트.md ← 이 문서
|
||||||
|
tools/mock-rosbridge.mjs ← 로봇 없이 테스트용 (선택, Node 필요)
|
||||||
|
```
|
||||||
|
|
||||||
|
`.pcd`는 옮길 필요 없습니다(용량만 큼). 실시간으로 볼 거고, 파일이 필요하면 그 PC에서 생성됩니다.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scp -r ~/Documents/"pcd viwer" user@ros2-pc:~/pcd_dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 호스트 세팅
|
||||||
|
|
||||||
|
### 2-1. rosbridge
|
||||||
|
```bash
|
||||||
|
sudo apt install ros-humble-rosbridge-suite
|
||||||
|
ros2 launch rosbridge_server rosbridge_websocket_launch.xml # :9090
|
||||||
|
```
|
||||||
|
- `rosapi` 노드가 함께 떠야 합니다(위 launch에 포함). 토픽 목록을 읽는 데 씁니다.
|
||||||
|
- 확인: `ros2 node list | grep -E "rosbridge|rosapi"`
|
||||||
|
|
||||||
|
### 2-2. 뷰어 서빙
|
||||||
|
```bash
|
||||||
|
cd ~/pcd_dashboard
|
||||||
|
python3 -m http.server 8000 # 0.0.0.0 바인딩(기본) → 다른 PC에서도 접속 가능
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2-3. 다른 PC에서 볼 때
|
||||||
|
- 브라우저: `http://<호스트IP>:8000/pcd_viewer.html`
|
||||||
|
- LIVE 주소: `ws://<호스트IP>:9090` ← **localhost 아님**
|
||||||
|
- 방화벽: 8000, 9090 열기
|
||||||
|
```bash
|
||||||
|
sudo ufw allow 8000/tcp && sudo ufw allow 9090/tcp
|
||||||
|
```
|
||||||
|
|
||||||
|
> **보안**: rosbridge는 인증이 없습니다. 붙는 사람이 토픽을 발행할 수도 있습니다.
|
||||||
|
> 신뢰된 LAN 안에서만 쓰고, 공인 IP로 열지 마세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 로봇 PC가 오프라인이면 (Three.js 로컬화)
|
||||||
|
|
||||||
|
기본값은 CDN(unpkg)에서 Three.js를 받습니다. 인터넷이 없으면 뷰어가 아예 안 뜹니다.
|
||||||
|
**아래 절차는 unpkg를 차단한 상태에서 실제로 검증했습니다.**
|
||||||
|
|
||||||
|
인터넷 되는 PC에서 받아서 같이 옮기면 됩니다:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/pcd_dashboard
|
||||||
|
mkdir -p vendor/three/controls vendor/three/loaders
|
||||||
|
curl -L -o vendor/three/three.module.js https://unpkg.com/three@0.160.0/build/three.module.js
|
||||||
|
curl -L -o vendor/three/controls/OrbitControls.js https://unpkg.com/three@0.160.0/examples/jsm/controls/OrbitControls.js
|
||||||
|
curl -L -o vendor/three/loaders/PCDLoader.js https://unpkg.com/three@0.160.0/examples/jsm/loaders/PCDLoader.js
|
||||||
|
```
|
||||||
|
|
||||||
|
`pcd_viewer.html`의 importmap을 이렇게 고칩니다 (디렉터리 구조를 지켜야 addon들이 `three`를 찾습니다):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="importmap">
|
||||||
|
{
|
||||||
|
"imports": {
|
||||||
|
"three": "./vendor/three/three.module.js",
|
||||||
|
"three/addons/": "./vendor/three/"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 첫 연결 — 순서와 기록할 것
|
||||||
|
|
||||||
|
### 4-1. 먼저 ROS 쪽에서 확인
|
||||||
|
```bash
|
||||||
|
ros2 topic list -t # 이름과 타입을 함께
|
||||||
|
ros2 topic hz /cloud_registered # 발행 주파수
|
||||||
|
ros2 topic echo /cloud_registered --field fields --once # x/y/z/rgb/intensity 구성
|
||||||
|
ros2 topic echo /cloud_registered --field width --once # 스캔당 점 개수
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4-2. 여기에 실제 값을 적어두세요 (지금은 추정)
|
||||||
|
|
||||||
|
| 슬롯 | 뷰어가 기대하는 타입 | 실제 토픽 이름 | 실제 타입 | 비고 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| CLOUD | `sensor_msgs/msg/PointCloud2` | (기록) | | 점/스캔: ___ , Hz: ___ |
|
||||||
|
| ODOM | `nav_msgs/msg/Odometry` | (기록) | | |
|
||||||
|
| PATH | `nav_msgs/msg/Path` | (기록) | | 없을 수도 있음 |
|
||||||
|
| IMAGE | `sensor_msgs/msg/CompressedImage` | (기록) | | raw `Image`면 5-1 |
|
||||||
|
|
||||||
|
> 뷰어는 **이름이 아니라 타입으로** 토픽을 찾습니다. 이름이 달라도 타입만 맞으면 드롭다운에 뜹니다.
|
||||||
|
> 자동 선택이 틀리면 드롭다운에서 바꾸면 됩니다.
|
||||||
|
|
||||||
|
### 4-3. 대시보드 연결
|
||||||
|
1. 브라우저 → LIVE 칸에 `ws://localhost:9090` → `연결`
|
||||||
|
2. **토픽 4칸이 채워지는지** 확인
|
||||||
|
3. **TELEMETRY의 Hz가 뜨는지** 확인 (`—`면 그 토픽은 조용하다는 뜻)
|
||||||
|
4. 맵이 자라는지, `F`로 뷰가 맞는지
|
||||||
|
5. 거리 모드로 라이브 맵 위 두 점 측정
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 실기에서 걸릴 만한 것들
|
||||||
|
|
||||||
|
### 5-1. 카메라가 안 뜬다 — 가능성 높음
|
||||||
|
지금은 `CompressedImage`만 받습니다. FAST-LIVO2는 `/origin_img`를 **raw `sensor_msgs/msg/Image`**로
|
||||||
|
낼 수 있고, 그러면 IMAGE 드롭다운이 비어 있습니다.
|
||||||
|
|
||||||
|
우회(권장 — 대역폭도 훨씬 유리):
|
||||||
|
```bash
|
||||||
|
ros2 run image_transport republish raw in:=/origin_img compressed out:=/origin_img/compressed
|
||||||
|
```
|
||||||
|
근본 해결: 뷰어에 raw Image 디코딩 추가 (7절).
|
||||||
|
|
||||||
|
### 5-2. 클라우드에 rgb가 없다
|
||||||
|
`rgb`가 없으면 `intensity`를 회색 계조로 씁니다. 둘 다 없으면 균일 회색입니다.
|
||||||
|
FAST-LIVO2는 컬러 맵을 내므로 보통 `rgb`가 있지만, 설정에 따라 다릅니다.
|
||||||
|
|
||||||
|
### 5-3. 대역폭
|
||||||
|
전송은 JSON + base64라 바이너리의 약 1.33배입니다. 점당 16바이트면 **점당 약 21바이트**.
|
||||||
|
|
||||||
|
```
|
||||||
|
대역폭 ≈ 점/스캔 × Hz × 21 B/점
|
||||||
|
예) 10,000 × 10 Hz × 21 B ≈ 2.1 MB/s ≈ 17 Mbps
|
||||||
|
30,000 × 10 Hz × 21 B ≈ 6.4 MB/s ≈ 51 Mbps
|
||||||
|
```
|
||||||
|
같은 PC(localhost)나 유선 LAN이면 괜찮고, **Wi-Fi로 원격에서 보면 빠듯**합니다.
|
||||||
|
|
||||||
|
### 5-4. `rosapi 응답이 없습니다`
|
||||||
|
rosbridge는 떴는데 rosapi가 없는 경우입니다. launch 파일을 확인하거나
|
||||||
|
`ros2 run rosapi rosapi_node`를 따로 띄우세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 누적 버퍼와 복셀 크기 정하기
|
||||||
|
|
||||||
|
`/cloud_registered`는 **매 스캔을 통째로** 내보내고 스캔끼리 공간이 크게 겹칩니다.
|
||||||
|
그대로 쌓으면 6M 버퍼가 **1분 남짓**에 찹니다(1만 점 × 10Hz 기준).
|
||||||
|
|
||||||
|
그래서 **복셀 누적이 기본으로 켜져 있습니다**(5cm). 이미 점이 있는 복셀의 점은 버리므로,
|
||||||
|
누적량이 **돌린 시간이 아니라 본 공간 크기**에 비례합니다. 원리는 가이드 6-3절.
|
||||||
|
|
||||||
|
### 6-1. 실기에서 할 일: 유지율 보고 크기 정하기
|
||||||
|
TELEMETRY의 **복셀** 행에 `5cm · 유지 18.0%`처럼 나옵니다.
|
||||||
|
|
||||||
|
| 유지율이 | 뜻 | 조치 |
|
||||||
|
|---|---|---|
|
||||||
|
| 계속 50~100% | 복셀이 너무 작아 사실상 원본을 쌓는 중 | 크기를 키우세요 |
|
||||||
|
| 시간이 갈수록 하강·수렴 | 정상. 새로 보는 공간만 늘고 있음 | 그대로 |
|
||||||
|
| 5% 이하인데 맵이 성김 | 복셀이 너무 큼 | 줄이세요 |
|
||||||
|
|
||||||
|
크기를 바꾸면 **이미 쌓인 점도 즉시 재색인**됩니다(키우면 그 자리에서 솎임).
|
||||||
|
목 서버 기준 20cm → 50cm 재색인에서 108,690점 → 7,799점(7.2%)으로 줄었습니다.
|
||||||
|
|
||||||
|
### 6-2. 그래도 부족하면
|
||||||
|
```
|
||||||
|
버퍼가 차는 시간 ≈ LIVE_CAP / (점/스캔 × Hz × 유지율)
|
||||||
|
```
|
||||||
|
- **LIVE_CAP 상향**: `pcd_viewer.html`의 `const LIVE_CAP = 6_000_000;` → 예: `40_000_000`.
|
||||||
|
메모리는 **점당 약 15바이트**(40M ≈ 600MB). 좋은 PC면 감당됩니다.
|
||||||
|
- **누적 맵 토픽 구독**: FAST-LIVO2가 누적 맵 토픽(`/cloud_map` 등)을 낸다면,
|
||||||
|
그걸 물리고 **매번 통째로 교체**하는 방식이 더 쌀 수 있습니다. → 4-2에서 실제 토픽 확인.
|
||||||
|
|
||||||
|
> 복셀을 **끄면** 원본을 그대로 쌓습니다. 짧고 고밀도인 계측용입니다. 버퍼는 빨리 찹니다.
|
||||||
|
> 가득 차면 조용히 버리지 않고 **"가득 참"이라고 말하고 멈춥니다.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 남은 개발 항목 (우선순위 순)
|
||||||
|
|
||||||
|
> **완료**: 복셀 다운샘플 누적(크기 슬라이더 + 재색인 포함). 6절 참고.
|
||||||
|
> 다만 복셀 인덱스는 `Set`이라 점 하나당 수십 바이트를 씁니다. 유지 점수가 수백만을
|
||||||
|
> 넘어가면 타입드 배열 해시로 바꾸는 게 다음 최적화입니다(지금은 필요 없을 가능성이 큼).
|
||||||
|
|
||||||
|
### 7-1. raw `sensor_msgs/msg/Image` 지원
|
||||||
|
- `CompressedImage`가 없는 환경 대비(5-1). rgb8/bgr8/mono8 → canvas ImageData.
|
||||||
|
- 대역폭이 크므로 throttle 필수.
|
||||||
|
|
||||||
|
### 7-2. CBOR 전송
|
||||||
|
- `subscribe`에 `compression: 'cbor'` + CBOR 디코더. base64 오버헤드(1.33배)를 없앱니다.
|
||||||
|
- 원격에서 볼 때만 의미 있음. localhost면 후순위.
|
||||||
|
|
||||||
|
### 7-3. 라이브 맵 저장
|
||||||
|
- 지금은 누적본을 **내보낼 수 없습니다**. FAST-LIVO2가 자체 저장하므로 필수는 아니지만,
|
||||||
|
"지금 화면의 이 맵"을 `.pcd`로 떨구는 버튼이 있으면 계측 워크플로에 유용합니다.
|
||||||
|
|
||||||
|
### 7-4. GPS 지오레퍼런싱 (위경도 표시)
|
||||||
|
- 가이드 8절 참고. 실시간이 붙었으므로 `sensor_msgs/msg/NavSatFix` 슬롯을 하나 더 두면
|
||||||
|
세션 중에 궤적↔GPS 정합을 계산할 수 있습니다.
|
||||||
|
|
||||||
|
### 7-5. 기타 후보
|
||||||
|
- 화면 하단 미니맵(위에서 본 궤적), 뷰 프리셋(Top/Front/Side)
|
||||||
|
- 여러 세션 비교(파일 + 라이브 동시 표시) — 지금은 활성 클라우드가 하나뿐입니다
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 로봇 없이 테스트하기 (mock)
|
||||||
|
|
||||||
|
FAST-LIVO2를 안 켜고 대시보드만 확인할 때 씁니다. **Node 18+ 필요**(의존성 없음).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node tools/mock-rosbridge.mjs # ws://localhost:9090
|
||||||
|
```
|
||||||
|
|
||||||
|
가짜로 내보내는 것: `/cloud_registered`(10Hz, 컬러 PointCloud2), `/aft_mapped_to_init`(20Hz),
|
||||||
|
`/path`(5Hz), `/origin_img`(5Hz, PNG). 뷰어에서 `ws://localhost:9090`으로 연결하면
|
||||||
|
실제와 같은 경로로 동작합니다.
|
||||||
|
|
||||||
|
> 이 목 서버는 **개발용**입니다. 실기 검증을 대신하지 못합니다 — 실제 토픽 이름·필드·주파수는
|
||||||
|
> 4절에서 반드시 확인하세요.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 이관 순서 요약
|
||||||
|
|
||||||
|
1. 파일 복사 (1절)
|
||||||
|
2. 오프라인이면 Three.js 로컬화 (3절)
|
||||||
|
3. rosbridge + http.server 실행 (2절)
|
||||||
|
4. `ros2 topic list -t`로 **실제 토픽 기록** (4-2)
|
||||||
|
5. 대시보드 연결 → 4칸·Hz·맵 성장 확인 (4-3)
|
||||||
|
6. 카메라 안 뜨면 republish (5-1)
|
||||||
|
7. **복셀 유지율 확인** → 크기 조정. 그래도 모자라면 LIVE_CAP 상향 (6절)
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/target
|
||||||
|
/web
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "pcd-viewer"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2.6.3", features = [] }
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2.11.5", features = [] }
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
codegen-units = 1
|
||||||
|
lto = true
|
||||||
|
opt-level = "s"
|
||||||
|
panic = "abort"
|
||||||
|
strip = true
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 77 KiB |
@@ -0,0 +1,7 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running pcd viewer");
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Collect the web app into web/ for bundling. The originals at the repo root stay
|
||||||
|
# the single source of truth — pcd_viewer.html is never edited for the native build.
|
||||||
|
# The .pcd files are deliberately excluded: DEFAULT_URL is expected to 404 and fall
|
||||||
|
# back to the drag-and-drop prompt, so bundling 1.2 GB of clouds would buy nothing.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
here=$(dirname "$0")
|
||||||
|
root="$here/.."
|
||||||
|
out="$here/web"
|
||||||
|
|
||||||
|
rm -rf "$out"
|
||||||
|
mkdir -p "$out"
|
||||||
|
cp "$root/pcd_viewer.html" "$out/"
|
||||||
|
cp -R "$root/vendor" "$out/"
|
||||||
|
|
||||||
|
echo "staged -> $(cd "$out" && pwd)"
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
|
"productName": "PCD Viewer",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.khj.pcdviewer",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "./web",
|
||||||
|
"beforeDevCommand": "sh ./src-tauri/stage-web.sh",
|
||||||
|
"beforeBuildCommand": "sh ./src-tauri/stage-web.sh"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"url": "pcd_viewer.html",
|
||||||
|
"title": "PCD Viewer",
|
||||||
|
"width": 1440,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 960,
|
||||||
|
"minHeight": 640,
|
||||||
|
"backgroundColor": "#0a0b0c",
|
||||||
|
"dragDropEnabled": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self' blob: data: ws: wss:; worker-src 'self' blob:",
|
||||||
|
"dangerousDisableAssetCspModification": ["script-src", "style-src"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": ["app", "dmg"],
|
||||||
|
"icon": ["icons/icon.icns"],
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
// Minimal rosbridge v2 stand-in: hand-rolled RFC6455 (no deps) + synthetic
|
||||||
|
// FAST-LIVO2-shaped topics, so the viewer's live path can be exercised for real.
|
||||||
|
import http from 'node:http';
|
||||||
|
import crypto from 'node:crypto';
|
||||||
|
import zlib from 'node:zlib';
|
||||||
|
|
||||||
|
const PORT = 9090;
|
||||||
|
|
||||||
|
// ── websocket framing ────────────────────────────────────────────────────
|
||||||
|
function accept(key) {
|
||||||
|
return crypto.createHash('sha1').update(key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');
|
||||||
|
}
|
||||||
|
function encodeFrame(str) {
|
||||||
|
const payload = Buffer.from(str, 'utf8');
|
||||||
|
const len = payload.length;
|
||||||
|
let header;
|
||||||
|
if (len < 126) { header = Buffer.alloc(2); header[1] = len; }
|
||||||
|
else if (len < 65536) { header = Buffer.alloc(4); header[1] = 126; header.writeUInt16BE(len, 2); }
|
||||||
|
else { header = Buffer.alloc(10); header[1] = 127; header.writeBigUInt64BE(BigInt(len), 2); }
|
||||||
|
header[0] = 0x81; // FIN + text
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
}
|
||||||
|
function* decodeFrames(buf) { // client → server frames are masked
|
||||||
|
let off = 0;
|
||||||
|
while (off + 2 <= buf.length) {
|
||||||
|
const b1 = buf[off + 1];
|
||||||
|
let len = b1 & 127, p = off + 2;
|
||||||
|
if (len === 126) { len = buf.readUInt16BE(p); p += 2; }
|
||||||
|
else if (len === 127) { len = Number(buf.readBigUInt64BE(p)); p += 8; }
|
||||||
|
const masked = b1 & 128;
|
||||||
|
const mask = masked ? buf.subarray(p, p + 4) : null;
|
||||||
|
if (masked) p += 4;
|
||||||
|
if (p + len > buf.length) return; // partial frame: wait for more
|
||||||
|
const data = Buffer.from(buf.subarray(p, p + len));
|
||||||
|
if (mask) for (let i = 0; i < data.length; i++) data[i] ^= mask[i % 4];
|
||||||
|
yield { opcode: buf[off] & 15, text: data.toString('utf8') };
|
||||||
|
off = p + len;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── a tiny PNG encoder, to stand in for the camera's compressed frames ───
|
||||||
|
function crc32(buf) {
|
||||||
|
let c, crc = 0xffffffff;
|
||||||
|
for (let n = 0; n < buf.length; n++) {
|
||||||
|
c = (crc ^ buf[n]) & 0xff;
|
||||||
|
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
||||||
|
crc = (crc >>> 8) ^ c;
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
function chunk(type, data) {
|
||||||
|
const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
|
||||||
|
const td = Buffer.concat([Buffer.from(type, 'ascii'), data]);
|
||||||
|
const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
|
||||||
|
return Buffer.concat([len, td, crc]);
|
||||||
|
}
|
||||||
|
function png(w, h, paint) {
|
||||||
|
const raw = Buffer.alloc(h * (w * 3 + 1));
|
||||||
|
for (let y = 0; y < h; y++) {
|
||||||
|
const row = y * (w * 3 + 1);
|
||||||
|
raw[row] = 0;
|
||||||
|
for (let x = 0; x < w; x++) {
|
||||||
|
const [r, g, b] = paint(x, y);
|
||||||
|
raw[row + 1 + x * 3] = r; raw[row + 2 + x * 3] = g; raw[row + 3 + x * 3] = b;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ihdr = Buffer.alloc(13);
|
||||||
|
ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4);
|
||||||
|
ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||||||
|
chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(raw)), chunk('IEND', Buffer.alloc(0))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── synthetic FAST-LIVO2 session ─────────────────────────────────────────
|
||||||
|
const TOPICS = [
|
||||||
|
['/cloud_registered', 'sensor_msgs/msg/PointCloud2'],
|
||||||
|
['/cloud_effected', 'sensor_msgs/msg/PointCloud2'],
|
||||||
|
['/aft_mapped_to_init', 'nav_msgs/msg/Odometry'],
|
||||||
|
['/path', 'nav_msgs/msg/Path'],
|
||||||
|
['/origin_img', 'sensor_msgs/msg/CompressedImage'],
|
||||||
|
['/rosout', 'rcl_interfaces/msg/Log']
|
||||||
|
];
|
||||||
|
|
||||||
|
let t0 = Date.now();
|
||||||
|
const trail = [];
|
||||||
|
const pose = () => {
|
||||||
|
const t = (Date.now() - t0) / 1000;
|
||||||
|
return { x: 6 * Math.cos(t * 0.25), y: 6 * Math.sin(t * 0.25), z: 0.4 + 0.1 * Math.sin(t) };
|
||||||
|
};
|
||||||
|
const stamp = () => ({ sec: Math.floor(Date.now() / 1000), nanosec: (Date.now() % 1000) * 1e6 });
|
||||||
|
|
||||||
|
function cloudMsg(n = 2500) {
|
||||||
|
const step = 16; // x,y,z float32 + rgb packed
|
||||||
|
const buf = Buffer.alloc(n * step);
|
||||||
|
const p = pose();
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = Math.random() * Math.PI * 2, r = 2 + Math.random() * 3;
|
||||||
|
const x = p.x + r * Math.cos(a), y = p.y + r * Math.sin(a), z = Math.random() * 2.4;
|
||||||
|
const o = i * step;
|
||||||
|
buf.writeFloatLE(x, o); buf.writeFloatLE(y, o + 4); buf.writeFloatLE(z, o + 8);
|
||||||
|
const t = Math.min(1, z / 2.4);
|
||||||
|
const rgb = ((60 + 180 * t | 0) << 16) | ((90 + 100 * (1 - t) | 0) << 8) | (200 - 140 * t | 0);
|
||||||
|
buf.writeUInt32LE(rgb >>> 0, o + 12);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
header: { stamp: stamp(), frame_id: 'camera_init' },
|
||||||
|
height: 1, width: n,
|
||||||
|
fields: [
|
||||||
|
{ name: 'x', offset: 0, datatype: 7, count: 1 },
|
||||||
|
{ name: 'y', offset: 4, datatype: 7, count: 1 },
|
||||||
|
{ name: 'z', offset: 8, datatype: 7, count: 1 },
|
||||||
|
{ name: 'rgb', offset: 12, datatype: 7, count: 1 }
|
||||||
|
],
|
||||||
|
is_bigendian: false, point_step: step, row_step: step * n,
|
||||||
|
data: buf.toString('base64'), is_dense: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function odomMsg() {
|
||||||
|
const p = pose();
|
||||||
|
trail.push(p);
|
||||||
|
if (trail.length > 600) trail.shift();
|
||||||
|
return { header: { stamp: stamp(), frame_id: 'camera_init' }, child_frame_id: 'body',
|
||||||
|
pose: { pose: { position: p, orientation: { x: 0, y: 0, z: 0, w: 1 } } } };
|
||||||
|
}
|
||||||
|
function pathMsg() {
|
||||||
|
return { header: { stamp: stamp(), frame_id: 'camera_init' },
|
||||||
|
poses: trail.map(p => ({ header: { stamp: stamp(), frame_id: 'camera_init' },
|
||||||
|
pose: { position: p, orientation: { x: 0, y: 0, z: 0, w: 1 } } })) };
|
||||||
|
}
|
||||||
|
function imgMsg() {
|
||||||
|
const t = (Date.now() - t0) / 400;
|
||||||
|
const buf = png(320, 180, (x, y) => {
|
||||||
|
const v = 40 + 90 * Math.sin(x / 22 + t) * Math.cos(y / 18 - t * 0.4);
|
||||||
|
return [Math.max(0, v * 1.4) | 0, Math.max(0, v) | 0, Math.max(0, v * 0.7) | 0];
|
||||||
|
});
|
||||||
|
return { header: { stamp: stamp(), frame_id: 'camera' }, format: 'rgb8; png compressed bgr8',
|
||||||
|
data: buf.toString('base64') };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── server ───────────────────────────────────────────────────────────────
|
||||||
|
const server = http.createServer((_, res) => res.end('mock rosbridge'));
|
||||||
|
server.on('upgrade', (req, socket) => {
|
||||||
|
socket.write('HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n' +
|
||||||
|
`Sec-WebSocket-Accept: ${accept(req.headers['sec-websocket-key'])}\r\n\r\n`);
|
||||||
|
const send = o => socket.write(encodeFrame(JSON.stringify(o)));
|
||||||
|
const timers = [];
|
||||||
|
const pub = (topic, make, hz) => timers.push(setInterval(() => send({ op: 'publish', topic, msg: make() }), 1000 / hz));
|
||||||
|
|
||||||
|
let acc = Buffer.alloc(0);
|
||||||
|
socket.on('data', d => {
|
||||||
|
acc = Buffer.concat([acc, d]);
|
||||||
|
let consumed = 0;
|
||||||
|
for (const fr of decodeFrames(acc)) {
|
||||||
|
consumed = acc.length; // our client only sends small whole frames
|
||||||
|
if (fr.opcode === 8) { socket.end(); return; }
|
||||||
|
let m; try { m = JSON.parse(fr.text); } catch { continue; }
|
||||||
|
console.log('→', m.op, m.service || m.topic || '');
|
||||||
|
if (m.op === 'call_service' && m.service === '/rosapi/topics') {
|
||||||
|
send({ op: 'service_response', service: m.service, id: m.id, result: true,
|
||||||
|
values: { topics: TOPICS.map(t => t[0]), types: TOPICS.map(t => t[1]) } });
|
||||||
|
} else if (m.op === 'subscribe') {
|
||||||
|
if (m.topic === '/cloud_registered' || m.topic === '/cloud_effected') pub(m.topic, () => cloudMsg(), 10);
|
||||||
|
else if (m.topic === '/aft_mapped_to_init') pub(m.topic, odomMsg, 20);
|
||||||
|
else if (m.topic === '/path') pub(m.topic, pathMsg, 5);
|
||||||
|
else if (m.topic === '/origin_img') pub(m.topic, imgMsg, 5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
acc = acc.subarray(consumed);
|
||||||
|
});
|
||||||
|
socket.on('close', () => timers.forEach(clearInterval));
|
||||||
|
socket.on('error', () => timers.forEach(clearInterval));
|
||||||
|
});
|
||||||
|
server.listen(PORT, () => console.log('mock rosbridge on ws://localhost:' + PORT));
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
import {
|
||||||
|
BufferGeometry,
|
||||||
|
Color,
|
||||||
|
FileLoader,
|
||||||
|
Float32BufferAttribute,
|
||||||
|
Int32BufferAttribute,
|
||||||
|
Loader,
|
||||||
|
Points,
|
||||||
|
PointsMaterial
|
||||||
|
} from 'three';
|
||||||
|
|
||||||
|
class PCDLoader extends Loader {
|
||||||
|
|
||||||
|
constructor( manager ) {
|
||||||
|
|
||||||
|
super( manager );
|
||||||
|
|
||||||
|
this.littleEndian = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
load( url, onLoad, onProgress, onError ) {
|
||||||
|
|
||||||
|
const scope = this;
|
||||||
|
|
||||||
|
const loader = new FileLoader( scope.manager );
|
||||||
|
loader.setPath( scope.path );
|
||||||
|
loader.setResponseType( 'arraybuffer' );
|
||||||
|
loader.setRequestHeader( scope.requestHeader );
|
||||||
|
loader.setWithCredentials( scope.withCredentials );
|
||||||
|
loader.load( url, function ( data ) {
|
||||||
|
|
||||||
|
try {
|
||||||
|
|
||||||
|
onLoad( scope.parse( data ) );
|
||||||
|
|
||||||
|
} catch ( e ) {
|
||||||
|
|
||||||
|
if ( onError ) {
|
||||||
|
|
||||||
|
onError( e );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
console.error( e );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
scope.manager.itemError( url );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}, onProgress, onError );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
parse( data ) {
|
||||||
|
|
||||||
|
// from https://gitlab.com/taketwo/three-pcd-loader/blob/master/decompress-lzf.js
|
||||||
|
|
||||||
|
function decompressLZF( inData, outLength ) {
|
||||||
|
|
||||||
|
const inLength = inData.length;
|
||||||
|
const outData = new Uint8Array( outLength );
|
||||||
|
let inPtr = 0;
|
||||||
|
let outPtr = 0;
|
||||||
|
let ctrl;
|
||||||
|
let len;
|
||||||
|
let ref;
|
||||||
|
do {
|
||||||
|
|
||||||
|
ctrl = inData[ inPtr ++ ];
|
||||||
|
if ( ctrl < ( 1 << 5 ) ) {
|
||||||
|
|
||||||
|
ctrl ++;
|
||||||
|
if ( outPtr + ctrl > outLength ) throw new Error( 'Output buffer is not large enough' );
|
||||||
|
if ( inPtr + ctrl > inLength ) throw new Error( 'Invalid compressed data' );
|
||||||
|
do {
|
||||||
|
|
||||||
|
outData[ outPtr ++ ] = inData[ inPtr ++ ];
|
||||||
|
|
||||||
|
} while ( -- ctrl );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
len = ctrl >> 5;
|
||||||
|
ref = outPtr - ( ( ctrl & 0x1f ) << 8 ) - 1;
|
||||||
|
if ( inPtr >= inLength ) throw new Error( 'Invalid compressed data' );
|
||||||
|
if ( len === 7 ) {
|
||||||
|
|
||||||
|
len += inData[ inPtr ++ ];
|
||||||
|
if ( inPtr >= inLength ) throw new Error( 'Invalid compressed data' );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
ref -= inData[ inPtr ++ ];
|
||||||
|
if ( outPtr + len + 2 > outLength ) throw new Error( 'Output buffer is not large enough' );
|
||||||
|
if ( ref < 0 ) throw new Error( 'Invalid compressed data' );
|
||||||
|
if ( ref >= outPtr ) throw new Error( 'Invalid compressed data' );
|
||||||
|
do {
|
||||||
|
|
||||||
|
outData[ outPtr ++ ] = outData[ ref ++ ];
|
||||||
|
|
||||||
|
} while ( -- len + 2 );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
} while ( inPtr < inLength );
|
||||||
|
|
||||||
|
return outData;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHeader( data ) {
|
||||||
|
|
||||||
|
const PCDheader = {};
|
||||||
|
const result1 = data.search( /[\r\n]DATA\s(\S*)\s/i );
|
||||||
|
const result2 = /[\r\n]DATA\s(\S*)\s/i.exec( data.slice( result1 - 1 ) );
|
||||||
|
|
||||||
|
PCDheader.data = result2[ 1 ];
|
||||||
|
PCDheader.headerLen = result2[ 0 ].length + result1;
|
||||||
|
PCDheader.str = data.slice( 0, PCDheader.headerLen );
|
||||||
|
|
||||||
|
// remove comments
|
||||||
|
|
||||||
|
PCDheader.str = PCDheader.str.replace( /#.*/gi, '' );
|
||||||
|
|
||||||
|
// parse
|
||||||
|
|
||||||
|
PCDheader.version = /VERSION (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.fields = /FIELDS (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.size = /SIZE (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.type = /TYPE (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.count = /COUNT (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.width = /WIDTH (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.height = /HEIGHT (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.viewpoint = /VIEWPOINT (.*)/i.exec( PCDheader.str );
|
||||||
|
PCDheader.points = /POINTS (.*)/i.exec( PCDheader.str );
|
||||||
|
|
||||||
|
// evaluate
|
||||||
|
|
||||||
|
if ( PCDheader.version !== null )
|
||||||
|
PCDheader.version = parseFloat( PCDheader.version[ 1 ] );
|
||||||
|
|
||||||
|
PCDheader.fields = ( PCDheader.fields !== null ) ? PCDheader.fields[ 1 ].split( ' ' ) : [];
|
||||||
|
|
||||||
|
if ( PCDheader.type !== null )
|
||||||
|
PCDheader.type = PCDheader.type[ 1 ].split( ' ' );
|
||||||
|
|
||||||
|
if ( PCDheader.width !== null )
|
||||||
|
PCDheader.width = parseInt( PCDheader.width[ 1 ] );
|
||||||
|
|
||||||
|
if ( PCDheader.height !== null )
|
||||||
|
PCDheader.height = parseInt( PCDheader.height[ 1 ] );
|
||||||
|
|
||||||
|
if ( PCDheader.viewpoint !== null )
|
||||||
|
PCDheader.viewpoint = PCDheader.viewpoint[ 1 ];
|
||||||
|
|
||||||
|
if ( PCDheader.points !== null )
|
||||||
|
PCDheader.points = parseInt( PCDheader.points[ 1 ], 10 );
|
||||||
|
|
||||||
|
if ( PCDheader.points === null )
|
||||||
|
PCDheader.points = PCDheader.width * PCDheader.height;
|
||||||
|
|
||||||
|
if ( PCDheader.size !== null ) {
|
||||||
|
|
||||||
|
PCDheader.size = PCDheader.size[ 1 ].split( ' ' ).map( function ( x ) {
|
||||||
|
|
||||||
|
return parseInt( x, 10 );
|
||||||
|
|
||||||
|
} );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( PCDheader.count !== null ) {
|
||||||
|
|
||||||
|
PCDheader.count = PCDheader.count[ 1 ].split( ' ' ).map( function ( x ) {
|
||||||
|
|
||||||
|
return parseInt( x, 10 );
|
||||||
|
|
||||||
|
} );
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
PCDheader.count = [];
|
||||||
|
|
||||||
|
for ( let i = 0, l = PCDheader.fields.length; i < l; i ++ ) {
|
||||||
|
|
||||||
|
PCDheader.count.push( 1 );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
PCDheader.offset = {};
|
||||||
|
|
||||||
|
let sizeSum = 0;
|
||||||
|
|
||||||
|
for ( let i = 0, l = PCDheader.fields.length; i < l; i ++ ) {
|
||||||
|
|
||||||
|
if ( PCDheader.data === 'ascii' ) {
|
||||||
|
|
||||||
|
PCDheader.offset[ PCDheader.fields[ i ] ] = i;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
|
||||||
|
PCDheader.offset[ PCDheader.fields[ i ] ] = sizeSum;
|
||||||
|
sizeSum += PCDheader.size[ i ] * PCDheader.count[ i ];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// for binary only
|
||||||
|
|
||||||
|
PCDheader.rowSize = sizeSum;
|
||||||
|
|
||||||
|
return PCDheader;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const textData = new TextDecoder().decode( data );
|
||||||
|
|
||||||
|
// parse header (always ascii format)
|
||||||
|
|
||||||
|
const PCDheader = parseHeader( textData );
|
||||||
|
|
||||||
|
// parse data
|
||||||
|
|
||||||
|
const position = [];
|
||||||
|
const normal = [];
|
||||||
|
const color = [];
|
||||||
|
const intensity = [];
|
||||||
|
const label = [];
|
||||||
|
|
||||||
|
const c = new Color();
|
||||||
|
|
||||||
|
// ascii
|
||||||
|
|
||||||
|
if ( PCDheader.data === 'ascii' ) {
|
||||||
|
|
||||||
|
const offset = PCDheader.offset;
|
||||||
|
const pcdData = textData.slice( PCDheader.headerLen );
|
||||||
|
const lines = pcdData.split( '\n' );
|
||||||
|
|
||||||
|
for ( let i = 0, l = lines.length; i < l; i ++ ) {
|
||||||
|
|
||||||
|
if ( lines[ i ] === '' ) continue;
|
||||||
|
|
||||||
|
const line = lines[ i ].split( ' ' );
|
||||||
|
|
||||||
|
if ( offset.x !== undefined ) {
|
||||||
|
|
||||||
|
position.push( parseFloat( line[ offset.x ] ) );
|
||||||
|
position.push( parseFloat( line[ offset.y ] ) );
|
||||||
|
position.push( parseFloat( line[ offset.z ] ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.rgb !== undefined ) {
|
||||||
|
|
||||||
|
const rgb_field_index = PCDheader.fields.findIndex( ( field ) => field === 'rgb' );
|
||||||
|
const rgb_type = PCDheader.type[ rgb_field_index ];
|
||||||
|
|
||||||
|
const float = parseFloat( line[ offset.rgb ] );
|
||||||
|
let rgb = float;
|
||||||
|
|
||||||
|
if ( rgb_type === 'F' ) {
|
||||||
|
|
||||||
|
// treat float values as int
|
||||||
|
// https://github.com/daavoo/pyntcloud/pull/204/commits/7b4205e64d5ed09abe708b2e91b615690c24d518
|
||||||
|
const farr = new Float32Array( 1 );
|
||||||
|
farr[ 0 ] = float;
|
||||||
|
rgb = new Int32Array( farr.buffer )[ 0 ];
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = ( ( rgb >> 16 ) & 0x0000ff ) / 255;
|
||||||
|
const g = ( ( rgb >> 8 ) & 0x0000ff ) / 255;
|
||||||
|
const b = ( ( rgb >> 0 ) & 0x0000ff ) / 255;
|
||||||
|
|
||||||
|
c.set( r, g, b ).convertSRGBToLinear();
|
||||||
|
|
||||||
|
color.push( c.r, c.g, c.b );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.normal_x !== undefined ) {
|
||||||
|
|
||||||
|
normal.push( parseFloat( line[ offset.normal_x ] ) );
|
||||||
|
normal.push( parseFloat( line[ offset.normal_y ] ) );
|
||||||
|
normal.push( parseFloat( line[ offset.normal_z ] ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.intensity !== undefined ) {
|
||||||
|
|
||||||
|
intensity.push( parseFloat( line[ offset.intensity ] ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.label !== undefined ) {
|
||||||
|
|
||||||
|
label.push( parseInt( line[ offset.label ] ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// binary-compressed
|
||||||
|
|
||||||
|
// normally data in PCD files are organized as array of structures: XYZRGBXYZRGB
|
||||||
|
// binary compressed PCD files organize their data as structure of arrays: XXYYZZRGBRGB
|
||||||
|
// that requires a totally different parsing approach compared to non-compressed data
|
||||||
|
|
||||||
|
if ( PCDheader.data === 'binary_compressed' ) {
|
||||||
|
|
||||||
|
const sizes = new Uint32Array( data.slice( PCDheader.headerLen, PCDheader.headerLen + 8 ) );
|
||||||
|
const compressedSize = sizes[ 0 ];
|
||||||
|
const decompressedSize = sizes[ 1 ];
|
||||||
|
const decompressed = decompressLZF( new Uint8Array( data, PCDheader.headerLen + 8, compressedSize ), decompressedSize );
|
||||||
|
const dataview = new DataView( decompressed.buffer );
|
||||||
|
|
||||||
|
const offset = PCDheader.offset;
|
||||||
|
|
||||||
|
for ( let i = 0; i < PCDheader.points; i ++ ) {
|
||||||
|
|
||||||
|
if ( offset.x !== undefined ) {
|
||||||
|
|
||||||
|
const xIndex = PCDheader.fields.indexOf( 'x' );
|
||||||
|
const yIndex = PCDheader.fields.indexOf( 'y' );
|
||||||
|
const zIndex = PCDheader.fields.indexOf( 'z' );
|
||||||
|
position.push( dataview.getFloat32( ( PCDheader.points * offset.x ) + PCDheader.size[ xIndex ] * i, this.littleEndian ) );
|
||||||
|
position.push( dataview.getFloat32( ( PCDheader.points * offset.y ) + PCDheader.size[ yIndex ] * i, this.littleEndian ) );
|
||||||
|
position.push( dataview.getFloat32( ( PCDheader.points * offset.z ) + PCDheader.size[ zIndex ] * i, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.rgb !== undefined ) {
|
||||||
|
|
||||||
|
const rgbIndex = PCDheader.fields.indexOf( 'rgb' );
|
||||||
|
|
||||||
|
const r = dataview.getUint8( ( PCDheader.points * offset.rgb ) + PCDheader.size[ rgbIndex ] * i + 2 ) / 255.0;
|
||||||
|
const g = dataview.getUint8( ( PCDheader.points * offset.rgb ) + PCDheader.size[ rgbIndex ] * i + 1 ) / 255.0;
|
||||||
|
const b = dataview.getUint8( ( PCDheader.points * offset.rgb ) + PCDheader.size[ rgbIndex ] * i + 0 ) / 255.0;
|
||||||
|
|
||||||
|
c.set( r, g, b ).convertSRGBToLinear();
|
||||||
|
|
||||||
|
color.push( c.r, c.g, c.b );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.normal_x !== undefined ) {
|
||||||
|
|
||||||
|
const xIndex = PCDheader.fields.indexOf( 'normal_x' );
|
||||||
|
const yIndex = PCDheader.fields.indexOf( 'normal_y' );
|
||||||
|
const zIndex = PCDheader.fields.indexOf( 'normal_z' );
|
||||||
|
normal.push( dataview.getFloat32( ( PCDheader.points * offset.normal_x ) + PCDheader.size[ xIndex ] * i, this.littleEndian ) );
|
||||||
|
normal.push( dataview.getFloat32( ( PCDheader.points * offset.normal_y ) + PCDheader.size[ yIndex ] * i, this.littleEndian ) );
|
||||||
|
normal.push( dataview.getFloat32( ( PCDheader.points * offset.normal_z ) + PCDheader.size[ zIndex ] * i, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.intensity !== undefined ) {
|
||||||
|
|
||||||
|
const intensityIndex = PCDheader.fields.indexOf( 'intensity' );
|
||||||
|
intensity.push( dataview.getFloat32( ( PCDheader.points * offset.intensity ) + PCDheader.size[ intensityIndex ] * i, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.label !== undefined ) {
|
||||||
|
|
||||||
|
const labelIndex = PCDheader.fields.indexOf( 'label' );
|
||||||
|
label.push( dataview.getInt32( ( PCDheader.points * offset.label ) + PCDheader.size[ labelIndex ] * i, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// binary
|
||||||
|
|
||||||
|
if ( PCDheader.data === 'binary' ) {
|
||||||
|
|
||||||
|
const dataview = new DataView( data, PCDheader.headerLen );
|
||||||
|
const offset = PCDheader.offset;
|
||||||
|
|
||||||
|
for ( let i = 0, row = 0; i < PCDheader.points; i ++, row += PCDheader.rowSize ) {
|
||||||
|
|
||||||
|
if ( offset.x !== undefined ) {
|
||||||
|
|
||||||
|
position.push( dataview.getFloat32( row + offset.x, this.littleEndian ) );
|
||||||
|
position.push( dataview.getFloat32( row + offset.y, this.littleEndian ) );
|
||||||
|
position.push( dataview.getFloat32( row + offset.z, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.rgb !== undefined ) {
|
||||||
|
|
||||||
|
const r = dataview.getUint8( row + offset.rgb + 2 ) / 255.0;
|
||||||
|
const g = dataview.getUint8( row + offset.rgb + 1 ) / 255.0;
|
||||||
|
const b = dataview.getUint8( row + offset.rgb + 0 ) / 255.0;
|
||||||
|
|
||||||
|
c.set( r, g, b ).convertSRGBToLinear();
|
||||||
|
|
||||||
|
color.push( c.r, c.g, c.b );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.normal_x !== undefined ) {
|
||||||
|
|
||||||
|
normal.push( dataview.getFloat32( row + offset.normal_x, this.littleEndian ) );
|
||||||
|
normal.push( dataview.getFloat32( row + offset.normal_y, this.littleEndian ) );
|
||||||
|
normal.push( dataview.getFloat32( row + offset.normal_z, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.intensity !== undefined ) {
|
||||||
|
|
||||||
|
intensity.push( dataview.getFloat32( row + offset.intensity, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( offset.label !== undefined ) {
|
||||||
|
|
||||||
|
label.push( dataview.getInt32( row + offset.label, this.littleEndian ) );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// build geometry
|
||||||
|
|
||||||
|
const geometry = new BufferGeometry();
|
||||||
|
|
||||||
|
if ( position.length > 0 ) geometry.setAttribute( 'position', new Float32BufferAttribute( position, 3 ) );
|
||||||
|
if ( normal.length > 0 ) geometry.setAttribute( 'normal', new Float32BufferAttribute( normal, 3 ) );
|
||||||
|
if ( color.length > 0 ) geometry.setAttribute( 'color', new Float32BufferAttribute( color, 3 ) );
|
||||||
|
if ( intensity.length > 0 ) geometry.setAttribute( 'intensity', new Float32BufferAttribute( intensity, 1 ) );
|
||||||
|
if ( label.length > 0 ) geometry.setAttribute( 'label', new Int32BufferAttribute( label, 1 ) );
|
||||||
|
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
|
||||||
|
// build material
|
||||||
|
|
||||||
|
const material = new PointsMaterial( { size: 0.005 } );
|
||||||
|
|
||||||
|
if ( color.length > 0 ) {
|
||||||
|
|
||||||
|
material.vertexColors = true;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// build point cloud
|
||||||
|
|
||||||
|
return new Points( geometry, material );
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export { PCDLoader };
|
||||||