[real] 整理 ROS 2 v2 Odin 与站姿调参
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
# PCD Map Viewer
|
||||
|
||||
Offline Web tool for Odin PCD inspection and route editing.
|
||||
|
||||
This is not the Nano runtime Web UI. Use it before a run to inspect the map and save route YAML files.
|
||||
|
||||
## Start
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
python .\tools\pcd_map_viewer\server.py --http-port 8090
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8090
|
||||
```
|
||||
|
||||
You can also run directly from this directory:
|
||||
|
||||
```powershell
|
||||
python server.py --http-port 8090
|
||||
```
|
||||
|
||||
## What It Does
|
||||
|
||||
- Scans `map/*.pcd`.
|
||||
- Shows raw Odin PCD as a rotatable 3D point cloud.
|
||||
- Supports z filtering, default `z=-2.0..1.0m`.
|
||||
- Supports voxel display for structure checks.
|
||||
- Provides a simplified 2D layer view.
|
||||
- Lets you click waypoints in 3D or 2D.
|
||||
- Lets you draw obstacle terrain rectangles and edit their x/y/yaw/size.
|
||||
- Tags new waypoints with the matching obstacle terrain, defaulting to `flat`.
|
||||
- Saves route YAML and JSON under `map/routes/<pcd_name>/`.
|
||||
|
||||
## Route Fields
|
||||
|
||||
Saved waypoint fields:
|
||||
|
||||
- `x`
|
||||
- `y`
|
||||
- `yaw_deg`
|
||||
- `speed`
|
||||
- `policy`
|
||||
- `tolerance`
|
||||
- `obstacle`
|
||||
- `obstacle_name`
|
||||
|
||||
Not saved:
|
||||
|
||||
- `z`
|
||||
- `action`
|
||||
|
||||
The browser may keep local `_viewZ` only for drawing markers in 3D. The runtime route runner is planar.
|
||||
|
||||
## Saved Format
|
||||
|
||||
```yaml
|
||||
name: test_route
|
||||
map: map1
|
||||
frame_id: map
|
||||
obstacles:
|
||||
- id: 1
|
||||
name: wall_1
|
||||
obstacle: wall
|
||||
x: 1.5000
|
||||
y: 2.0000
|
||||
yaw_deg: 0.00
|
||||
length: 1.000
|
||||
width: 0.500
|
||||
policy: rough
|
||||
waypoints:
|
||||
- id: 1
|
||||
x: 1.0000
|
||||
y: 2.0000
|
||||
yaw_deg: 0.00
|
||||
speed: 0.350
|
||||
policy: rough
|
||||
tolerance: 0.150
|
||||
obstacle: wall
|
||||
obstacle_name: wall_1
|
||||
```
|
||||
|
||||
Default runtime route:
|
||||
|
||||
```text
|
||||
map/routes/map1/test_route.yaml
|
||||
```
|
||||
|
||||
## Runtime Test
|
||||
|
||||
```bash
|
||||
ros2 topic pub --once /route_runner/cmd std_msgs/msg/String "{data: reload}"
|
||||
ros2 topic pub --once /route_runner/cmd std_msgs/msg/String "{data: start}"
|
||||
ros2 topic pub --once /route_runner/cmd std_msgs/msg/String "{data: stop}"
|
||||
```
|
||||
|
||||
See also:
|
||||
|
||||
- [Maps And Routes](../../docs/ROUTES_AND_MAPS.md)
|
||||
- [Common Commands](../../docs/COMMANDS.md)
|
||||
@@ -0,0 +1,28 @@
|
||||
# PCD 地图查看和打点工具
|
||||
|
||||
这是离线 Web 工具,用于查看 Odin PCD 地图和保存路线点。它不是 Nano 运行时 Web。
|
||||
|
||||
## 启动
|
||||
|
||||
在仓库根目录:
|
||||
|
||||
```powershell
|
||||
python .\tools\pcd_map_viewer\server.py --http-port 8090
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8090
|
||||
```
|
||||
|
||||
路线点保存:
|
||||
|
||||
- `x`
|
||||
- `y`
|
||||
- `yaw_deg`
|
||||
- `speed`
|
||||
- `policy`
|
||||
- `tolerance`
|
||||
|
||||
不保存 `z` 和 `action`。
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,146 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PCD Obstacle Terrain Editor</title>
|
||||
<link rel="stylesheet" href="./style.css?v=obstacle-terrain-2">
|
||||
</head>
|
||||
<body>
|
||||
<aside class="sidebar">
|
||||
<div class="title">
|
||||
<h1>Obstacle Terrain Editor</h1>
|
||||
<span id="serverState">obstacle-ui loading</span>
|
||||
</div>
|
||||
|
||||
<label>Map</label>
|
||||
<select id="mapSelect"></select>
|
||||
|
||||
<div id="layerControls" class="hidden">
|
||||
<label>Layer</label>
|
||||
<select id="layerSelect"></select>
|
||||
</div>
|
||||
|
||||
<label>View</label>
|
||||
<select id="viewSelect">
|
||||
<option value="3d" selected>3D map</option>
|
||||
<option value="2d">2D layers</option>
|
||||
</select>
|
||||
|
||||
<label>3D Mode</label>
|
||||
<select id="cloudModeSelect">
|
||||
<option value="points" selected>Point cloud</option>
|
||||
<option value="voxels">Occupied voxels</option>
|
||||
</select>
|
||||
|
||||
<div class="buttonRow">
|
||||
<button id="fitBtn" title="Fit map">Fit</button>
|
||||
<button id="reloadBtn" title="Reload maps">Reload</button>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Point Cloud</h2>
|
||||
<div class="formGrid">
|
||||
<label>Z Min</label><input id="cloudZMin" type="number" step="0.1" value="-2.0">
|
||||
<label>Z Max</label><input id="cloudZMax" type="number" step="0.1" value="1.0">
|
||||
<label>Max Pts</label><input id="cloudMaxPoints" type="number" step="1000" value="120000">
|
||||
<label>Voxel</label><input id="voxelSize" type="number" step="0.01" value="0.20">
|
||||
<label>Min Pts</label><input id="voxelMinPoints" type="number" step="1" value="1">
|
||||
<label>Cluster</label><input id="voxelMinCluster" type="number" step="1" value="1">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Cursor</h2>
|
||||
<pre id="cursorInfo">-</pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Waypoint</h2>
|
||||
<input id="routeName" placeholder="route name" value="test_route">
|
||||
<div class="formGrid">
|
||||
<label>X</label><input id="wpX" type="number" step="0.01">
|
||||
<label>Y</label><input id="wpY" type="number" step="0.01">
|
||||
<label>Yaw</label><input id="wpYaw" type="number" step="1" value="0">
|
||||
<label>Terrain</label><input id="wpObstacle" value="flat" readonly>
|
||||
<label>Speed</label><input id="wpSpeed" type="number" step="0.05" value="0.35">
|
||||
<label>Policy</label>
|
||||
<select id="wpPolicy">
|
||||
<option value="rough">rough</option>
|
||||
<option value="crawl">crawl</option>
|
||||
</select>
|
||||
<label>Tol</label><input id="wpTol" type="number" step="0.01" value="0.15">
|
||||
<label>Yaw Tol</label><input id="wpYawTol" type="number" step="1" value="30">
|
||||
<label>Require Yaw</label><input id="wpRequireYaw" type="checkbox">
|
||||
<label>PreDock Dist</label><input id="wpPreDockDist" type="number" step="0.01" value="0.35">
|
||||
<label>PreDock Tol</label><input id="wpPreDockTol" type="number" step="0.01" value="0.18">
|
||||
</div>
|
||||
<div class="buttonRow">
|
||||
<button id="updateWpBtn">Update</button>
|
||||
<button id="deleteWpBtn">Delete</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Obstacle</h2>
|
||||
<label>Region</label>
|
||||
<select id="obstacleSelect"></select>
|
||||
<div class="formGrid">
|
||||
<label>Name</label><input id="obstacleName" value="obstacle_1">
|
||||
<label>Type</label>
|
||||
<select id="obstacleType">
|
||||
<option value="rough_pit">rough_pit</option>
|
||||
<option value="wall">wall</option>
|
||||
<option value="low_bar">low_bar</option>
|
||||
<option value="stairs">stairs</option>
|
||||
<option value="ramp">ramp</option>
|
||||
<option value="bridge_a">bridge_a</option>
|
||||
<option value="bridge_b">bridge_b</option>
|
||||
<option value="slalom">slalom</option>
|
||||
<option value="return_home">return_home</option>
|
||||
</select>
|
||||
<label>X</label><input id="obstacleX" type="number" step="0.01">
|
||||
<label>Y</label><input id="obstacleY" type="number" step="0.01">
|
||||
<label>Yaw</label><input id="obstacleYaw" type="number" step="1" value="0">
|
||||
<label>Length</label><input id="obstacleLength" type="number" step="0.05" value="1.00">
|
||||
<label>Width</label><input id="obstacleWidth" type="number" step="0.05" value="1.00">
|
||||
<label>Policy</label>
|
||||
<select id="obstaclePolicy">
|
||||
<option value="rough">rough</option>
|
||||
<option value="crawl">crawl</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="buttonRow">
|
||||
<button id="drawObstacleBtn">Draw</button>
|
||||
<button id="updateObstacleBtn">Update</button>
|
||||
<button id="deleteObstacleBtn">Delete</button>
|
||||
</div>
|
||||
<ol id="obstacleList"></ol>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Route</h2>
|
||||
<div class="buttonRow">
|
||||
<button id="clearRouteBtn">Clear</button>
|
||||
<button id="exportJsonBtn">Export</button>
|
||||
<button id="saveRouteBtn">Save</button>
|
||||
</div>
|
||||
<ol id="waypointList"></ol>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<main class="main view-3d">
|
||||
<canvas id="mapCanvas"></canvas>
|
||||
<canvas id="cloudCanvas"></canvas>
|
||||
<div class="hud">
|
||||
<span>Click cloud: add/select waypoint</span>
|
||||
<span>Draw: drag 2D / click 3D obstacle</span>
|
||||
<span>Wheel: zoom</span>
|
||||
<span>Drag: orbit 3D / pan 2D</span>
|
||||
<span>Shift + drag: set yaw</span>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script type="module" src="./app.js?v=obstacle-terrain-2"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,191 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", Arial, sans-serif;
|
||||
background: #0d1117;
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 340px 1fr;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: #111827;
|
||||
border-right: 1px solid #263244;
|
||||
padding: 16px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
margin: 0;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 14px;
|
||||
margin: 18px 0 8px;
|
||||
color: #cbd5e1;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
color: #94a3b8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
select,
|
||||
input,
|
||||
button {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
border: 1px solid #344256;
|
||||
background: #0b1220;
|
||||
color: #e5e7eb;
|
||||
border-radius: 6px;
|
||||
padding: 0 10px;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #1f2937;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #2f3b4d;
|
||||
}
|
||||
|
||||
.buttonRow {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.buttonRow:has(button:nth-child(2):last-child) {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.formGrid {
|
||||
display: grid;
|
||||
grid-template-columns: 62px 1fr;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.formGrid label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre {
|
||||
min-height: 112px;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
background: #070b12;
|
||||
border: 1px solid #263244;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
color: #a7f3d0;
|
||||
}
|
||||
|
||||
#waypointList,
|
||||
#obstacleList {
|
||||
padding-left: 22px;
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
|
||||
#waypointList li,
|
||||
#obstacleList li {
|
||||
padding: 6px 4px;
|
||||
border-bottom: 1px solid #263244;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#waypointList li.active,
|
||||
#obstacleList li.active {
|
||||
color: #67e8f9;
|
||||
background: #102033;
|
||||
}
|
||||
|
||||
.main {
|
||||
position: relative;
|
||||
background: #0b0f17;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#mapCanvas,
|
||||
#cloudCanvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.main.view-3d #cloudCanvas {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.main.view-3d #mapCanvas {
|
||||
z-index: 2;
|
||||
background: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.main.view-2d #mapCanvas {
|
||||
z-index: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.main.view-2d #cloudCanvas {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
#mapCanvas.hidden,
|
||||
#cloudCanvas.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hud {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hud span,
|
||||
#serverState {
|
||||
border: 1px solid #344256;
|
||||
background: rgba(8, 13, 22, 0.82);
|
||||
color: #cbd5e1;
|
||||
border-radius: 6px;
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,782 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import http.server
|
||||
import json
|
||||
import math
|
||||
import socket
|
||||
import socketserver
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass
|
||||
class RoutePoint:
|
||||
id: int
|
||||
x: float
|
||||
y: float
|
||||
yaw_deg: float | None
|
||||
segment: str
|
||||
|
||||
|
||||
SVG_SEGMENT_COLORS = [
|
||||
"#d81b60",
|
||||
"#1e88e5",
|
||||
"#43a047",
|
||||
"#fb8c00",
|
||||
"#8e24aa",
|
||||
"#00897b",
|
||||
"#6d4c41",
|
||||
"#546e7a",
|
||||
"#e53935",
|
||||
"#3949ab",
|
||||
]
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Preview an ASCII PCD map with route points and yaw arrows."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--pcd",
|
||||
default="map/map_b.pcd",
|
||||
help="Path to the ASCII PCD file. Default: map/map_b.pcd",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--route",
|
||||
default="tools/test_route.json",
|
||||
help="Path to the route JSON file. Default: tools/test_route.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--floor-z-min",
|
||||
type=float,
|
||||
default=-1.6,
|
||||
help="Minimum z value kept from the PCD floor points.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--floor-z-max",
|
||||
type=float,
|
||||
default=0.4,
|
||||
help="Maximum z value kept from the PCD floor points.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--sample-step",
|
||||
type=int,
|
||||
default=25,
|
||||
help="Keep one point every N points after filtering.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--point-size",
|
||||
type=float,
|
||||
default=1.0,
|
||||
help="Scatter point size for the PCD map.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--arrow-len",
|
||||
type=float,
|
||||
default=0.35,
|
||||
help="Arrow length used to visualize waypoint yaw.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-id",
|
||||
action="store_true",
|
||||
help="Draw waypoint id labels.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--show-yaw-text",
|
||||
action="store_true",
|
||||
help="Draw yawDeg text next to each waypoint.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--connect",
|
||||
action="store_true",
|
||||
help="Connect route points in order.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--renderer",
|
||||
choices=["auto", "mpl", "html"],
|
||||
default="html",
|
||||
help="Rendering backend. Default: html.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
default="tools/pcd_route_preview.html",
|
||||
help="Output html file used by the html renderer.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=1400,
|
||||
help="Canvas width for the html/svg renderer.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--height",
|
||||
type=int,
|
||||
default=980,
|
||||
help="Canvas height for the html/svg renderer.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--serve",
|
||||
action="store_true",
|
||||
help="Serve the generated html over HTTP after rendering.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--host",
|
||||
default="0.0.0.0",
|
||||
help="Host used by the built-in HTTP server. Default: 0.0.0.0",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--port",
|
||||
type=int,
|
||||
default=8000,
|
||||
help="Port used by the built-in HTTP server. Default: 8000",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def load_ascii_pcd_xy(
|
||||
path: Path,
|
||||
floor_z_min: float,
|
||||
floor_z_max: float,
|
||||
sample_step: int,
|
||||
) -> list[tuple[float, float]]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"PCD file not found: {path}")
|
||||
|
||||
points: list[tuple[float, float]] = []
|
||||
data_started = False
|
||||
fields: list[str] = []
|
||||
x_index = 0
|
||||
y_index = 1
|
||||
z_index = 2
|
||||
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
for line in f:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
continue
|
||||
|
||||
if not data_started:
|
||||
upper = stripped.upper()
|
||||
if upper.startswith("FIELDS "):
|
||||
fields = stripped.split()[1:]
|
||||
if {"x", "y", "z"}.issubset(set(fields)):
|
||||
x_index = fields.index("x")
|
||||
y_index = fields.index("y")
|
||||
z_index = fields.index("z")
|
||||
elif upper.startswith("DATA"):
|
||||
if "ascii" not in stripped.lower():
|
||||
raise RuntimeError("Only ASCII PCD is supported.")
|
||||
data_started = True
|
||||
continue
|
||||
|
||||
parts = stripped.split()
|
||||
needed_index = max(x_index, y_index, z_index)
|
||||
if len(parts) <= needed_index:
|
||||
continue
|
||||
|
||||
try:
|
||||
x = float(parts[x_index])
|
||||
y = float(parts[y_index])
|
||||
z = float(parts[z_index])
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
if floor_z_min <= z <= floor_z_max:
|
||||
points.append((x, y))
|
||||
|
||||
if not points:
|
||||
raise RuntimeError("No usable floor points found after z filtering.")
|
||||
|
||||
return points[:: max(1, sample_step)]
|
||||
|
||||
|
||||
def load_route_points(path: Path) -> list[RoutePoint]:
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"Route file not found: {path}")
|
||||
|
||||
with path.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
raw_segments = data.get("segments", [])
|
||||
if not isinstance(raw_segments, list) or not raw_segments:
|
||||
top_level_waypoints = data.get("waypoints", [])
|
||||
if isinstance(top_level_waypoints, list) and top_level_waypoints:
|
||||
raw_segments = [
|
||||
{
|
||||
"name": "segment_1",
|
||||
"waypoints": top_level_waypoints,
|
||||
}
|
||||
]
|
||||
else:
|
||||
raise RuntimeError("Route JSON has no usable segments or waypoints.")
|
||||
|
||||
points: list[RoutePoint] = []
|
||||
fallback_id = 1
|
||||
for segment_index, segment in enumerate(raw_segments, start=1):
|
||||
if not isinstance(segment, dict):
|
||||
continue
|
||||
segment_name = str(segment.get("name", f"segment_{segment_index}")).strip() or f"segment_{segment_index}"
|
||||
raw_waypoints = segment.get("waypoints", [])
|
||||
if not isinstance(raw_waypoints, list):
|
||||
continue
|
||||
|
||||
for waypoint in raw_waypoints:
|
||||
if not isinstance(waypoint, dict):
|
||||
continue
|
||||
try:
|
||||
x = float(waypoint["x"])
|
||||
y = float(waypoint["y"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
continue
|
||||
|
||||
yaw_deg = waypoint.get("yawDeg", waypoint.get("yaw_deg"))
|
||||
if yaw_deg is not None:
|
||||
try:
|
||||
yaw_deg = float(yaw_deg)
|
||||
except (TypeError, ValueError):
|
||||
yaw_deg = None
|
||||
|
||||
point_id = waypoint.get("id", fallback_id)
|
||||
try:
|
||||
point_id = int(point_id)
|
||||
except (TypeError, ValueError):
|
||||
point_id = fallback_id
|
||||
|
||||
points.append(
|
||||
RoutePoint(
|
||||
id=point_id,
|
||||
x=x,
|
||||
y=y,
|
||||
yaw_deg=yaw_deg,
|
||||
segment=segment_name,
|
||||
)
|
||||
)
|
||||
fallback_id += 1
|
||||
|
||||
if not points:
|
||||
raise RuntimeError("Route JSON contains no valid waypoint coordinates.")
|
||||
|
||||
return points
|
||||
|
||||
|
||||
def plot_preview(
|
||||
map_points: list[tuple[float, float]],
|
||||
route_points: list[RoutePoint],
|
||||
point_size: float,
|
||||
arrow_len: float,
|
||||
show_id: bool,
|
||||
show_yaw_text: bool,
|
||||
connect: bool,
|
||||
title: str,
|
||||
) -> None:
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(12, 9))
|
||||
|
||||
map_x = [p[0] for p in map_points]
|
||||
map_y = [p[1] for p in map_points]
|
||||
ax.scatter(map_x, map_y, s=point_size, c="black", alpha=0.35, label="PCD floor")
|
||||
|
||||
segment_names: list[str] = []
|
||||
for point in route_points:
|
||||
if point.segment not in segment_names:
|
||||
segment_names.append(point.segment)
|
||||
|
||||
segment_colors = {
|
||||
name: plt.cm.tab10(index % 10) for index, name in enumerate(segment_names)
|
||||
}
|
||||
|
||||
for index, point in enumerate(route_points):
|
||||
color = segment_colors[point.segment]
|
||||
ax.scatter([point.x], [point.y], s=55, c=[color], edgecolors="white", linewidths=0.8)
|
||||
|
||||
if connect and index > 0:
|
||||
prev = route_points[index - 1]
|
||||
ax.plot([prev.x, point.x], [prev.y, point.y], color=color, linewidth=1.4, alpha=0.9)
|
||||
|
||||
if point.yaw_deg is not None:
|
||||
yaw_rad = math.radians(point.yaw_deg)
|
||||
dx = arrow_len * math.cos(yaw_rad)
|
||||
dy = arrow_len * math.sin(yaw_rad)
|
||||
ax.arrow(
|
||||
point.x,
|
||||
point.y,
|
||||
dx,
|
||||
dy,
|
||||
width=0.018,
|
||||
head_width=0.12,
|
||||
head_length=0.12,
|
||||
length_includes_head=True,
|
||||
color=color,
|
||||
alpha=0.95,
|
||||
)
|
||||
|
||||
label_parts: list[str] = []
|
||||
if show_id:
|
||||
label_parts.append(str(point.id))
|
||||
if show_yaw_text and point.yaw_deg is not None:
|
||||
label_parts.append(f"{point.yaw_deg:.1f}deg")
|
||||
if label_parts:
|
||||
ax.text(
|
||||
point.x + 0.05,
|
||||
point.y + 0.05,
|
||||
" | ".join(label_parts),
|
||||
color=color,
|
||||
fontsize=9,
|
||||
weight="bold",
|
||||
)
|
||||
|
||||
ax.set_title(title)
|
||||
ax.set_xlabel("map x")
|
||||
ax.set_ylabel("map y")
|
||||
ax.set_aspect("equal", adjustable="box")
|
||||
ax.grid(True, alpha=0.2)
|
||||
plt.tight_layout()
|
||||
plt.show()
|
||||
|
||||
|
||||
def compute_bounds(
|
||||
map_points: list[tuple[float, float]],
|
||||
route_points: list[RoutePoint],
|
||||
) -> tuple[float, float, float, float]:
|
||||
xs = [p[0] for p in map_points] + [p.x for p in route_points]
|
||||
ys = [p[1] for p in map_points] + [p.y for p in route_points]
|
||||
min_x = min(xs)
|
||||
max_x = max(xs)
|
||||
min_y = min(ys)
|
||||
max_y = max(ys)
|
||||
|
||||
if math.isclose(min_x, max_x):
|
||||
max_x = min_x + 1.0
|
||||
if math.isclose(min_y, max_y):
|
||||
max_y = min_y + 1.0
|
||||
return min_x, max_x, min_y, max_y
|
||||
|
||||
|
||||
def build_segment_color_map(route_points: list[RoutePoint]) -> dict[str, str]:
|
||||
segment_names: list[str] = []
|
||||
for point in route_points:
|
||||
if point.segment not in segment_names:
|
||||
segment_names.append(point.segment)
|
||||
return {
|
||||
name: SVG_SEGMENT_COLORS[index % len(SVG_SEGMENT_COLORS)]
|
||||
for index, name in enumerate(segment_names)
|
||||
}
|
||||
|
||||
|
||||
def compute_canvas_transform(
|
||||
min_x: float,
|
||||
max_x: float,
|
||||
min_y: float,
|
||||
max_y: float,
|
||||
width: int,
|
||||
height: int,
|
||||
padding: int,
|
||||
) -> tuple[float, float, float, float, float]:
|
||||
usable_width = max(1.0, float(width - padding * 2))
|
||||
usable_height = max(1.0, float(height - padding * 2))
|
||||
scale_x = usable_width / max(1e-9, max_x - min_x)
|
||||
scale_y = usable_height / max(1e-9, max_y - min_y)
|
||||
scale = min(scale_x, scale_y)
|
||||
|
||||
draw_width = (max_x - min_x) * scale
|
||||
draw_height = (max_y - min_y) * scale
|
||||
offset_x = padding + (usable_width - draw_width) * 0.5
|
||||
offset_y = padding + (usable_height - draw_height) * 0.5
|
||||
return scale, draw_width, draw_height, offset_x, offset_y
|
||||
|
||||
|
||||
def map_to_canvas(
|
||||
x: float,
|
||||
y: float,
|
||||
min_x: float,
|
||||
max_x: float,
|
||||
min_y: float,
|
||||
max_y: float,
|
||||
width: int,
|
||||
height: int,
|
||||
padding: int,
|
||||
) -> tuple[float, float]:
|
||||
scale, _draw_width, _draw_height, offset_x, offset_y = compute_canvas_transform(
|
||||
min_x=min_x,
|
||||
max_x=max_x,
|
||||
min_y=min_y,
|
||||
max_y=max_y,
|
||||
width=width,
|
||||
height=height,
|
||||
padding=padding,
|
||||
)
|
||||
|
||||
canvas_x = offset_x + (x - min_x) * scale
|
||||
canvas_y = height - (offset_y + (y - min_y) * scale)
|
||||
return canvas_x, canvas_y
|
||||
|
||||
|
||||
def svg_arrow_polygon(
|
||||
start_x: float,
|
||||
start_y: float,
|
||||
end_x: float,
|
||||
end_y: float,
|
||||
color: str,
|
||||
) -> str:
|
||||
dx = end_x - start_x
|
||||
dy = end_y - start_y
|
||||
length = math.hypot(dx, dy)
|
||||
if length < 1e-6:
|
||||
return ""
|
||||
|
||||
ux = dx / length
|
||||
uy = dy / length
|
||||
px = -uy
|
||||
py = ux
|
||||
|
||||
head_len = min(16.0, max(8.0, length * 0.35))
|
||||
shaft_half = 2.5
|
||||
head_half = 7.0
|
||||
|
||||
base_x = end_x - ux * head_len
|
||||
base_y = end_y - uy * head_len
|
||||
|
||||
p1 = (start_x + px * shaft_half, start_y + py * shaft_half)
|
||||
p2 = (base_x + px * shaft_half, base_y + py * shaft_half)
|
||||
p3 = (base_x + px * head_half, base_y + py * head_half)
|
||||
p4 = (end_x, end_y)
|
||||
p5 = (base_x - px * head_half, base_y - py * head_half)
|
||||
p6 = (base_x - px * shaft_half, base_y - py * shaft_half)
|
||||
p7 = (start_x - px * shaft_half, start_y - py * shaft_half)
|
||||
|
||||
points_text = " ".join(f"{x:.2f},{y:.2f}" for x, y in [p1, p2, p3, p4, p5, p6, p7])
|
||||
return f'<polygon points="{points_text}" fill="{color}" fill-opacity="0.95" />'
|
||||
|
||||
|
||||
def write_html_preview(
|
||||
output_path: Path,
|
||||
map_points: list[tuple[float, float]],
|
||||
route_points: list[RoutePoint],
|
||||
point_size: float,
|
||||
arrow_len: float,
|
||||
show_id: bool,
|
||||
show_yaw_text: bool,
|
||||
connect: bool,
|
||||
title: str,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> None:
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
min_x, max_x, min_y, max_y = compute_bounds(map_points, route_points)
|
||||
padding = 48
|
||||
segment_colors = build_segment_color_map(route_points)
|
||||
scale, draw_width, draw_height, offset_x, offset_y = compute_canvas_transform(
|
||||
min_x=min_x,
|
||||
max_x=max_x,
|
||||
min_y=min_y,
|
||||
max_y=max_y,
|
||||
width=width,
|
||||
height=height,
|
||||
padding=padding,
|
||||
)
|
||||
|
||||
svg_parts: list[str] = []
|
||||
svg_parts.append(
|
||||
f'<svg id="pcd-map-svg" viewBox="0 0 {width} {height}" width="{width}" height="{height}" '
|
||||
f'data-min-x="{min_x:.10f}" data-min-y="{min_y:.10f}" data-scale="{scale:.10f}" '
|
||||
f'data-offset-x="{offset_x:.10f}" data-offset-y="{offset_y:.10f}" '
|
||||
f'data-canvas-height="{float(height):.10f}" data-draw-width="{draw_width:.10f}" '
|
||||
f'data-draw-height="{draw_height:.10f}" xmlns="http://www.w3.org/2000/svg">'
|
||||
)
|
||||
svg_parts.append(f'<rect x="0" y="0" width="{width}" height="{height}" fill="#f7f7f5" />')
|
||||
|
||||
for x, y in map_points:
|
||||
cx, cy = map_to_canvas(x, y, min_x, max_x, min_y, max_y, width, height, padding)
|
||||
radius = max(0.35, point_size * 0.7)
|
||||
svg_parts.append(
|
||||
f'<circle cx="{cx:.2f}" cy="{cy:.2f}" r="{radius:.2f}" fill="#1f1f1f" fill-opacity="0.35" />'
|
||||
)
|
||||
|
||||
if connect:
|
||||
for prev, curr in zip(route_points, route_points[1:]):
|
||||
color = segment_colors[curr.segment]
|
||||
x1, y1 = map_to_canvas(prev.x, prev.y, min_x, max_x, min_y, max_y, width, height, padding)
|
||||
x2, y2 = map_to_canvas(curr.x, curr.y, min_x, max_x, min_y, max_y, width, height, padding)
|
||||
svg_parts.append(
|
||||
f'<line x1="{x1:.2f}" y1="{y1:.2f}" x2="{x2:.2f}" y2="{y2:.2f}" '
|
||||
f'stroke="{color}" stroke-width="2" stroke-opacity="0.85" />'
|
||||
)
|
||||
|
||||
for point in route_points:
|
||||
color = segment_colors[point.segment]
|
||||
cx, cy = map_to_canvas(point.x, point.y, min_x, max_x, min_y, max_y, width, height, padding)
|
||||
svg_parts.append(
|
||||
f'<circle class="route-point" data-id="{point.id}" data-segment="{html.escape(point.segment)}" '
|
||||
f'data-map-x="{point.x:.6f}" data-map-y="{point.y:.6f}" '
|
||||
+ (
|
||||
f'data-yaw-deg="{point.yaw_deg:.3f}" '
|
||||
if point.yaw_deg is not None else
|
||||
""
|
||||
)
|
||||
+ f'cx="{cx:.2f}" cy="{cy:.2f}" r="6.5" fill="{color}" stroke="#ffffff" stroke-width="1.5">'
|
||||
f"<title>ID {point.id} | x={point.x:.3f} y={point.y:.3f}"
|
||||
+ (f" | yaw={point.yaw_deg:.1f}deg" if point.yaw_deg is not None else "")
|
||||
+ "</title></circle>"
|
||||
)
|
||||
|
||||
if point.yaw_deg is not None:
|
||||
yaw_rad = math.radians(point.yaw_deg)
|
||||
end_x, end_y = map_to_canvas(
|
||||
point.x + arrow_len * math.cos(yaw_rad),
|
||||
point.y + arrow_len * math.sin(yaw_rad),
|
||||
min_x,
|
||||
max_x,
|
||||
min_y,
|
||||
max_y,
|
||||
width,
|
||||
height,
|
||||
padding,
|
||||
)
|
||||
svg_parts.append(svg_arrow_polygon(cx, cy, end_x, end_y, color))
|
||||
|
||||
label_parts: list[str] = []
|
||||
if show_id:
|
||||
label_parts.append(str(point.id))
|
||||
if show_yaw_text and point.yaw_deg is not None:
|
||||
label_parts.append(f"{point.yaw_deg:.1f}deg")
|
||||
if label_parts:
|
||||
svg_parts.append(
|
||||
f'<text x="{cx + 8:.2f}" y="{cy - 8:.2f}" font-size="12" font-weight="700" '
|
||||
f'fill="{color}">{html.escape(" | ".join(label_parts))}</text>'
|
||||
)
|
||||
|
||||
svg_parts.append(
|
||||
f'<text x="24" y="30" font-size="20" font-weight="700" fill="#222">{html.escape(title)}</text>'
|
||||
)
|
||||
svg_parts.append(
|
||||
f'<text x="24" y="{height - 24}" font-size="13" fill="#444">'
|
||||
f'{html.escape(f"route points: {len(route_points)} | pcd samples: {len(map_points)} | scale: {scale:.2f}px/m")}'
|
||||
"</text>"
|
||||
)
|
||||
svg_parts.append(
|
||||
'<g id="click-marker" visibility="hidden">'
|
||||
'<circle cx="0" cy="0" r="8" fill="none" stroke="#ff1744" stroke-width="2" />'
|
||||
'<line x1="-12" y1="0" x2="12" y2="0" stroke="#ff1744" stroke-width="2" />'
|
||||
'<line x1="0" y1="-12" x2="0" y2="12" stroke="#ff1744" stroke-width="2" />'
|
||||
"</g>"
|
||||
)
|
||||
svg_parts.append("</svg>")
|
||||
|
||||
html_text = "\n".join(
|
||||
[
|
||||
"<!DOCTYPE html>",
|
||||
'<html lang="zh-CN">',
|
||||
"<head>",
|
||||
'<meta charset="utf-8" />',
|
||||
f"<title>{html.escape(title)}</title>",
|
||||
"<style>",
|
||||
"body { margin: 0; background: #ece9e1; font-family: 'Segoe UI', sans-serif; color: #222; }",
|
||||
".wrap { padding: 20px; }",
|
||||
".panel { background: #ffffff; border-radius: 12px; box-shadow: 0 10px 30px rgba(0,0,0,0.10); overflow: auto; }",
|
||||
".toolbar { display: flex; flex-wrap: wrap; gap: 12px 18px; align-items: center; padding: 14px 20px 0 20px; font-size: 14px; }",
|
||||
".toolbar strong { color: #111; }",
|
||||
".meta { padding: 10px 20px 6px 20px; color: #444; font-size: 14px; }",
|
||||
".chip { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border-radius: 999px; background: #f3f1eb; }",
|
||||
".swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; }",
|
||||
"#click-coords { font-family: Consolas, 'Courier New', monospace; }",
|
||||
"#point-detail { font-family: Consolas, 'Courier New', monospace; }",
|
||||
"svg { display: block; margin: 0 auto; cursor: crosshair; user-select: none; }",
|
||||
".hint { padding: 0 20px 12px 20px; color: #666; font-size: 13px; }",
|
||||
"</style>",
|
||||
"</head>",
|
||||
"<body>",
|
||||
'<div class="wrap">',
|
||||
'<div class="panel">',
|
||||
'<div class="toolbar">',
|
||||
'<div class="chip"><strong>点击坐标</strong><span id="click-coords">尚未点击</span></div>',
|
||||
'<div class="chip"><strong>点位信息</strong><span id="point-detail">点击路线点可查看 id / yaw</span></div>',
|
||||
"</div>",
|
||||
'<div class="meta">这是纯浏览器预览页。点击 PCD 或空白位置会显示当前地图坐标,点击路线点还会显示该点的 id、segment 和 yaw。</div>',
|
||||
'<div class="hint">浏览器可直接缩放页面查看细节,图上的红色十字为你最近一次点击的位置。</div>',
|
||||
"\n".join(svg_parts),
|
||||
"<script>",
|
||||
"(() => {",
|
||||
" const svg = document.getElementById('pcd-map-svg');",
|
||||
" const marker = document.getElementById('click-marker');",
|
||||
" const clickCoords = document.getElementById('click-coords');",
|
||||
" const pointDetail = document.getElementById('point-detail');",
|
||||
" const minX = parseFloat(svg.dataset.minX);",
|
||||
" const minY = parseFloat(svg.dataset.minY);",
|
||||
" const scale = parseFloat(svg.dataset.scale);",
|
||||
" const offsetX = parseFloat(svg.dataset.offsetX);",
|
||||
" const offsetY = parseFloat(svg.dataset.offsetY);",
|
||||
" const canvasHeight = parseFloat(svg.dataset.canvasHeight);",
|
||||
" function svgPointFromEvent(evt) {",
|
||||
" const pt = svg.createSVGPoint();",
|
||||
" pt.x = evt.clientX;",
|
||||
" pt.y = evt.clientY;",
|
||||
" return pt.matrixTransform(svg.getScreenCTM().inverse());",
|
||||
" }",
|
||||
" function svgToMap(px, py) {",
|
||||
" const mapX = minX + (px - offsetX) / scale;",
|
||||
" const mapY = minY + ((canvasHeight - py) - offsetY) / scale;",
|
||||
" return { x: mapX, y: mapY };",
|
||||
" }",
|
||||
" function formatNum(v) {",
|
||||
" return Number.isFinite(v) ? v.toFixed(3) : 'NaN';",
|
||||
" }",
|
||||
" function updateFromEvent(evt) {",
|
||||
" const svgPoint = svgPointFromEvent(evt);",
|
||||
" const mapPoint = svgToMap(svgPoint.x, svgPoint.y);",
|
||||
" marker.setAttribute('transform', `translate(${svgPoint.x} ${svgPoint.y})`);",
|
||||
" marker.setAttribute('visibility', 'visible');",
|
||||
" clickCoords.textContent = `x=${formatNum(mapPoint.x)}, y=${formatNum(mapPoint.y)}`;",
|
||||
" const routePoint = evt.target.closest('.route-point');",
|
||||
" if (routePoint) {",
|
||||
" const yaw = routePoint.dataset.yawDeg;",
|
||||
" pointDetail.textContent = `id=${routePoint.dataset.id}, segment=${routePoint.dataset.segment}, x=${routePoint.dataset.mapX}, y=${routePoint.dataset.mapY}` + (yaw ? `, yaw=${Number(yaw).toFixed(1)}deg` : '');",
|
||||
" } else {",
|
||||
" pointDetail.textContent = '未点击路线点';",
|
||||
" }",
|
||||
" }",
|
||||
" svg.addEventListener('click', updateFromEvent);",
|
||||
"})();",
|
||||
"</script>",
|
||||
"</div>",
|
||||
"</div>",
|
||||
"</body>",
|
||||
"</html>",
|
||||
]
|
||||
)
|
||||
|
||||
output_path.write_text(html_text, encoding="utf-8")
|
||||
|
||||
|
||||
def guess_local_ip() -> str:
|
||||
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
try:
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
return str(sock.getsockname()[0])
|
||||
except OSError:
|
||||
return "127.0.0.1"
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
|
||||
def serve_output_file(output_path: Path, host: str, port: int) -> None:
|
||||
output_path = output_path.resolve()
|
||||
web_root = output_path.parent.resolve()
|
||||
relative_url = output_path.relative_to(web_root).as_posix()
|
||||
|
||||
class PreviewHttpHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, directory=str(web_root), **kwargs)
|
||||
|
||||
server_address = (host, int(port))
|
||||
with socketserver.TCPServer(server_address, PreviewHttpHandler) as httpd:
|
||||
local_ip = guess_local_ip()
|
||||
bound_host = host if host not in {"0.0.0.0", "::"} else local_ip
|
||||
print("Preview server is running.")
|
||||
print(f"Local URL: http://127.0.0.1:{port}/{relative_url}")
|
||||
print(f"LAN URL: http://{bound_host}:{port}/{relative_url}")
|
||||
print("Open the LAN URL from your Windows browser.")
|
||||
print("Press Ctrl+C to stop the server.")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nServer stopped.")
|
||||
|
||||
|
||||
def render_preview(
|
||||
renderer: str,
|
||||
output_path: Path,
|
||||
map_points: list[tuple[float, float]],
|
||||
route_points: list[RoutePoint],
|
||||
point_size: float,
|
||||
arrow_len: float,
|
||||
show_id: bool,
|
||||
show_yaw_text: bool,
|
||||
connect: bool,
|
||||
title: str,
|
||||
width: int,
|
||||
height: int,
|
||||
) -> str:
|
||||
if renderer in {"auto", "mpl"}:
|
||||
try:
|
||||
plot_preview(
|
||||
map_points=map_points,
|
||||
route_points=route_points,
|
||||
point_size=point_size,
|
||||
arrow_len=arrow_len,
|
||||
show_id=show_id,
|
||||
show_yaw_text=show_yaw_text,
|
||||
connect=connect,
|
||||
title=title,
|
||||
)
|
||||
return "mpl"
|
||||
except Exception as exc:
|
||||
if renderer == "mpl":
|
||||
raise RuntimeError(
|
||||
"matplotlib 渲染失败。当前环境很可能存在 numpy / matplotlib 二进制不兼容问题。"
|
||||
) from exc
|
||||
print(f"[info] matplotlib 不可用,自动切换到 html 渲染: {exc}")
|
||||
|
||||
write_html_preview(
|
||||
output_path=output_path,
|
||||
map_points=map_points,
|
||||
route_points=route_points,
|
||||
point_size=point_size,
|
||||
arrow_len=arrow_len,
|
||||
show_id=show_id,
|
||||
show_yaw_text=show_yaw_text,
|
||||
connect=connect,
|
||||
title=title,
|
||||
width=width,
|
||||
height=height,
|
||||
)
|
||||
return "html"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
pcd_path = Path(args.pcd)
|
||||
route_path = Path(args.route)
|
||||
output_path = Path(args.output)
|
||||
|
||||
map_points = load_ascii_pcd_xy(
|
||||
path=pcd_path,
|
||||
floor_z_min=float(args.floor_z_min),
|
||||
floor_z_max=float(args.floor_z_max),
|
||||
sample_step=max(1, int(args.sample_step)),
|
||||
)
|
||||
route_points = load_route_points(route_path)
|
||||
|
||||
title = f"PCD Route Preview: {pcd_path.name} + {route_path.name}"
|
||||
used_renderer = render_preview(
|
||||
renderer=str(args.renderer),
|
||||
output_path=output_path,
|
||||
map_points=map_points,
|
||||
route_points=route_points,
|
||||
point_size=float(args.point_size),
|
||||
arrow_len=float(args.arrow_len),
|
||||
show_id=bool(args.show_id),
|
||||
show_yaw_text=bool(args.show_yaw_text),
|
||||
connect=bool(args.connect),
|
||||
title=title,
|
||||
width=max(600, int(args.width)),
|
||||
height=max(400, int(args.height)),
|
||||
)
|
||||
if used_renderer == "html":
|
||||
print(f"HTML preview written to: {output_path.resolve()}")
|
||||
if bool(args.serve):
|
||||
if used_renderer != "html":
|
||||
raise RuntimeError("Built-in server currently supports html output only.")
|
||||
serve_output_file(
|
||||
output_path=output_path,
|
||||
host=str(args.host),
|
||||
port=int(args.port),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,204 @@
|
||||
{
|
||||
"name": "test_route",
|
||||
"map": "map_b",
|
||||
"frame_id": "map",
|
||||
"yawToleranceDegDefault": 30.0,
|
||||
"createdAt": "2026-07-02T12:18:22.647Z",
|
||||
"segments": [
|
||||
{
|
||||
"name": "segment_1",
|
||||
"obstacle": "slalom",
|
||||
"waypoints": [
|
||||
{
|
||||
"id": 1,
|
||||
"x": 5.359,
|
||||
"y": 2.442,
|
||||
"yawDeg": 0,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"x": 6.924,
|
||||
"y": 2.388,
|
||||
"yawDeg": -90,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"x": 6.827,
|
||||
"y": 0.712,
|
||||
"yawDeg": -5,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"x": 9.368,
|
||||
"y": 0.706,
|
||||
"yawDeg": -3,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"x": 11.373,
|
||||
"y": 0.43,
|
||||
"yawDeg": 0,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"x": 12.877,
|
||||
"y": 0.432,
|
||||
"yawDeg": 90,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"x": 12.935,
|
||||
"y": 2.241,
|
||||
"yawDeg": 88.2,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"x": 12.991,
|
||||
"y": 4.277,
|
||||
"yawDeg": 180,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"x": 12.004,
|
||||
"y": 4.339,
|
||||
"yawDeg": -90,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 10,
|
||||
"x": 11.872,
|
||||
"y": 2.583,
|
||||
"yawDeg": 180,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"x": 9.5719,
|
||||
"y": 2.7972,
|
||||
"yawDeg": 174.7,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 12,
|
||||
"x": 9.508,
|
||||
"y": 3.562,
|
||||
"yawDeg": 180,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 13,
|
||||
"x": 8.414,
|
||||
"y": 3.573,
|
||||
"yawDeg": 125,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 14,
|
||||
"x": 7.867,
|
||||
"y": 4.528,
|
||||
"yawDeg": 180,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 15,
|
||||
"x": 4.475,
|
||||
"y": 4.743,
|
||||
"yawDeg": 180,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.15
|
||||
},
|
||||
{
|
||||
"id": 16,
|
||||
"x": 4.926,
|
||||
"y": 4.785,
|
||||
"yawDeg": 180,
|
||||
"speed": 0.4,
|
||||
"policy": "crawl",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 17,
|
||||
"x": 3.2951,
|
||||
"y": 4.7963,
|
||||
"yawDeg": 179.6,
|
||||
"speed": 0.4,
|
||||
"policy": "crawl",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 18,
|
||||
"x": 3.092,
|
||||
"y": 4.777,
|
||||
"yawDeg": -180,
|
||||
"speed": 0.4,
|
||||
"policy": "crawl",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 19,
|
||||
"x": 1.694,
|
||||
"y": 4.886,
|
||||
"yawDeg": 210,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 20,
|
||||
"x": 1.055,
|
||||
"y": 4.548,
|
||||
"yawDeg": -90,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.1
|
||||
},
|
||||
{
|
||||
"id": 21,
|
||||
"x": 1.042,
|
||||
"y": 2.92,
|
||||
"yawDeg": -90.4,
|
||||
"speed": 0.4,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.1
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "test_route",
|
||||
"map": "map_b",
|
||||
"frame_id": "map",
|
||||
"yawToleranceDegDefault": 45.0,
|
||||
"requireYawDefault": false,
|
||||
"preDockDistanceDefault": 0.35,
|
||||
"preDockToleranceDefault": 0.18,
|
||||
"createdAt": "2026-07-02T15:07:00.219Z",
|
||||
"segments": [
|
||||
{
|
||||
"name": "segment_1",
|
||||
"obstacle": "slalom",
|
||||
"waypoints": [
|
||||
{
|
||||
"id": 1,
|
||||
"x": 1.2,
|
||||
"y": 0.5,
|
||||
"yawDeg": 90,
|
||||
"speed": 0.35,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.25
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"x": 1.247,
|
||||
"y": 1.748,
|
||||
"yawDeg": 0,
|
||||
"speed": 0.35,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.25
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"x": 2.312,
|
||||
"y": 1.673,
|
||||
"yawDeg": -90,
|
||||
"speed": 0.35,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.25
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"x": 2.33,
|
||||
"y": 0.496,
|
||||
"yawDeg": -180,
|
||||
"speed": 0.35,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.25
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"x": 0.4626,
|
||||
"y": 0.6271,
|
||||
"yawDeg": 176,
|
||||
"speed": 0.35,
|
||||
"policy": "rough",
|
||||
"tolerance": 0.25,
|
||||
"requireYaw": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# Windows Runtime Web Debug
|
||||
|
||||
This folder contains a small Windows-side HTTP server for debugging the Nano runtime Web bridge through UDP.
|
||||
|
||||
Most of the time you can open the Nano runtime Web directly:
|
||||
|
||||
```text
|
||||
http://<nano-ip>:18080
|
||||
```
|
||||
|
||||
Use this tool only when you want the browser and HTTP server to run on Windows while Nano communicates over UDP.
|
||||
|
||||
## Start
|
||||
|
||||
From the repository root:
|
||||
|
||||
```powershell
|
||||
python .\tools\win_web_debug\server.py --nano-host <nano-ip> --http-port 8088
|
||||
```
|
||||
|
||||
Or from this folder:
|
||||
|
||||
```powershell
|
||||
python server.py --nano-host <nano-ip> --http-port 8088
|
||||
```
|
||||
|
||||
Open:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8088
|
||||
```
|
||||
|
||||
## Related Runtime Node
|
||||
|
||||
Nano side:
|
||||
|
||||
```text
|
||||
src/sim2real_runtime/src/web_udp_bridge_node.py
|
||||
```
|
||||
|
||||
See:
|
||||
|
||||
- [Runtime Web](../../docs/RUNTIME_WEB.md)
|
||||
- [Common Commands](../../docs/COMMANDS.md)
|
||||
@@ -0,0 +1,23 @@
|
||||
# Windows Runtime Web 调试桥
|
||||
|
||||
大多数时候直接打开 Nano Web 即可:
|
||||
|
||||
```text
|
||||
http://<nano-ip>:18080
|
||||
```
|
||||
|
||||
这个工具只在你希望 HTTP 服务跑在 Windows、Nano 通过 UDP 传状态时使用。
|
||||
|
||||
## 启动
|
||||
|
||||
在仓库根目录:
|
||||
|
||||
```powershell
|
||||
python .\tools\win_web_debug\server.py --nano-host <nano-ip> --http-port 8088
|
||||
```
|
||||
|
||||
打开:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:8088
|
||||
```
|
||||
@@ -12,6 +12,7 @@ from typing import Optional
|
||||
|
||||
STATE_LOCK = threading.Lock()
|
||||
LATEST_STATE: dict = {"type": "state", "connected": False}
|
||||
LATEST_MAP: Optional[dict] = None
|
||||
NANO_ADDR: tuple[str, int]
|
||||
UDP_SOCK: socket.socket
|
||||
|
||||
@@ -28,7 +29,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
deadline = time.time() + 1.0
|
||||
while time.time() < deadline:
|
||||
with STATE_LOCK:
|
||||
maybe_map = LATEST_STATE.get("map")
|
||||
maybe_map = LATEST_MAP
|
||||
if isinstance(maybe_map, dict):
|
||||
data = json.dumps(maybe_map).encode("utf-8")
|
||||
self._json(200, data)
|
||||
@@ -69,7 +70,7 @@ def send_udp(payload: dict) -> None:
|
||||
|
||||
|
||||
def udp_rx_loop(sock: socket.socket) -> None:
|
||||
global LATEST_STATE
|
||||
global LATEST_STATE, LATEST_MAP
|
||||
while True:
|
||||
try:
|
||||
data, _ = sock.recvfrom(65535)
|
||||
@@ -77,7 +78,10 @@ def udp_rx_loop(sock: socket.socket) -> None:
|
||||
payload["connected"] = True
|
||||
payload["local_receive_time"] = time.time()
|
||||
with STATE_LOCK:
|
||||
LATEST_STATE = payload
|
||||
if payload.get("type") == "map":
|
||||
LATEST_MAP = payload
|
||||
else:
|
||||
LATEST_STATE = payload
|
||||
except Exception:
|
||||
time.sleep(0.01)
|
||||
|
||||
|
||||
@@ -15,20 +15,36 @@ let dragging = false;
|
||||
let mapModel = null;
|
||||
let latestPose = null;
|
||||
let lastGoal = null;
|
||||
let latestConnected = false;
|
||||
let pendingModePromise = null;
|
||||
let currentGoalName = '';
|
||||
let currentGoalIndex = -1;
|
||||
let currentGoalTotal = 0;
|
||||
let latestModelState = { current_model: '--', switch_state: '--', backend: '--', switching: false };
|
||||
let goalPointsByName = {};
|
||||
let defaultMissionName = '';
|
||||
let defaultMissionGoals = [];
|
||||
let routeAlignmentInfo = {};
|
||||
let activeNavPath = { goal_name: '', stage: 'idle', path_index: 0, points: [] };
|
||||
|
||||
const mapCanvas = $('map-canvas');
|
||||
const mapCtx = mapCanvas ? mapCanvas.getContext('2d') : null;
|
||||
|
||||
async function post(payload) {
|
||||
try {
|
||||
await fetch('/api/control', {
|
||||
const res = await fetch('/api/control', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok || data.ok === false) {
|
||||
throw new Error(data.error || `HTTP ${res.status}`);
|
||||
}
|
||||
return { ok: true, data };
|
||||
} catch (e) {
|
||||
appendEvent('API_ERROR', e.message, 'bad');
|
||||
return { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,32 +84,71 @@ function highlightMode(mode) {
|
||||
el.className = 'stage ' + mode;
|
||||
}
|
||||
currentMode = mode;
|
||||
updateTaskButtonState();
|
||||
}
|
||||
|
||||
async function setMode(mode) {
|
||||
if (mode === 'WEB') {
|
||||
const ok = confirm('Confirm switch to WEB control?\nMake sure the robot is safe and velocity is zero.');
|
||||
if (!ok) return false;
|
||||
function updateTaskButtonState() {
|
||||
const btn = $('btn-run-task');
|
||||
if (!btn) return;
|
||||
const hasMission = Boolean(defaultMissionName);
|
||||
const enabled = latestConnected && currentMode === 'NAV' && hasMission;
|
||||
btn.disabled = !enabled;
|
||||
btn.textContent = hasMission ? `Run ${defaultMissionName}` : 'Run Mission';
|
||||
if (!hasMission) {
|
||||
btn.title = 'No default mission loaded from backend';
|
||||
return;
|
||||
}
|
||||
await post({ type: 'mode', mode });
|
||||
highlightMode(mode);
|
||||
appendEvent('MODE_SET', `-> ${mode}`, 'ok');
|
||||
btn.title = enabled
|
||||
? `Run default mission ${defaultMissionName}`
|
||||
: 'Available only in NAV mode while connected';
|
||||
}
|
||||
|
||||
function setText(id, text, cls) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
if (cls !== undefined) el.className = 'diag-value ' + cls;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 2) {
|
||||
return Number.isFinite(value) ? Number(value).toFixed(digits) : '--';
|
||||
}
|
||||
|
||||
function setLastGoalByName(goalName) {
|
||||
const goal = goalPointsByName[goalName];
|
||||
if (!goal) return false;
|
||||
lastGoal = { x: goal.x, y: goal.y };
|
||||
currentGoalName = goalName;
|
||||
currentGoalIndex = defaultMissionGoals.findIndex(goalItem => goalItem.name === goalName);
|
||||
setText('d-active-goal', goalName, 'active');
|
||||
renderTaskGoals();
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureMode(mode) {
|
||||
if (currentMode === mode) return true;
|
||||
if (pendingModePromise) {
|
||||
const activeMode = await pendingModePromise;
|
||||
if (activeMode === mode) return true;
|
||||
function syncGoalMarkerFromNavStatus(statusText) {
|
||||
if (!statusText) return;
|
||||
const missionStatusMatch = statusText.match(/waypoint\s+(\d+)\/(\d+)\s*->\s*([A-Za-z0-9_()=.,-]+)\b/i);
|
||||
if (missionStatusMatch) {
|
||||
currentGoalIndex = Math.max(0, Number.parseInt(missionStatusMatch[1], 10) - 1);
|
||||
currentGoalTotal = Math.max(0, Number.parseInt(missionStatusMatch[2], 10));
|
||||
setLastGoalByName(missionStatusMatch[3]);
|
||||
return;
|
||||
}
|
||||
const nextGoalMatch = statusText.match(/->\s*([A-Za-z0-9_()=.,-]+)\b/);
|
||||
if (nextGoalMatch && setLastGoalByName(nextGoalMatch[1])) {
|
||||
return;
|
||||
}
|
||||
const reachedMatch = statusText.match(/reached\s+([A-Za-z0-9_()=.,-]+)\b/i);
|
||||
if (reachedMatch && setLastGoalByName(reachedMatch[1])) {
|
||||
return;
|
||||
}
|
||||
if (/navigation stopped:/i.test(statusText)) {
|
||||
currentGoalName = '';
|
||||
currentGoalIndex = -1;
|
||||
currentGoalTotal = 0;
|
||||
setText('d-active-goal', '--');
|
||||
renderTaskGoals();
|
||||
}
|
||||
pendingModePromise = (async () => {
|
||||
const ok = await setMode(mode);
|
||||
return ok ? mode : currentMode;
|
||||
})();
|
||||
const resolvedMode = await pendingModePromise;
|
||||
pendingModePromise = null;
|
||||
return resolvedMode === mode;
|
||||
}
|
||||
|
||||
function worldToCanvas(x, y) {
|
||||
@@ -115,12 +170,16 @@ function canvasToWorld(px, py) {
|
||||
}
|
||||
|
||||
function buildMapModel(points) {
|
||||
if (!points || !points.length || !mapCanvas) return null;
|
||||
if (!mapCanvas) return null;
|
||||
const boundsPoints = [...(points || [])];
|
||||
for (const goal of defaultMissionGoals) boundsPoints.push([goal.x, goal.y]);
|
||||
if (!boundsPoints.length) return null;
|
||||
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const [x, y] of points) {
|
||||
for (const [x, y] of boundsPoints) {
|
||||
if (x < minX) minX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (x > maxX) maxX = x;
|
||||
@@ -132,7 +191,102 @@ function buildMapModel(points) {
|
||||
const spanX = Math.max(maxX - minX, 1e-6);
|
||||
const spanY = Math.max(maxY - minY, 1e-6);
|
||||
const scale = Math.min(usableW / spanX, usableH / spanY);
|
||||
return { minX, minY, maxX, maxY, scale, pad, drawH: usableH };
|
||||
return { minX, minY, maxX, maxY, scale, pad, drawH: usableH, points: points || [] };
|
||||
}
|
||||
|
||||
function goalColor(goal) {
|
||||
return goal.policy === 'crawl' ? '#30d158' : '#ffd60a';
|
||||
}
|
||||
|
||||
function drawMissionPath() {
|
||||
if (!mapCtx || defaultMissionGoals.length < 2) return;
|
||||
mapCtx.save();
|
||||
mapCtx.lineWidth = 2;
|
||||
mapCtx.strokeStyle = 'rgba(34, 197, 94, 0.95)';
|
||||
mapCtx.beginPath();
|
||||
for (let i = 0; i < defaultMissionGoals.length; i += 1) {
|
||||
const goal = defaultMissionGoals[i];
|
||||
const p = worldToCanvas(goal.x, goal.y);
|
||||
if (!p) continue;
|
||||
if (i === 0) mapCtx.moveTo(p.x, p.y);
|
||||
else mapCtx.lineTo(p.x, p.y);
|
||||
}
|
||||
mapCtx.stroke();
|
||||
|
||||
if (currentGoalIndex > 0) {
|
||||
mapCtx.strokeStyle = 'rgba(10, 132, 255, 0.95)';
|
||||
mapCtx.lineWidth = 3;
|
||||
mapCtx.beginPath();
|
||||
let started = false;
|
||||
for (let i = 0; i <= Math.min(currentGoalIndex, defaultMissionGoals.length - 1); i += 1) {
|
||||
const goal = defaultMissionGoals[i];
|
||||
const p = worldToCanvas(goal.x, goal.y);
|
||||
if (!p) continue;
|
||||
if (!started) {
|
||||
mapCtx.moveTo(p.x, p.y);
|
||||
started = true;
|
||||
} else {
|
||||
mapCtx.lineTo(p.x, p.y);
|
||||
}
|
||||
}
|
||||
if (started) mapCtx.stroke();
|
||||
}
|
||||
mapCtx.restore();
|
||||
}
|
||||
|
||||
function drawActiveNavPath() {
|
||||
if (!mapCtx || !activeNavPath || !Array.isArray(activeNavPath.points) || activeNavPath.points.length < 2) return;
|
||||
mapCtx.save();
|
||||
mapCtx.lineWidth = 2.5;
|
||||
mapCtx.strokeStyle = 'rgba(168, 85, 247, 0.95)';
|
||||
mapCtx.beginPath();
|
||||
let started = false;
|
||||
for (const point of activeNavPath.points) {
|
||||
if (!Array.isArray(point) || point.length < 2) continue;
|
||||
const p = worldToCanvas(point[0], point[1]);
|
||||
if (!p) continue;
|
||||
if (!started) {
|
||||
mapCtx.moveTo(p.x, p.y);
|
||||
started = true;
|
||||
} else {
|
||||
mapCtx.lineTo(p.x, p.y);
|
||||
}
|
||||
}
|
||||
if (started) mapCtx.stroke();
|
||||
|
||||
const highlightIndex = Math.min(
|
||||
Math.max(0, Number(activeNavPath.path_index || 0)),
|
||||
activeNavPath.points.length - 1,
|
||||
);
|
||||
const highlightPoint = activeNavPath.points[highlightIndex];
|
||||
if (Array.isArray(highlightPoint) && highlightPoint.length >= 2) {
|
||||
const p = worldToCanvas(highlightPoint[0], highlightPoint[1]);
|
||||
if (p) {
|
||||
mapCtx.fillStyle = '#c084fc';
|
||||
mapCtx.beginPath();
|
||||
mapCtx.arc(p.x, p.y, 5, 0, Math.PI * 2);
|
||||
mapCtx.fill();
|
||||
}
|
||||
}
|
||||
mapCtx.restore();
|
||||
}
|
||||
|
||||
function drawGoal(goal, index) {
|
||||
const p = worldToCanvas(goal.x, goal.y);
|
||||
if (!p || !mapCtx) return;
|
||||
const active = goal.name === currentGoalName;
|
||||
const completed = currentGoalIndex > index;
|
||||
mapCtx.fillStyle = active ? '#ff9f0a' : completed ? '#67e8f9' : goalColor(goal);
|
||||
mapCtx.beginPath();
|
||||
mapCtx.arc(p.x, p.y, active ? 7 : 5, 0, Math.PI * 2);
|
||||
mapCtx.fill();
|
||||
mapCtx.strokeStyle = '#111827';
|
||||
mapCtx.lineWidth = 1.5;
|
||||
mapCtx.stroke();
|
||||
|
||||
mapCtx.fillStyle = active ? '#ffcf66' : '#ffe680';
|
||||
mapCtx.font = '12px sans-serif';
|
||||
mapCtx.fillText(`${index + 1}.${goal.name}`, p.x + 8, p.y - 8);
|
||||
}
|
||||
|
||||
function drawMap() {
|
||||
@@ -155,6 +309,13 @@ function drawMap() {
|
||||
mapCtx.fillRect(p.x, p.y, 1.5, 1.5);
|
||||
}
|
||||
|
||||
drawMissionPath();
|
||||
drawActiveNavPath();
|
||||
|
||||
for (const [index, goal] of defaultMissionGoals.entries()) {
|
||||
drawGoal(goal, index);
|
||||
}
|
||||
|
||||
if (latestPose) {
|
||||
const p = worldToCanvas(latestPose.x, latestPose.y);
|
||||
if (p) {
|
||||
@@ -186,16 +347,74 @@ function drawMap() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatRouteAlignment(info) {
|
||||
if (!info || Object.keys(info).length === 0) return '--';
|
||||
const applied = Number.isFinite(info.applied_deg) ? `${Number(info.applied_deg).toFixed(2)}deg` : '--';
|
||||
const hits = Number.isFinite(info.hits) && Number.isFinite(info.total) ? `${info.hits}/${info.total}` : '--';
|
||||
const reason = info.reason || (info.enabled ? 'auto align' : 'fixed');
|
||||
return `${applied} hits=${hits} ${reason}`;
|
||||
}
|
||||
|
||||
function renderTaskGoals() {
|
||||
const list = $('task-goals-list');
|
||||
if (!list) return;
|
||||
if (!defaultMissionGoals.length) {
|
||||
list.innerHTML = '<div class="task-goal-item empty">No mission goals loaded</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = defaultMissionGoals.map((goal, index) => {
|
||||
const yawText = Number.isFinite(goal.yaw_deg) ? `${Number(goal.yaw_deg).toFixed(1)}deg` : '--';
|
||||
const yawTolText = Number.isFinite(goal.yaw_tolerance_deg) ? `${Number(goal.yaw_tolerance_deg).toFixed(1)}deg` : '--';
|
||||
const tolText = Number.isFinite(goal.tolerance) ? Number(goal.tolerance).toFixed(2) : '--';
|
||||
const policyText = goal.policy || 'rough';
|
||||
const requireYawText = goal.require_yaw ? ' strict_yaw' : '';
|
||||
const preDockText = Number.isFinite(goal.pre_dock_distance) ? ` pre_dock=${Number(goal.pre_dock_distance).toFixed(2)}` : '';
|
||||
const activeClass = goal.name === currentGoalName ? ' active' : '';
|
||||
return `
|
||||
<div class="task-goal-item${activeClass}">
|
||||
<div class="task-goal-head">
|
||||
<span class="task-goal-index">${index + 1}</span>
|
||||
<span class="task-goal-name">${goal.name}</span>
|
||||
<span class="task-goal-policy ${policyText}">${policyText}</span>
|
||||
</div>
|
||||
<div class="task-goal-meta">x=${formatNumber(goal.x)} y=${formatNumber(goal.y)} yaw=${yawText} yaw_tol=${yawTolText} tol=${tolText}${requireYawText}${preDockText}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function applyMapMetadata(data) {
|
||||
const goalSpecs = Array.isArray(data.goal_specs) ? data.goal_specs : [];
|
||||
goalPointsByName = {};
|
||||
for (const goal of goalSpecs) {
|
||||
if (!goal || !goal.name) continue;
|
||||
goalPointsByName[goal.name] = goal;
|
||||
}
|
||||
defaultMissionName = data.default_mission_name || '';
|
||||
defaultMissionGoals = Array.isArray(data.default_mission_goals) ? data.default_mission_goals : [];
|
||||
routeAlignmentInfo = data.route_alignment || {};
|
||||
activeNavPath = data.nav_path || activeNavPath;
|
||||
currentGoalIndex = currentGoalName
|
||||
? defaultMissionGoals.findIndex(goal => goal.name === currentGoalName)
|
||||
: -1;
|
||||
currentGoalTotal = defaultMissionGoals.length;
|
||||
|
||||
setText('d-task-mission', defaultMissionName || '--', defaultMissionName ? 'ok' : '');
|
||||
setText('d-task-goals', String(defaultMissionGoals.length || 0), defaultMissionGoals.length ? 'ok' : '');
|
||||
setText('d-route-align', formatRouteAlignment(routeAlignmentInfo), routeAlignmentInfo.enabled ? 'ok' : 'warn');
|
||||
updateTaskButtonState();
|
||||
renderTaskGoals();
|
||||
}
|
||||
|
||||
async function fetchMap() {
|
||||
try {
|
||||
const res = await fetch('/api/map');
|
||||
const data = await res.json();
|
||||
const points = data.points || [];
|
||||
applyMapMetadata(data);
|
||||
const points = Array.isArray(data.points) ? data.points : [];
|
||||
mapModel = buildMapModel(points);
|
||||
if (mapModel) mapModel.points = points;
|
||||
if (data.pose) latestPose = data.pose;
|
||||
drawMap();
|
||||
appendEvent('MAP', `loaded ${points.length} filtered points`, 'ok');
|
||||
appendEvent('MAP', `loaded ${points.length} filtered points, mission=${defaultMissionName || 'none'}`, 'ok');
|
||||
} catch (e) {
|
||||
appendEvent('MAP_ERROR', e.message, 'bad');
|
||||
}
|
||||
@@ -209,27 +428,25 @@ function onMapClick(event) {
|
||||
const world = canvasToWorld(px, py);
|
||||
if (!world) return;
|
||||
lastGoal = world;
|
||||
currentGoalName = '';
|
||||
setText('d-active-goal', 'direct goal', 'active');
|
||||
renderTaskGoals();
|
||||
drawMap();
|
||||
ensureMode('NAV').then(ok => {
|
||||
ensureMode('NAV').then(async ok => {
|
||||
if (!ok) return;
|
||||
post({ type: 'go_to', x: world.x, y: world.y });
|
||||
appendEvent('NAV_GO', `x=${world.x.toFixed(2)} y=${world.y.toFixed(2)}`, 'ok');
|
||||
const result = await post({ type: 'go_to', x: world.x, y: world.y });
|
||||
if (result.ok) {
|
||||
appendEvent('NAV_GO', `x=${world.x.toFixed(2)} y=${world.y.toFixed(2)}`, 'ok');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setText(id, text, cls) {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.textContent = text;
|
||||
if (cls !== undefined) el.className = 'diag-value ' + cls;
|
||||
}
|
||||
|
||||
function initJointsGrid() {
|
||||
const grid = $('joints-grid');
|
||||
if (!grid) return;
|
||||
grid.innerHTML = JOINT_NAMES.map((name, i) => `
|
||||
<div class="motor-row" id="mi-${i}">
|
||||
<span class="stale" id="ms-${i}" style="color:#ef4444">●</span>
|
||||
<span class="stale" id="ms-${i}" style="color:#ef4444">*</span>
|
||||
<span class="name">${name}</span>
|
||||
<span class="val pos" id="mp-${i}">0.00</span>
|
||||
<span class="val vel" id="mv-${i}">0.00</span>
|
||||
@@ -261,14 +478,17 @@ function updateJointsGrid(robot) {
|
||||
|
||||
function applyState(data) {
|
||||
const connected = data.connected && (!data.local_receive_time || Date.now() / 1000 - data.local_receive_time < 2.5);
|
||||
latestConnected = connected;
|
||||
if (!connected) {
|
||||
$('stage').textContent = 'DISCONNECTED';
|
||||
$('stage').className = 'stage DISCONNECTED';
|
||||
updateTaskButtonState();
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = data.mode || 'UNKNOWN';
|
||||
if (mode !== currentMode) highlightMode(mode);
|
||||
updateTaskButtonState();
|
||||
|
||||
const rt = data.runtime || {};
|
||||
const src = rt.target_source || '--';
|
||||
@@ -280,6 +500,18 @@ function applyState(data) {
|
||||
setText('d-estop', String(data.estop ?? '--'), data.estop ? 'bad' : 'ok');
|
||||
setText('d-mux', data.mux_status || '--');
|
||||
|
||||
const model = data.model || {};
|
||||
latestModelState = {
|
||||
current_model: model.current_model || '--',
|
||||
switch_state: model.switch_state || '--',
|
||||
backend: model.backend || '--',
|
||||
switching: Boolean(model.switching),
|
||||
};
|
||||
setText('d-model', latestModelState.current_model, latestModelState.current_model === 'crawl' ? 'active' : 'ok');
|
||||
setText('d-model-switch', latestModelState.switch_state, latestModelState.switching ? 'warn' : 'ok');
|
||||
setText('d-model-backend', latestModelState.backend || '--');
|
||||
setText('d-model-switching', String(latestModelState.switching), latestModelState.switching ? 'warn' : 'ok');
|
||||
|
||||
const robot = data.robot || {};
|
||||
const imuAge = robot.imu_age_ms ?? null;
|
||||
setText('d-imu-fresh', String(robot.imu_fresh ?? '--'), robot.imu_fresh ? 'ok' : 'bad');
|
||||
@@ -292,13 +524,15 @@ function applyState(data) {
|
||||
const lp = robot.odom_local_pos;
|
||||
setText('d-odom-pos', lp ? `x=${Number(lp[0]).toFixed(2)} y=${Number(lp[1]).toFixed(2)}` : '--');
|
||||
setText('d-nav-status', data.nav_status || '--');
|
||||
syncGoalMarkerFromNavStatus(data.nav_status || '');
|
||||
|
||||
const nav = data.nav || {};
|
||||
if (nav.pose) {
|
||||
latestPose = nav.pose;
|
||||
setText('d-nav-pose', `x=${nav.pose.x.toFixed(2)} y=${nav.pose.y.toFixed(2)} yaw=${(nav.pose.yaw * 57.2958).toFixed(1)}deg`);
|
||||
drawMap();
|
||||
}
|
||||
if (nav.path) activeNavPath = nav.path;
|
||||
drawMap();
|
||||
|
||||
const cv = data.cmd_vel || {};
|
||||
const lin = cv.linear || {};
|
||||
@@ -328,31 +562,96 @@ function appendEvent(kind, detail, cls) {
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
$('btn-disabled').onclick = () => {
|
||||
async function setMode(mode) {
|
||||
if (mode === 'WEB') {
|
||||
const ok = confirm('Confirm switch to WEB control?\nMake sure the robot is safe and velocity is zero.');
|
||||
if (!ok) return false;
|
||||
}
|
||||
const result = await post({ type: 'mode', mode });
|
||||
if (!result.ok) return false;
|
||||
highlightMode(mode);
|
||||
appendEvent('MODE_SET', `-> ${mode}`, 'ok');
|
||||
return true;
|
||||
}
|
||||
|
||||
async function ensureMode(mode) {
|
||||
if (currentMode === mode) return true;
|
||||
if (pendingModePromise) {
|
||||
const activeMode = await pendingModePromise;
|
||||
if (activeMode === mode) return true;
|
||||
}
|
||||
pendingModePromise = (async () => {
|
||||
const ok = await setMode(mode);
|
||||
return ok ? mode : currentMode;
|
||||
})();
|
||||
const resolvedMode = await pendingModePromise;
|
||||
pendingModePromise = null;
|
||||
return resolvedMode === mode;
|
||||
}
|
||||
|
||||
$('btn-disabled').onclick = async () => {
|
||||
zeroAll();
|
||||
setMode('DISABLED');
|
||||
await setMode('DISABLED');
|
||||
};
|
||||
$('btn-remote').onclick = () => setMode('REMOTE');
|
||||
$('btn-web').onclick = () => setMode('WEB');
|
||||
$('btn-nav').onclick = () => setMode('NAV');
|
||||
$('btn-zero').onclick = zeroAll;
|
||||
$('btn-estop').onclick = () => {
|
||||
$('btn-estop').onclick = async () => {
|
||||
if (confirm('Confirm soft e-stop?')) {
|
||||
post({ type: 'estop', data: true });
|
||||
const result = await post({ type: 'estop', data: true });
|
||||
zeroAll();
|
||||
appendEvent('ESTOP', 'soft e-stop triggered', 'bad');
|
||||
if (result.ok) appendEvent('ESTOP', 'soft e-stop triggered', 'bad');
|
||||
}
|
||||
};
|
||||
$('btn-refresh-map').onclick = fetchMap;
|
||||
$('btn-record').onclick = () => ensureMode('NAV').then(ok => {
|
||||
if (ok) post({ type: 'nav_cmd', command: 'record web_p1' });
|
||||
$('btn-run-task').onclick = async () => {
|
||||
if (!latestConnected) {
|
||||
appendEvent('TASK', 'bridge not connected', 'warn');
|
||||
updateTaskButtonState();
|
||||
return;
|
||||
}
|
||||
if (currentMode !== 'NAV') {
|
||||
appendEvent('TASK', 'only available in NAV mode', 'warn');
|
||||
updateTaskButtonState();
|
||||
return;
|
||||
}
|
||||
if (!defaultMissionName) {
|
||||
appendEvent('TASK', 'no default mission loaded', 'warn');
|
||||
return;
|
||||
}
|
||||
if (defaultMissionGoals.length) {
|
||||
setLastGoalByName(defaultMissionGoals[0].name);
|
||||
drawMap();
|
||||
}
|
||||
const result = await post({ type: 'nav_cmd', command: `run ${defaultMissionName}` });
|
||||
if (result.ok) {
|
||||
appendEvent('TASK', `run mission ${defaultMissionName}`, 'ok');
|
||||
}
|
||||
};
|
||||
$('btn-record').onclick = () => ensureMode('NAV').then(async ok => {
|
||||
if (!ok) return;
|
||||
const result = await post({ type: 'nav_cmd', command: 'record web_p1' });
|
||||
if (result.ok) appendEvent('NAV_RECORD', 'record web_p1', 'ok');
|
||||
});
|
||||
$('btn-stop-nav').onclick = () => ensureMode('NAV').then(ok => {
|
||||
if (ok) post({ type: 'nav_cmd', command: 'stop' });
|
||||
$('btn-stop-nav').onclick = () => ensureMode('NAV').then(async ok => {
|
||||
if (!ok) return;
|
||||
const result = await post({ type: 'nav_cmd', command: 'stop' });
|
||||
if (result.ok) appendEvent('NAV_STOP', 'simple_nav stop', 'ok');
|
||||
});
|
||||
$('btn-go-rel').onclick = () => ensureMode('NAV').then(ok => {
|
||||
if (ok) post({ type: 'go_rel', dx: 0.3, dy: 0.0 });
|
||||
$('btn-go-rel').onclick = () => ensureMode('NAV').then(async ok => {
|
||||
if (!ok) return;
|
||||
const result = await post({ type: 'go_rel', dx: 0.3, dy: 0.0 });
|
||||
if (result.ok) appendEvent('NAV_REL', 'forward 0.3m', 'ok');
|
||||
});
|
||||
$('btn-model-switch').onclick = async () => {
|
||||
if (latestModelState.switching) {
|
||||
appendEvent('MODEL', 'switch already in progress', 'warn');
|
||||
return;
|
||||
}
|
||||
const result = await post({ type: 'model_toggle' });
|
||||
if (result.ok) appendEvent('MODEL', `toggle requested from ${latestModelState.current_model}`, 'ok');
|
||||
};
|
||||
|
||||
for (const [id, key] of [['cmd-vx', 'vx'], ['cmd-vy', 'vy'], ['cmd-yaw', 'yaw']]) {
|
||||
$(id).addEventListener('input', e => {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>sim2real ROS2 控制台</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<title>sim2real ROS2 Console</title>
|
||||
<link rel="stylesheet" href="style.css?v=mission-sync-2">
|
||||
</head>
|
||||
<body>
|
||||
<header class="glass-panel top-bar">
|
||||
@@ -13,32 +13,37 @@
|
||||
<span class="stage" id="stage">DISCONNECTED</span>
|
||||
</div>
|
||||
<div class="top-bar-center">
|
||||
<span class="label">控制模式</span>
|
||||
<span class="label">Control Mode</span>
|
||||
<button class="btn" id="btn-disabled">DISABLED</button>
|
||||
<button class="btn btn-remote" id="btn-remote">REMOTE</button>
|
||||
<button class="btn btn-web" id="btn-web">WEB</button>
|
||||
<button class="btn btn-nav" id="btn-nav">NAV</button>
|
||||
<div class="divider"></div>
|
||||
<button class="btn" id="btn-zero">速度归零</button>
|
||||
<button class="btn" id="btn-zero">Zero Velocity</button>
|
||||
</div>
|
||||
<div class="top-bar-right">
|
||||
<button class="btn btn-danger" id="btn-estop">软急停</button>
|
||||
<button class="btn btn-danger" id="btn-estop">Soft E-Stop</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="glass-panel side-panel left-panel">
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">运行状态</h2>
|
||||
<h2 class="panel-title">Runtime</h2>
|
||||
<div class="diag-row"><span class="diag-label">target_source</span><span class="diag-value" id="d-source">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">runtime_released</span><span class="diag-value" id="d-released">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">release_alpha</span><span class="diag-value" id="d-alpha">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">zero_command</span><span class="diag-value" id="d-zero">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">estop</span><span class="diag-value" id="d-estop">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">mux</span><span class="diag-value" id="d-mux">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">model</span><span class="diag-value" id="d-model">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">switch_state</span><span class="diag-value" id="d-model-switch">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">backend</span><span class="diag-value" id="d-model-backend">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">switching</span><span class="diag-value" id="d-model-switching">--</span></div>
|
||||
<div class="diag-row"><button class="btn" id="btn-model-switch">Toggle Model</button></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">IMU & 里程计</h2>
|
||||
<h2 class="panel-title">IMU and Odom</h2>
|
||||
<div class="diag-row"><span class="diag-label">IMU fresh</span><span class="diag-value" id="d-imu-fresh">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">IMU age ms</span><span class="diag-value" id="d-imu-age">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">projected_gravity</span><span class="diag-value" id="d-gravity">--</span></div>
|
||||
@@ -47,10 +52,14 @@
|
||||
<div class="diag-row"><span class="diag-label">odom local pos</span><span class="diag-value" id="d-odom-pos">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">nav pose</span><span class="diag-value" id="d-nav-pose">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">nav status</span><span class="diag-value" id="d-nav-status">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">task mission</span><span class="diag-value" id="d-task-mission">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">active goal</span><span class="diag-value" id="d-active-goal">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">goal count</span><span class="diag-value" id="d-task-goals">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">route align</span><span class="diag-value" id="d-route-align">--</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section flex-1">
|
||||
<h2 class="panel-title">关节状态 (16轴)</h2>
|
||||
<h2 class="panel-title">Joint State (16)</h2>
|
||||
<div id="joints-grid" class="motors-grid-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,25 +67,26 @@
|
||||
<main class="glass-panel center-panel">
|
||||
<div class="panel-section map-section">
|
||||
<div class="map-header">
|
||||
<h2 class="panel-title">PCD 2D 导航</h2>
|
||||
<h2 class="panel-title">PCD 2D Nav</h2>
|
||||
<div class="map-actions">
|
||||
<button class="btn" id="btn-refresh-map">刷新地图</button>
|
||||
<button class="btn" id="btn-record">记录当前位置</button>
|
||||
<button class="btn" id="btn-stop-nav">停止导航</button>
|
||||
<button class="btn" id="btn-go-rel">前进 0.3m</button>
|
||||
<button class="btn" id="btn-refresh-map">Refresh Map</button>
|
||||
<button class="btn btn-nav" id="btn-run-task" disabled>Run Mission</button>
|
||||
<button class="btn" id="btn-record">Record Pose</button>
|
||||
<button class="btn" id="btn-stop-nav">Stop Nav</button>
|
||||
<button class="btn" id="btn-go-rel">Forward 0.3m</button>
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="map-canvas" width="900" height="680"></canvas>
|
||||
<div class="map-help">左键点击地图发送绝对目标;蓝点是当前机器人,红叉是最近目标。</div>
|
||||
<div class="map-help">Left click sends an absolute goal. Green line is the mission path, purple is the active A* path, cyan marks completed waypoints, orange is the current target, and blue is the robot pose.</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="glass-panel side-panel right-panel">
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">Web 手动控制</h2>
|
||||
<h2 class="panel-title">Web Manual Control</h2>
|
||||
<div class="joystick-area">
|
||||
<div class="joystick" id="joystick"><div id="stick"></div></div>
|
||||
<p class="hint">拖动控制前后(vx)和横移(vy),松开归零</p>
|
||||
<p class="hint">Drag to control vx and vy. Release to return to zero.</p>
|
||||
</div>
|
||||
<div class="slider-group">
|
||||
<div class="slider-row">
|
||||
@@ -91,7 +101,7 @@
|
||||
</div>
|
||||
<div class="slider-row">
|
||||
<span class="slider-label">yaw</span>
|
||||
<input type="range" id="cmd-yaw" class="glass-slider" min="-0.5" max="0.5" step="0.01" value="0">
|
||||
<input type="range" id="cmd-yaw" class="glass-slider" min="-0.8" max="0.8" step="0.01" value="0">
|
||||
<span class="slider-val" id="cmd-yaw-v">0.00</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,18 +109,18 @@
|
||||
</div>
|
||||
|
||||
<div class="panel-section">
|
||||
<h2 class="panel-title">当前输出 /cmd_vel</h2>
|
||||
<h2 class="panel-title">Current /cmd_vel</h2>
|
||||
<div class="diag-row"><span class="diag-label">linear.x</span><span class="diag-value" id="cv-vx">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">linear.y</span><span class="diag-value" id="cv-vy">--</span></div>
|
||||
<div class="diag-row"><span class="diag-label">angular.z</span><span class="diag-value" id="cv-yaw">--</span></div>
|
||||
</div>
|
||||
|
||||
<div class="panel-section log-section flex-1">
|
||||
<h2 class="panel-title">事件流</h2>
|
||||
<h2 class="panel-title">Events</h2>
|
||||
<div id="events-log" class="log"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
<script src="app.js?v=mission-sync-2"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -107,6 +107,18 @@ body {
|
||||
.btn-nav { background: rgba(255,214,10,0.2); border-color: rgba(255,214,10,0.4); color: #ffe680; }
|
||||
.btn.active-mode { box-shadow: 0 0 0 2px white; }
|
||||
|
||||
.route-select {
|
||||
min-width: 220px;
|
||||
height: 30px;
|
||||
border: 1px solid rgba(255,255,255,0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.32);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.label { font-size: 11px; color: var(--text-tertiary); }
|
||||
|
||||
.side-panel,
|
||||
@@ -172,6 +184,85 @@ body {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.route-section {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-goals-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.task-goal-item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0,0,0,0.24);
|
||||
border: 1px solid rgba(255,255,255,0.06);
|
||||
}
|
||||
.task-goal-item.active {
|
||||
border-color: rgba(255,214,10,0.55);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,214,10,0.18);
|
||||
}
|
||||
.task-goal-item.empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.task-goal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.task-goal-index {
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
background: rgba(10,132,255,0.22);
|
||||
color: #82c4ff;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.task-goal-name {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-family: monospace;
|
||||
flex: 1;
|
||||
}
|
||||
.task-goal-policy {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255,255,255,0.08);
|
||||
}
|
||||
.task-goal-policy.rough {
|
||||
color: #ffe680;
|
||||
}
|
||||
.task-goal-policy.crawl {
|
||||
color: #8deda7;
|
||||
}
|
||||
.task-goal-meta {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
font-family: monospace;
|
||||
}
|
||||
.obstacle-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
.obstacle-grid .btn {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
#map-canvas {
|
||||
width: 100%;
|
||||
height: calc(100% - 56px);
|
||||
@@ -296,4 +387,5 @@ body {
|
||||
}
|
||||
.center-panel { min-height: 700px; }
|
||||
#map-canvas { min-height: 420px; }
|
||||
.task-goals-list { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user