synchronize source..
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ COPY requirements.txt /app/requirements.txt
|
|||||||
COPY bridge.py /app/bridge.py
|
COPY bridge.py /app/bridge.py
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs && \
|
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs ros-humble-rtabmap-msgs ros-humble-rtabmap-util && \
|
||||||
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
|
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,8 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -17,7 +19,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
||||||
from action_msgs.msg import GoalStatus
|
from action_msgs.msg import GoalStatus
|
||||||
@@ -25,20 +27,21 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
|
|||||||
from nav2_msgs.action import NavigateToPose
|
from nav2_msgs.action import NavigateToPose
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
from rclpy.duration import Duration
|
|
||||||
from rclpy.executors import MultiThreadedExecutor
|
from rclpy.executors import MultiThreadedExecutor
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
from rclpy.parameter import Parameter
|
from rclpy.parameter import Parameter
|
||||||
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
|
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
|
||||||
from rclpy.time import Time
|
from rclpy.time import Time
|
||||||
from sensor_msgs.msg import JointState, LaserScan, PointCloud2
|
from sensor_msgs.msg import CompressedImage, JointState, LaserScan, PointCloud2
|
||||||
from std_msgs.msg import Float64
|
from std_msgs.msg import Float64, String
|
||||||
|
from std_srvs.srv import Empty
|
||||||
from tf2_msgs.msg import TFMessage
|
from tf2_msgs.msg import TFMessage
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000")))
|
_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000")))
|
||||||
|
_CAMERA_FRAME_MAX_AGE_SECONDS = 3.0
|
||||||
|
|
||||||
|
|
||||||
def yaw_from_quaternion(q) -> float:
|
def yaw_from_quaternion(q) -> float:
|
||||||
@@ -159,6 +162,8 @@ class SlamNode(Node):
|
|||||||
self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose")
|
self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose")
|
||||||
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
|
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
|
||||||
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
|
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
|
||||||
|
self.color_point_cloud_topic = os.environ.get("COLOR_POINT_CLOUD_TOPIC", "/camera_test/colored_points")
|
||||||
|
self.camera_image_topic = os.environ.get("CAMERA_IMAGE_TOPIC", "/camera_test/image_jpeg")
|
||||||
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
|
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
|
||||||
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
|
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
|
||||||
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
|
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
|
||||||
@@ -166,8 +171,10 @@ class SlamNode(Node):
|
|||||||
self.joint_states_topic = os.environ.get("JOINT_STATES_TOPIC", "/joint_states")
|
self.joint_states_topic = os.environ.get("JOINT_STATES_TOPIC", "/joint_states")
|
||||||
self.steering_topic = os.environ.get("STEERING_TOPIC", "/mkmini/steering_command")
|
self.steering_topic = os.environ.get("STEERING_TOPIC", "/mkmini/steering_command")
|
||||||
self.steering_feedback_topic = os.environ.get("STEERING_FEEDBACK_TOPIC", "/mkmini/steering_angle")
|
self.steering_feedback_topic = os.environ.get("STEERING_FEEDBACK_TOPIC", "/mkmini/steering_angle")
|
||||||
|
self.localization_session_topic = os.environ.get("LOCALIZATION_SESSION_TOPIC", "/localization_session_id")
|
||||||
self.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
|
self.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
|
||||||
self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes")
|
self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes")
|
||||||
|
self.saved_map_database_path = Path(os.environ.get("SAVED_MAP_DATABASE_PATH", ""))
|
||||||
|
|
||||||
best_effort_qos = QoSProfile(
|
best_effort_qos = QoSProfile(
|
||||||
depth=1,
|
depth=1,
|
||||||
@@ -195,6 +202,8 @@ class SlamNode(Node):
|
|||||||
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
|
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
|
||||||
self._point_cloud: Optional[PointCloud2] = None
|
self._point_cloud: Optional[PointCloud2] = None
|
||||||
self._fallback_point_cloud: Optional[PointCloud2] = None
|
self._fallback_point_cloud: Optional[PointCloud2] = None
|
||||||
|
self._color_point_cloud: Optional[PointCloud2] = None
|
||||||
|
self._camera_image: Optional[CompressedImage] = None
|
||||||
self._icp_odom: Optional[Odometry] = None
|
self._icp_odom: Optional[Odometry] = None
|
||||||
self._map: Optional[OccupancyGrid] = None
|
self._map: Optional[OccupancyGrid] = None
|
||||||
self._plan: Optional[NavPath] = None
|
self._plan: Optional[NavPath] = None
|
||||||
@@ -207,6 +216,8 @@ class SlamNode(Node):
|
|||||||
self._amcl_pose_count = 0
|
self._amcl_pose_count = 0
|
||||||
self._point_cloud_count = 0
|
self._point_cloud_count = 0
|
||||||
self._fallback_point_cloud_count = 0
|
self._fallback_point_cloud_count = 0
|
||||||
|
self._color_point_cloud_count = 0
|
||||||
|
self._camera_image_count = 0
|
||||||
self._icp_odom_count = 0
|
self._icp_odom_count = 0
|
||||||
self._map_count = 0
|
self._map_count = 0
|
||||||
self._tf_count = 0
|
self._tf_count = 0
|
||||||
@@ -215,6 +226,8 @@ class SlamNode(Node):
|
|||||||
self._last_amcl_pose_wall_time = None
|
self._last_amcl_pose_wall_time = None
|
||||||
self._last_point_cloud_wall_time = None
|
self._last_point_cloud_wall_time = None
|
||||||
self._last_fallback_point_cloud_wall_time = None
|
self._last_fallback_point_cloud_wall_time = None
|
||||||
|
self._last_color_point_cloud_wall_time = None
|
||||||
|
self._last_camera_image_wall_time = None
|
||||||
self._last_icp_odom_wall_time = None
|
self._last_icp_odom_wall_time = None
|
||||||
self._last_map_wall_time = None
|
self._last_map_wall_time = None
|
||||||
self._plan_count = 0
|
self._plan_count = 0
|
||||||
@@ -224,17 +237,25 @@ class SlamNode(Node):
|
|||||||
self._navigation = {"status": "idle", "goal_id": None}
|
self._navigation = {"status": "idle", "goal_id": None}
|
||||||
self._active_goal_handle = None
|
self._active_goal_handle = None
|
||||||
self._requested_initial_pose: Optional[dict] = None
|
self._requested_initial_pose: Optional[dict] = None
|
||||||
|
self._initial_pose_state_path = Path(
|
||||||
|
os.environ.get("INITIAL_POSE_STATE_PATH", "/workspace/.nav2-initial-pose-state.json")
|
||||||
|
)
|
||||||
|
self._initial_pose_set, self._initial_pose_session_id = self._load_initial_pose_state()
|
||||||
|
self._localization_session_id: Optional[str] = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._saved_map_point_cloud = self._load_saved_map_point_cloud()
|
||||||
|
|
||||||
self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos)
|
self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos)
|
||||||
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
|
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
|
||||||
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
|
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
|
||||||
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
|
|
||||||
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos)
|
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos)
|
||||||
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos)
|
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos)
|
||||||
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
|
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
|
||||||
if self.fallback_point_cloud_topic != self.point_cloud_topic:
|
if self.fallback_point_cloud_topic != self.point_cloud_topic:
|
||||||
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
|
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
|
||||||
|
if self.color_point_cloud_topic not in (self.point_cloud_topic, self.fallback_point_cloud_topic):
|
||||||
|
self.create_subscription(PointCloud2, self.color_point_cloud_topic, self._on_color_point_cloud, qos_profile_sensor_data)
|
||||||
|
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
|
||||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
||||||
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
||||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
||||||
@@ -242,37 +263,159 @@ class SlamNode(Node):
|
|||||||
self.create_subscription(NavPath, self.plan_topic, self._on_plan, reliable_qos)
|
self.create_subscription(NavPath, self.plan_topic, self._on_plan, reliable_qos)
|
||||||
self.create_subscription(JointState, self.joint_states_topic, self._on_joint_states, reliable_qos)
|
self.create_subscription(JointState, self.joint_states_topic, self._on_joint_states, reliable_qos)
|
||||||
self.create_subscription(Float64, self.steering_feedback_topic, self._on_steering_feedback, reliable_qos)
|
self.create_subscription(Float64, self.steering_feedback_topic, self._on_steering_feedback, reliable_qos)
|
||||||
|
self.create_subscription(String, self.localization_session_topic, self._on_localization_session, map_qos)
|
||||||
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
||||||
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
|
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
|
||||||
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
||||||
self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
|
self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
|
||||||
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
|
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
|
||||||
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
||||||
|
self._rtabmap_backup_client = self.create_client(Empty, "/rtabmap/backup")
|
||||||
|
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"SLAM bridge started: odom={self.odom_topic}, icp_odom={self.icp_odom_topic}, scan={self.scan_topic}, point_cloud={self.point_cloud_topic}, fallback_point_cloud={self.fallback_point_cloud_topic}, map={self.map_topic}, read_only={self.read_only}"
|
f"SLAM bridge started: odom={self.odom_topic}, icp_odom={self.icp_odom_topic}, scan={self.scan_topic}, point_cloud={self.point_cloud_topic}, fallback_point_cloud={self.fallback_point_cloud_topic}, color_point_cloud={self.color_point_cloud_topic}, camera_image={self.camera_image_topic}, map={self.map_topic}, read_only={self.read_only}"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _load_saved_map_point_cloud(self) -> Optional[dict]:
|
||||||
|
"""Create a stable navigation-map cloud from the selected RTAB-Map DB."""
|
||||||
|
if not self.saved_map_database_path.is_file():
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with tempfile.TemporaryDirectory(prefix="slam-map-cloud-") as output_dir:
|
||||||
|
output_name = "saved-map"
|
||||||
|
subprocess.run(
|
||||||
|
[
|
||||||
|
"rtabmap-export",
|
||||||
|
"--cloud",
|
||||||
|
"--scan",
|
||||||
|
"--voxel",
|
||||||
|
"0.01",
|
||||||
|
"--output",
|
||||||
|
output_name,
|
||||||
|
"--output_dir",
|
||||||
|
output_dir,
|
||||||
|
str(self.saved_map_database_path),
|
||||||
|
],
|
||||||
|
check=True,
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
points = self._read_exported_ply_points(
|
||||||
|
Path(output_dir) / f"{output_name}_cloud.ply",
|
||||||
|
_POINT_CLOUD_LIMIT,
|
||||||
|
)
|
||||||
|
except (OSError, subprocess.SubprocessError, ValueError) as error:
|
||||||
|
self.get_logger().warning(f"Saved 3D map export failed: {error}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not points:
|
||||||
|
self.get_logger().warning("Saved 3D map export did not contain any points.")
|
||||||
|
return None
|
||||||
|
self.get_logger().info(f"Loaded saved 3D map point cloud: {len(points)} points")
|
||||||
|
return {
|
||||||
|
"frame_id": "map",
|
||||||
|
"points": points,
|
||||||
|
"transform": {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0},
|
||||||
|
"saved_map": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_exported_ply_points(path: Path, limit: int) -> list[list[float]]:
|
||||||
|
with path.open("rb") as source:
|
||||||
|
header = bytearray()
|
||||||
|
while not header.endswith(b"end_header\n"):
|
||||||
|
chunk = source.read(1)
|
||||||
|
if not chunk or len(header) > 16_384:
|
||||||
|
raise ValueError("Invalid exported PLY header")
|
||||||
|
header.extend(chunk)
|
||||||
|
lines = header.decode("ascii", errors="strict").splitlines()
|
||||||
|
if "format binary_little_endian 1.0" not in lines:
|
||||||
|
raise ValueError("Unsupported exported PLY format")
|
||||||
|
vertex_line = next((line for line in lines if line.startswith("element vertex ")), None)
|
||||||
|
if vertex_line is None:
|
||||||
|
raise ValueError("Exported PLY does not have vertices")
|
||||||
|
vertex_count = int(vertex_line.rsplit(" ", 1)[1])
|
||||||
|
if vertex_count <= 0:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# rtabmap-export writes x/y/z followed by five float fields.
|
||||||
|
data = source.read(vertex_count * 32)
|
||||||
|
if len(data) < vertex_count * 32:
|
||||||
|
raise ValueError("Exported PLY was truncated")
|
||||||
|
stride = max(1, (vertex_count + max(1, limit) - 1) // max(1, limit))
|
||||||
|
return [
|
||||||
|
list(struct.unpack_from("<3f", data, index * 32))
|
||||||
|
for index in range(0, vertex_count, stride)
|
||||||
|
]
|
||||||
|
|
||||||
|
def _load_initial_pose_state(self) -> tuple[bool, Optional[str]]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(self._initial_pose_state_path.read_text(encoding="utf-8"))
|
||||||
|
session_id = payload.get("localization_session_id")
|
||||||
|
return (
|
||||||
|
payload.get("initial_pose_set") is True,
|
||||||
|
session_id.strip() if isinstance(session_id, str) and session_id.strip() else None,
|
||||||
|
)
|
||||||
|
except (OSError, ValueError, TypeError):
|
||||||
|
return False, None
|
||||||
|
|
||||||
|
def _save_initial_pose_state(self) -> None:
|
||||||
|
try:
|
||||||
|
self._initial_pose_state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary_path = self._initial_pose_state_path.with_suffix(".tmp")
|
||||||
|
temporary_path.write_text(json.dumps({
|
||||||
|
"initial_pose_set": self._initial_pose_set,
|
||||||
|
"localization_session_id": self._initial_pose_session_id,
|
||||||
|
}), encoding="utf-8")
|
||||||
|
temporary_path.replace(self._initial_pose_state_path)
|
||||||
|
except OSError as error:
|
||||||
|
self.get_logger().warning(f"Failed to persist initial pose state: {error}")
|
||||||
|
|
||||||
|
def _on_localization_session(self, msg: String):
|
||||||
|
session_id = msg.data.strip()
|
||||||
|
if not session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
should_persist = False
|
||||||
|
with self._lock:
|
||||||
|
if self._localization_session_id == session_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._localization_session_id = session_id
|
||||||
|
if self._initial_pose_set and self._initial_pose_session_id == "__pending__":
|
||||||
|
self._initial_pose_session_id = session_id
|
||||||
|
should_persist = True
|
||||||
|
elif self._initial_pose_session_id != session_id:
|
||||||
|
# A localization module restart starts a new estimation session.
|
||||||
|
# Never reuse the previous session's initial pose in it.
|
||||||
|
self._initial_pose_set = False
|
||||||
|
self._initial_pose_session_id = None
|
||||||
|
should_persist = True
|
||||||
|
|
||||||
|
if should_persist:
|
||||||
|
self._save_initial_pose_state()
|
||||||
|
self.get_logger().info("Localization session changed; initial pose state was reset.")
|
||||||
|
|
||||||
def _on_odom(self, msg: Odometry):
|
def _on_odom(self, msg: Odometry):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._odom = msg
|
self._odom = msg
|
||||||
self._odom_count += 1
|
self._odom_count += 1
|
||||||
self._last_odom_wall_time = time.time()
|
self._last_odom_wall_time = time.time()
|
||||||
|
|
||||||
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None:
|
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
|
||||||
# AMCL transforms the initial pose at the message timestamp. On a real
|
"""Provide a map-frame seed without overwriting live ICP odometry."""
|
||||||
# platform the bridge clock can be a few milliseconds ahead of the
|
|
||||||
# latest odom/TF sample, which makes AMCL reject the pose as a future
|
|
||||||
# extrapolation. Anchor it to the latest received odometry instead.
|
|
||||||
with self._lock:
|
with self._lock:
|
||||||
odom_stamp = self._odom.header.stamp if self._odom is not None else None
|
has_3d_localization = self._icp_odom is not None
|
||||||
|
|
||||||
|
# A zero ROS timestamp means "use the latest available transform".
|
||||||
|
# The bridge and platform receive odometry through different DDS
|
||||||
|
# processes, so an odometry-derived timestamp can still be newer than
|
||||||
|
# AMCL's TF buffer.
|
||||||
message = PoseWithCovarianceStamped()
|
message = PoseWithCovarianceStamped()
|
||||||
if odom_stamp:
|
message.header.stamp = Time().to_msg()
|
||||||
# The odom callback can lead the TF buffer by a few milliseconds.
|
|
||||||
# Keep a small margin so AMCL can always transform map -> odom.
|
|
||||||
message.header.stamp = (Time.from_msg(odom_stamp) - Duration(seconds=0.1)).to_msg()
|
|
||||||
else:
|
|
||||||
message.header.stamp = self.get_clock().now().to_msg()
|
|
||||||
message.header.frame_id = frame_id
|
message.header.frame_id = frame_id
|
||||||
message.pose.pose.position.x = x
|
message.pose.pose.position.x = x
|
||||||
message.pose.pose.position.y = y
|
message.pose.pose.position.y = y
|
||||||
@@ -284,20 +427,33 @@ class SlamNode(Node):
|
|||||||
message.pose.covariance[0] = 0.25
|
message.pose.covariance[0] = 0.25
|
||||||
message.pose.covariance[7] = 0.25
|
message.pose.covariance[7] = 0.25
|
||||||
message.pose.covariance[35] = 0.0685
|
message.pose.covariance[35] = 0.0685
|
||||||
with self._lock:
|
if not has_3d_localization:
|
||||||
# Use this only until AMCL starts publishing map -> odom. It is a
|
with self._lock:
|
||||||
# short-lived viewer fallback, not a replacement for live TF.
|
# Use this only until AMCL starts publishing map -> odom. It
|
||||||
self._requested_initial_pose = {
|
# is a short-lived viewer fallback, not a replacement for TF.
|
||||||
"x": float(x),
|
self._requested_initial_pose = {
|
||||||
"y": float(y),
|
"x": float(x),
|
||||||
"z": 0.0,
|
"y": float(y),
|
||||||
"yaw": float(yaw),
|
"z": 0.0,
|
||||||
"frame_id": frame_id or "map",
|
"yaw": float(yaw),
|
||||||
"t": time.time(),
|
"frame_id": frame_id or "map",
|
||||||
"expires_at": time.monotonic() + 2.0,
|
"t": time.time(),
|
||||||
}
|
"expires_at": time.monotonic() + 2.0,
|
||||||
|
}
|
||||||
|
# In 3D this is consumed by the localization module's local relay,
|
||||||
|
# which can call ICP odometry without crossing the DDS peer boundary.
|
||||||
self._initial_pose_pub.publish(message)
|
self._initial_pose_pub.publish(message)
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._initial_pose_set = True
|
||||||
|
self._initial_pose_session_id = self._localization_session_id or "__pending__"
|
||||||
|
self._save_initial_pose_state()
|
||||||
|
return {
|
||||||
|
"mode": "rtabmap-3d" if has_3d_localization else "amcl-2d",
|
||||||
|
"refining": has_3d_localization,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def save_map(self, name: Optional[str] = None) -> dict:
|
def save_map(self, name: Optional[str] = None) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
grid = self._map
|
grid = self._map
|
||||||
@@ -369,6 +525,10 @@ class SlamNode(Node):
|
|||||||
database_path = None
|
database_path = None
|
||||||
database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", ""))
|
database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", ""))
|
||||||
if os.environ.get("RTABMAP_MAP_SAVE_ENABLED", "").lower() == "true" and database_source.is_file():
|
if os.environ.get("RTABMAP_MAP_SAVE_ENABLED", "").lower() == "true" and database_source.is_file():
|
||||||
|
# RTAB-Map keeps the optimized pose graph in memory until its
|
||||||
|
# backup service flushes it. Copying SQLite directly here leaves a
|
||||||
|
# usable PGM but a DB that cannot localize on the saved map.
|
||||||
|
self._backup_rtabmap_database()
|
||||||
database_snapshot = maps_dir / f"{safe_name}.db"
|
database_snapshot = maps_dir / f"{safe_name}.db"
|
||||||
source_connection = None
|
source_connection = None
|
||||||
destination_connection = None
|
destination_connection = None
|
||||||
@@ -386,6 +546,11 @@ class SlamNode(Node):
|
|||||||
destination_connection.close()
|
destination_connection.close()
|
||||||
if source_connection is not None:
|
if source_connection is not None:
|
||||||
source_connection.close()
|
source_connection.close()
|
||||||
|
# A saved map must start unlocalized. RTAB-Map stores the last
|
||||||
|
# localization pose beside the optimized graph; retaining it
|
||||||
|
# makes a later localization session place the vehicle before the
|
||||||
|
# learner supplies the first pose.
|
||||||
|
self._clear_database_last_localization(database_snapshot)
|
||||||
database_path = str(database_snapshot)
|
database_path = str(database_snapshot)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -399,6 +564,28 @@ class SlamNode(Node):
|
|||||||
"previewPgm": base64.b64encode(preview_pgm).decode("ascii"),
|
"previewPgm": base64.b64encode(preview_pgm).decode("ascii"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _backup_rtabmap_database(self, timeout_seconds: float = 30.0) -> None:
|
||||||
|
if not self._rtabmap_backup_client.wait_for_service(timeout_sec=5.0):
|
||||||
|
raise RuntimeError("3D SLAM DB 저장 서비스를 찾지 못했습니다. RTAB-Map 모듈이 실행 중인지 확인하세요.")
|
||||||
|
|
||||||
|
future = self._rtabmap_backup_client.call_async(Empty.Request())
|
||||||
|
deadline = time.monotonic() + timeout_seconds
|
||||||
|
while not future.done() and time.monotonic() < deadline:
|
||||||
|
time.sleep(0.05)
|
||||||
|
|
||||||
|
if not future.done():
|
||||||
|
raise RuntimeError("3D SLAM DB 저장 시간이 초과되었습니다.")
|
||||||
|
if future.exception() is not None:
|
||||||
|
raise RuntimeError(f"3D SLAM DB 저장에 실패했습니다: {future.exception()}")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clear_database_last_localization(database_path: Path) -> None:
|
||||||
|
try:
|
||||||
|
with sqlite3.connect(str(database_path)) as connection:
|
||||||
|
connection.execute("UPDATE Admin SET opt_last_localization = NULL")
|
||||||
|
except sqlite3.Error as error:
|
||||||
|
raise RuntimeError(f"저장한 3D SLAM DB를 정리하지 못했습니다: {error}") from error
|
||||||
|
|
||||||
def _on_scan(self, msg: LaserScan):
|
def _on_scan(self, msg: LaserScan):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._scan = msg
|
self._scan = msg
|
||||||
@@ -423,6 +610,18 @@ class SlamNode(Node):
|
|||||||
self._fallback_point_cloud_count += 1
|
self._fallback_point_cloud_count += 1
|
||||||
self._last_fallback_point_cloud_wall_time = time.time()
|
self._last_fallback_point_cloud_wall_time = time.time()
|
||||||
|
|
||||||
|
def _on_color_point_cloud(self, msg: PointCloud2):
|
||||||
|
with self._lock:
|
||||||
|
self._color_point_cloud = msg
|
||||||
|
self._color_point_cloud_count += 1
|
||||||
|
self._last_color_point_cloud_wall_time = time.time()
|
||||||
|
|
||||||
|
def _on_camera_image(self, msg: CompressedImage):
|
||||||
|
with self._lock:
|
||||||
|
self._camera_image = msg
|
||||||
|
self._camera_image_count += 1
|
||||||
|
self._last_camera_image_wall_time = time.time()
|
||||||
|
|
||||||
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
primary = self._point_cloud
|
primary = self._point_cloud
|
||||||
@@ -478,6 +677,8 @@ class SlamNode(Node):
|
|||||||
amcl_pose_last = self._last_amcl_pose_wall_time
|
amcl_pose_last = self._last_amcl_pose_wall_time
|
||||||
point_cloud_last = self._last_point_cloud_wall_time
|
point_cloud_last = self._last_point_cloud_wall_time
|
||||||
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
|
||||||
|
color_point_cloud_last = self._last_color_point_cloud_wall_time
|
||||||
|
camera_image_last = self._last_camera_image_wall_time
|
||||||
map_count = self._map_count
|
map_count = self._map_count
|
||||||
plan_count = self._plan_count
|
plan_count = self._plan_count
|
||||||
|
|
||||||
@@ -500,7 +701,24 @@ class SlamNode(Node):
|
|||||||
"amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)},
|
"amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)},
|
||||||
"point_cloud": {
|
"point_cloud": {
|
||||||
"name": self.point_cloud_topic,
|
"name": self.point_cloud_topic,
|
||||||
"available": received_recently(point_cloud_last) or received_recently(fallback_point_cloud_last),
|
# RTAB-Map publishes the reconstructed global cloud with
|
||||||
|
# TRANSIENT_LOCAL durability. In localization it may publish
|
||||||
|
# only once after loading the database, so a received map is
|
||||||
|
# still valid even when it is older than the live-topic
|
||||||
|
# freshness window used for sensors.
|
||||||
|
"available": self._saved_map_point_cloud is not None or point_cloud_last is not None,
|
||||||
|
},
|
||||||
|
"sensor_point_cloud": {
|
||||||
|
"name": self.fallback_point_cloud_topic,
|
||||||
|
"available": received_recently(fallback_point_cloud_last),
|
||||||
|
},
|
||||||
|
"color_point_cloud": {
|
||||||
|
"name": self.color_point_cloud_topic,
|
||||||
|
"available": received_recently(color_point_cloud_last),
|
||||||
|
},
|
||||||
|
"camera_image": {
|
||||||
|
"name": self.camera_image_topic,
|
||||||
|
"available": received_recently(camera_image_last),
|
||||||
},
|
},
|
||||||
"plan": {"name": self.plan_topic, "available": plan_available},
|
"plan": {"name": self.plan_topic, "available": plan_available},
|
||||||
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
|
||||||
@@ -725,8 +943,14 @@ class SlamNode(Node):
|
|||||||
plan = self._plan
|
plan = self._plan
|
||||||
amcl_pose = self._amcl_pose
|
amcl_pose = self._amcl_pose
|
||||||
requested_initial_pose = dict(self._requested_initial_pose) if self._requested_initial_pose else None
|
requested_initial_pose = dict(self._requested_initial_pose) if self._requested_initial_pose else None
|
||||||
|
initial_pose_set = self._initial_pose_set
|
||||||
|
localization_session_id = self._localization_session_id
|
||||||
|
initial_pose_session_id = self._initial_pose_session_id
|
||||||
joint_positions = dict(self._joint_positions)
|
joint_positions = dict(self._joint_positions)
|
||||||
steering_feedback = self._steering_feedback
|
steering_feedback = self._steering_feedback
|
||||||
|
fallback_point_cloud = self._fallback_point_cloud
|
||||||
|
color_point_cloud = self._color_point_cloud
|
||||||
|
camera_image = self._camera_image
|
||||||
tf_snapshot = dict(self._tf)
|
tf_snapshot = dict(self._tf)
|
||||||
navigation = dict(self._navigation)
|
navigation = dict(self._navigation)
|
||||||
effective_map = self._effective_map(grid, scan, odom)
|
effective_map = self._effective_map(grid, scan, odom)
|
||||||
@@ -897,15 +1121,14 @@ class SlamNode(Node):
|
|||||||
"yaw": euler["yaw"],
|
"yaw": euler["yaw"],
|
||||||
"t": icp_odom.header.stamp.sec + icp_odom.header.stamp.nanosec * 1e-9,
|
"t": icp_odom.header.stamp.sec + icp_odom.header.stamp.nanosec * 1e-9,
|
||||||
}
|
}
|
||||||
cloud_frame = point_cloud.header.frame_id if point_cloud is not None else ""
|
map_to_icp_odom = resolve_2d_transform(tf_snapshot, "map", icp_odom.header.frame_id)
|
||||||
map_to_icp_odom = tf_snapshot.get((cloud_frame, icp_odom.header.frame_id)) if cloud_frame else None
|
|
||||||
if map_to_icp_odom is not None:
|
if map_to_icp_odom is not None:
|
||||||
# RTAB-Map publishes /cloud_map in map, while ICP odometry is
|
# ICP is local odometry. RTAB-Map localization publishes the
|
||||||
# expressed in icp_odom. Render both in the cloud frame.
|
# map -> icp_odom correction needed to render it on the saved map.
|
||||||
pose_3d = {
|
pose_3d = {
|
||||||
**pose_3d,
|
**pose_3d,
|
||||||
**compose_2d(map_to_icp_odom, pose_3d),
|
**compose_2d(map_to_icp_odom, pose_3d),
|
||||||
"frame_id": cloud_frame,
|
"frame_id": "map",
|
||||||
"source_frame_id": icp_odom.header.frame_id,
|
"source_frame_id": icp_odom.header.frame_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -943,12 +1166,38 @@ class SlamNode(Node):
|
|||||||
"t": point_cloud.header.stamp.sec + point_cloud.header.stamp.nanosec * 1e-9,
|
"t": point_cloud.header.stamp.sec + point_cloud.header.stamp.nanosec * 1e-9,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sensor_point_cloud_payload = None
|
||||||
|
if fallback_point_cloud is not None:
|
||||||
|
sensor_point_cloud_payload = {
|
||||||
|
"frame_id": fallback_point_cloud.header.frame_id,
|
||||||
|
"count": fallback_point_cloud.width * fallback_point_cloud.height,
|
||||||
|
"t": fallback_point_cloud.header.stamp.sec + fallback_point_cloud.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
|
||||||
|
color_point_cloud_payload = None
|
||||||
|
if color_point_cloud is not None:
|
||||||
|
color_point_cloud_payload = {
|
||||||
|
"frame_id": color_point_cloud.header.frame_id,
|
||||||
|
"count": color_point_cloud.width * color_point_cloud.height,
|
||||||
|
"t": color_point_cloud.header.stamp.sec + color_point_cloud.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
|
||||||
|
camera_image_payload = None
|
||||||
|
if camera_image is not None:
|
||||||
|
camera_image_payload = {
|
||||||
|
"frame_id": camera_image.header.frame_id,
|
||||||
|
"t": camera_image.header.stamp.sec + camera_image.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"topics": {
|
"topics": {
|
||||||
"odom": self.odom_topic,
|
"odom": self.odom_topic,
|
||||||
"scan": self.scan_topic,
|
"scan": self.scan_topic,
|
||||||
"point_cloud": self.point_cloud_topic,
|
"point_cloud": self.point_cloud_topic,
|
||||||
|
"sensor_point_cloud": self.fallback_point_cloud_topic,
|
||||||
|
"color_point_cloud": self.color_point_cloud_topic,
|
||||||
|
"camera_image": self.camera_image_topic,
|
||||||
"icp_odom": self.icp_odom_topic,
|
"icp_odom": self.icp_odom_topic,
|
||||||
"map": self.map_topic,
|
"map": self.map_topic,
|
||||||
"cmd_vel": self.cmd_vel_topic,
|
"cmd_vel": self.cmd_vel_topic,
|
||||||
@@ -962,11 +1211,17 @@ class SlamNode(Node):
|
|||||||
"vehicle": vehicle,
|
"vehicle": vehicle,
|
||||||
"scan": scan_payload,
|
"scan": scan_payload,
|
||||||
"point_cloud": point_cloud_payload,
|
"point_cloud": point_cloud_payload,
|
||||||
|
"sensor_point_cloud": sensor_point_cloud_payload,
|
||||||
|
"color_point_cloud": color_point_cloud_payload,
|
||||||
|
"camera_image": camera_image_payload,
|
||||||
"map": map_payload,
|
"map": map_payload,
|
||||||
"tf": {
|
"tf": {
|
||||||
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
|
||||||
},
|
},
|
||||||
"navigation": navigation,
|
"navigation": navigation,
|
||||||
|
"initial_pose_set": initial_pose_set,
|
||||||
|
"localization_session_id": localization_session_id,
|
||||||
|
"initial_pose_session_id": initial_pose_session_id,
|
||||||
"topic_status": self.topic_status(),
|
"topic_status": self.topic_status(),
|
||||||
"plan": {
|
"plan": {
|
||||||
"frame_id": plan.header.frame_id,
|
"frame_id": plan.header.frame_id,
|
||||||
@@ -983,7 +1238,57 @@ class SlamNode(Node):
|
|||||||
}
|
}
|
||||||
|
|
||||||
def point_cloud_payload(self, limit: int = _POINT_CLOUD_LIMIT) -> Optional[dict]:
|
def point_cloud_payload(self, limit: int = _POINT_CLOUD_LIMIT) -> Optional[dict]:
|
||||||
cloud = self._active_point_cloud()
|
with self._lock:
|
||||||
|
saved_map_point_cloud = self._saved_map_point_cloud
|
||||||
|
# A saved RTAB-Map map may be published in the local ICP frame
|
||||||
|
# during navigation, while a live SLAM map is normally in `map`.
|
||||||
|
# Preserve the source frame here so the viewer can place either
|
||||||
|
# cloud in the common map scene.
|
||||||
|
cloud = self._point_cloud or self._fallback_point_cloud
|
||||||
|
tf_snapshot = dict(self._tf)
|
||||||
|
if saved_map_point_cloud is not None:
|
||||||
|
return saved_map_point_cloud
|
||||||
|
payload = self._serialize_point_cloud(cloud, limit)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def sensor_point_cloud_payload(self, limit: int = 10_000) -> Optional[dict]:
|
||||||
|
with self._lock:
|
||||||
|
cloud = self._fallback_point_cloud
|
||||||
|
tf_snapshot = dict(self._tf)
|
||||||
|
received_at = self._last_fallback_point_cloud_wall_time
|
||||||
|
payload = self._serialize_point_cloud(cloud, limit)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
|
||||||
|
payload["received_at"] = received_at
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def color_point_cloud_payload(self, limit: int = 10_000) -> Optional[dict]:
|
||||||
|
with self._lock:
|
||||||
|
cloud = self._color_point_cloud
|
||||||
|
tf_snapshot = dict(self._tf)
|
||||||
|
received_at = self._last_color_point_cloud_wall_time
|
||||||
|
payload = self._serialize_point_cloud(cloud, limit, include_colors=True)
|
||||||
|
if payload is None:
|
||||||
|
return None
|
||||||
|
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
|
||||||
|
payload["received_at"] = received_at
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def camera_frame_jpeg(self) -> Optional[bytes]:
|
||||||
|
with self._lock:
|
||||||
|
image = self._camera_image
|
||||||
|
received_at = self._last_camera_image_wall_time
|
||||||
|
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
|
||||||
|
return None
|
||||||
|
if not image.data or "jpeg" not in image.format.lower():
|
||||||
|
return None
|
||||||
|
return bytes(image.data)
|
||||||
|
|
||||||
|
def _serialize_point_cloud(self, cloud: Optional[PointCloud2], limit: int, include_colors: bool = False) -> Optional[dict]:
|
||||||
if cloud is None or cloud.point_step <= 0 or not cloud.data:
|
if cloud is None or cloud.point_step <= 0 or not cloud.data:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@@ -998,6 +1303,9 @@ class SlamNode(Node):
|
|||||||
prefix = ">" if cloud.is_bigendian else "<"
|
prefix = ">" if cloud.is_bigendian else "<"
|
||||||
data = cloud.data
|
data = cloud.data
|
||||||
points = []
|
points = []
|
||||||
|
colors = []
|
||||||
|
color_offset = offsets.get("rgb", offsets.get("rgba"))
|
||||||
|
color_field = next((field for field in cloud.fields if field.name in ("rgb", "rgba")), None)
|
||||||
for index in range(0, total, stride):
|
for index in range(0, total, stride):
|
||||||
offset = index * cloud.point_step
|
offset = index * cloud.point_step
|
||||||
if offset + cloud.point_step > len(data):
|
if offset + cloud.point_step > len(data):
|
||||||
@@ -1010,12 +1318,27 @@ class SlamNode(Node):
|
|||||||
break
|
break
|
||||||
if math.isfinite(x) and math.isfinite(y) and math.isfinite(z):
|
if math.isfinite(x) and math.isfinite(y) and math.isfinite(z):
|
||||||
points.append([x, y, z])
|
points.append([x, y, z])
|
||||||
|
if include_colors:
|
||||||
|
rgb = 0xFFFFFF
|
||||||
|
if color_offset is not None and color_field is not None:
|
||||||
|
try:
|
||||||
|
if color_field.datatype == 6: # PointField.UINT32
|
||||||
|
rgb = struct.unpack_from(f"{prefix}I", data, offset + color_offset)[0]
|
||||||
|
elif color_field.datatype == 7: # PointField.FLOAT32 packed RGB
|
||||||
|
packed = struct.unpack_from(f"{prefix}f", data, offset + color_offset)[0]
|
||||||
|
rgb = struct.unpack(f"{prefix}I", struct.pack(f"{prefix}f", packed))[0]
|
||||||
|
except struct.error:
|
||||||
|
pass
|
||||||
|
colors.append([(rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF])
|
||||||
|
|
||||||
return {
|
payload = {
|
||||||
"frame_id": cloud.header.frame_id,
|
"frame_id": cloud.header.frame_id,
|
||||||
"stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9,
|
"stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9,
|
||||||
"points": points,
|
"points": points,
|
||||||
}
|
}
|
||||||
|
if include_colors:
|
||||||
|
payload["colors"] = colors
|
||||||
|
return payload
|
||||||
|
|
||||||
def map_binary(self) -> Optional[bytes]:
|
def map_binary(self) -> Optional[bytes]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
@@ -1049,7 +1372,7 @@ class SlamNode(Node):
|
|||||||
def topic_debug(self) -> dict:
|
def topic_debug(self) -> dict:
|
||||||
topics = {}
|
topics = {}
|
||||||
for name, types in self.get_topic_names_and_types():
|
for name, types in self.get_topic_names_and_types():
|
||||||
if name in (self.odom_topic, self.icp_odom_topic, self.scan_topic, self.point_cloud_topic, self.fallback_point_cloud_topic, self.map_topic, self.cmd_vel_topic):
|
if name in (self.odom_topic, self.icp_odom_topic, self.scan_topic, self.point_cloud_topic, self.fallback_point_cloud_topic, self.color_point_cloud_topic, self.camera_image_topic, self.map_topic, self.cmd_vel_topic):
|
||||||
topics[name] = types
|
topics[name] = types
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1058,6 +1381,8 @@ class SlamNode(Node):
|
|||||||
"scan": self.scan_topic,
|
"scan": self.scan_topic,
|
||||||
"point_cloud": self.point_cloud_topic,
|
"point_cloud": self.point_cloud_topic,
|
||||||
"fallback_point_cloud": self.fallback_point_cloud_topic,
|
"fallback_point_cloud": self.fallback_point_cloud_topic,
|
||||||
|
"color_point_cloud": self.color_point_cloud_topic,
|
||||||
|
"camera_image": self.camera_image_topic,
|
||||||
"icp_odom": self.icp_odom_topic,
|
"icp_odom": self.icp_odom_topic,
|
||||||
"map": self.map_topic,
|
"map": self.map_topic,
|
||||||
"cmd_vel": self.cmd_vel_topic,
|
"cmd_vel": self.cmd_vel_topic,
|
||||||
@@ -1067,6 +1392,8 @@ class SlamNode(Node):
|
|||||||
"scan_publishers": self.count_publishers(self.scan_topic),
|
"scan_publishers": self.count_publishers(self.scan_topic),
|
||||||
"point_cloud_publishers": self.count_publishers(self.point_cloud_topic),
|
"point_cloud_publishers": self.count_publishers(self.point_cloud_topic),
|
||||||
"fallback_point_cloud_publishers": self.count_publishers(self.fallback_point_cloud_topic),
|
"fallback_point_cloud_publishers": self.count_publishers(self.fallback_point_cloud_topic),
|
||||||
|
"color_point_cloud_publishers": self.count_publishers(self.color_point_cloud_topic),
|
||||||
|
"camera_image_publishers": self.count_publishers(self.camera_image_topic),
|
||||||
"icp_odom_publishers": self.count_publishers(self.icp_odom_topic),
|
"icp_odom_publishers": self.count_publishers(self.icp_odom_topic),
|
||||||
"odom_publishers": self.count_publishers(self.odom_topic),
|
"odom_publishers": self.count_publishers(self.odom_topic),
|
||||||
"map_publishers": self.count_publishers(self.map_topic),
|
"map_publishers": self.count_publishers(self.map_topic),
|
||||||
@@ -1075,6 +1402,8 @@ class SlamNode(Node):
|
|||||||
"scan_count": self._scan_count,
|
"scan_count": self._scan_count,
|
||||||
"point_cloud_count": self._point_cloud_count,
|
"point_cloud_count": self._point_cloud_count,
|
||||||
"fallback_point_cloud_count": self._fallback_point_cloud_count,
|
"fallback_point_cloud_count": self._fallback_point_cloud_count,
|
||||||
|
"color_point_cloud_count": self._color_point_cloud_count,
|
||||||
|
"camera_image_count": self._camera_image_count,
|
||||||
"icp_odom_count": self._icp_odom_count,
|
"icp_odom_count": self._icp_odom_count,
|
||||||
"odom_count": self._odom_count,
|
"odom_count": self._odom_count,
|
||||||
"map_count": self._map_count,
|
"map_count": self._map_count,
|
||||||
@@ -1082,6 +1411,8 @@ class SlamNode(Node):
|
|||||||
"last_scan_wall_time": self._last_scan_wall_time,
|
"last_scan_wall_time": self._last_scan_wall_time,
|
||||||
"last_point_cloud_wall_time": self._last_point_cloud_wall_time,
|
"last_point_cloud_wall_time": self._last_point_cloud_wall_time,
|
||||||
"last_fallback_point_cloud_wall_time": self._last_fallback_point_cloud_wall_time,
|
"last_fallback_point_cloud_wall_time": self._last_fallback_point_cloud_wall_time,
|
||||||
|
"last_color_point_cloud_wall_time": self._last_color_point_cloud_wall_time,
|
||||||
|
"last_camera_image_wall_time": self._last_camera_image_wall_time,
|
||||||
"last_icp_odom_wall_time": self._last_icp_odom_wall_time,
|
"last_icp_odom_wall_time": self._last_icp_odom_wall_time,
|
||||||
"last_odom_wall_time": self._last_odom_wall_time,
|
"last_odom_wall_time": self._last_odom_wall_time,
|
||||||
"last_map_wall_time": self._last_map_wall_time,
|
"last_map_wall_time": self._last_map_wall_time,
|
||||||
@@ -1155,6 +1486,36 @@ def point_cloud():
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sensor-point-cloud")
|
||||||
|
def sensor_point_cloud():
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
payload = slam_node.sensor_point_cloud_payload()
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=404, detail="센서 포인트클라우드를 아직 수신하지 못했습니다.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/color-point-cloud")
|
||||||
|
def color_point_cloud():
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
payload = slam_node.color_point_cloud_payload()
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=404, detail="컬러 포인트클라우드를 아직 수신하지 못했습니다.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/camera-frame")
|
||||||
|
def camera_frame():
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
frame = slam_node.camera_frame_jpeg()
|
||||||
|
if frame is None:
|
||||||
|
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
|
||||||
|
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/world-layout")
|
@app.get("/world-layout")
|
||||||
def world_layout():
|
def world_layout():
|
||||||
path = Path(os.environ.get("WORLD_LAYOUT_PATH", "/workspace/world-layout.json"))
|
path = Path(os.environ.get("WORLD_LAYOUT_PATH", "/workspace/world-layout.json"))
|
||||||
@@ -1172,7 +1533,7 @@ def save_map(payload: SaveMapPayload):
|
|||||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
try:
|
try:
|
||||||
return {"ok": True, "map": slam_node.save_map(payload.name)}
|
return {"ok": True, "map": slam_node.save_map(payload.name)}
|
||||||
except ValueError as error:
|
except (ValueError, RuntimeError) as error:
|
||||||
raise HTTPException(status_code=409, detail=str(error)) from error
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
@@ -1239,8 +1600,11 @@ def initial_pose(payload: InitialPosePayload):
|
|||||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
if slam_node.read_only:
|
if slam_node.read_only:
|
||||||
raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.")
|
raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.")
|
||||||
slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id)
|
try:
|
||||||
return {"ok": True}
|
result = slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
return {"ok": True, **result}
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/state")
|
@app.websocket("/ws/state")
|
||||||
|
|||||||
+105
-13
@@ -352,7 +352,11 @@ async function syncWorkspaceSavedMapArtifacts(
|
|||||||
}
|
}
|
||||||
await Promise.all(artifacts.map(async (artifact) => {
|
await Promise.all(artifacts.map(async (artifact) => {
|
||||||
const normalized = validateSavedMapDownload(artifact)
|
const normalized = validateSavedMapDownload(artifact)
|
||||||
await downloadArtifact(normalized.url, resolve(mapsDirectory, normalized.fileName))
|
const targetPath = resolve(mapsDirectory, normalized.fileName)
|
||||||
|
// SLAM containers can leave an artifact owned by root. Removing it first
|
||||||
|
// lets the download recreate the file with the desktop user's ownership.
|
||||||
|
await rm(targetPath, { force: true })
|
||||||
|
await downloadArtifact(normalized.url, targetPath)
|
||||||
}))
|
}))
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
@@ -1335,6 +1339,45 @@ async function readRemoteSavedMapArtifacts(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function copyRemoteActiveRtabmapDatabaseToLocal(
|
||||||
|
targetInput: RemoteWorkspaceTarget,
|
||||||
|
localWorkspaceRoot: string | null | undefined,
|
||||||
|
destinationPath: string,
|
||||||
|
): Promise<{ databasePath: string }> {
|
||||||
|
const target = normalizeRemoteTarget(targetInput)
|
||||||
|
const destinationFileName = savedMapArtifactFileName('database', destinationPath)
|
||||||
|
const sourcePath = `${target.workspaceRoot}/maps/rtabmap-active.db`
|
||||||
|
const snapshotScript = [
|
||||||
|
'import sqlite3, sys',
|
||||||
|
'source, destination = sys.argv[1:3]',
|
||||||
|
'source_db = sqlite3.connect(f"file:{source}?mode=ro", uri=True)',
|
||||||
|
'destination_db = sqlite3.connect(destination)',
|
||||||
|
'source_db.backup(destination_db)',
|
||||||
|
'destination_db.close()',
|
||||||
|
'source_db.close()',
|
||||||
|
].join('; ')
|
||||||
|
const remoteCommand = [
|
||||||
|
'set -e',
|
||||||
|
`SOURCE=${quoteShellSingle(sourcePath)}`,
|
||||||
|
'test -f "$SOURCE" || { echo "3D SLAM DB를 찾을 수 없습니다: $SOURCE" >&2; exit 1; }',
|
||||||
|
'SNAPSHOT=$(mktemp)',
|
||||||
|
`python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`,
|
||||||
|
'base64 -w0 "$SNAPSHOT"',
|
||||||
|
'rm -f "$SNAPSHOT"',
|
||||||
|
].join('\n')
|
||||||
|
const base64 = (await runSshOutput(target, remoteCommand, 120_000)).trim()
|
||||||
|
if (!base64) throw new Error('3D SLAM DB 스냅샷을 읽지 못했습니다.')
|
||||||
|
|
||||||
|
await syncWorkspaceSavedMapArtifacts(localWorkspaceRoot, [])
|
||||||
|
const root = expandUserPath(localWorkspaceRoot)
|
||||||
|
if (!root) throw new Error('로컬 워크스페이스 경로가 설정되어 있지 않습니다.')
|
||||||
|
const mapsDirectory = resolve(root, 'maps')
|
||||||
|
const destination = resolve(mapsDirectory, destinationFileName)
|
||||||
|
if (!destination.startsWith(`${mapsDirectory}/`)) throw new Error('저장된 3D 맵 경로가 올바르지 않습니다.')
|
||||||
|
await writeFile(destination, Buffer.from(base64, 'base64'))
|
||||||
|
return { databasePath: `/workspace/maps/${destinationFileName}` }
|
||||||
|
}
|
||||||
|
|
||||||
async function syncRemoteSavedMapArtifacts(
|
async function syncRemoteSavedMapArtifacts(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
artifacts: SavedMapArtifactDownload[],
|
artifacts: SavedMapArtifactDownload[],
|
||||||
@@ -1347,7 +1390,13 @@ async function syncRemoteSavedMapArtifacts(
|
|||||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||||
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
|
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
|
||||||
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then',
|
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then',
|
||||||
' IMAGE="$(docker image inspect future-nav2:humble-jetson_orin --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble-jetson_orin || docker image inspect future-nav2:humble --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble || true)"',
|
' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then',
|
||||||
|
' IMAGE="future-nav2:humble-jetson_orin"',
|
||||||
|
' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then',
|
||||||
|
' IMAGE="future-nav2:humble"',
|
||||||
|
' else',
|
||||||
|
' IMAGE=""',
|
||||||
|
' fi',
|
||||||
' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi',
|
' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi',
|
||||||
' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"',
|
' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"',
|
||||||
'fi',
|
'fi',
|
||||||
@@ -1460,7 +1509,7 @@ async function startRemoteVisualizationBridge(
|
|||||||
`-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`,
|
`-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`,
|
||||||
'-e ODOM_TOPIC=/odom -e SCAN_TOPIC=/scan -e MAP_TOPIC=/map -e CMD_VEL_TOPIC=/cmd_vel -e NAVIGATE_TO_POSE_ACTION=/navigate_to_pose',
|
'-e ODOM_TOPIC=/odom -e SCAN_TOPIC=/scan -e MAP_TOPIC=/map -e CMD_VEL_TOPIC=/cmd_vel -e NAVIGATE_TO_POSE_ACTION=/navigate_to_pose',
|
||||||
...(stack === 'slam3d'
|
...(stack === 'slam3d'
|
||||||
? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e READ_ONLY=true -e RTABMAP_MAP_SAVE_ENABLED=true']
|
? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e RTABMAP_MAP_SAVE_ENABLED=true']
|
||||||
: []),
|
: []),
|
||||||
bridgeEnvArgs,
|
bridgeEnvArgs,
|
||||||
'-e WORLD_LAYOUT_PATH=/workspace/world-layout.json -e SAVED_MAPS_PATH=/workspace/maps -e RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
|
'-e WORLD_LAYOUT_PATH=/workspace/world-layout.json -e SAVED_MAPS_PATH=/workspace/maps -e RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
|
||||||
@@ -2450,7 +2499,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string {
|
|||||||
return [
|
return [
|
||||||
'source /opt/ros/humble/setup.bash',
|
'source /opt/ros/humble/setup.bash',
|
||||||
setupWorkspace,
|
setupWorkspace,
|
||||||
`python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath}`,
|
`(python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath})`,
|
||||||
`python3 ${bridgePath}`
|
`python3 ${bridgePath}`
|
||||||
].filter(Boolean).join(' && ')
|
].filter(Boolean).join(' && ')
|
||||||
}
|
}
|
||||||
@@ -2739,6 +2788,7 @@ async function startVisualizationBridge(
|
|||||||
rosDomainId?: string | null,
|
rosDomainId?: string | null,
|
||||||
environmentProfile?: EnvironmentProfileConfig | null,
|
environmentProfile?: EnvironmentProfileConfig | null,
|
||||||
rosNetwork?: RosNetworkConfig | null,
|
rosNetwork?: RosNetworkConfig | null,
|
||||||
|
savedMapDatabasePath?: string | null,
|
||||||
): Promise<VisualizationBridgeStatus> {
|
): Promise<VisualizationBridgeStatus> {
|
||||||
const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput)
|
const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput)
|
||||||
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
|
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
|
||||||
@@ -2840,6 +2890,10 @@ async function startVisualizationBridge(
|
|||||||
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
|
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
|
||||||
)
|
)
|
||||||
if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true')
|
if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true')
|
||||||
|
const savedMapFileName = savedMapDatabasePath ? safeSavedMapFileName(basename(savedMapDatabasePath)) : null
|
||||||
|
if (savedMapFileName?.endsWith('.db') && existsSync(resolve(layoutWorkspaceRoot, 'maps', savedMapFileName))) {
|
||||||
|
args.push('-e', `SAVED_MAP_DATABASE_PATH=/workspace/maps/${savedMapFileName}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId)
|
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId)
|
||||||
if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) {
|
if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) {
|
||||||
@@ -3101,8 +3155,15 @@ async function installRemoteWorkspaceModule(
|
|||||||
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
||||||
' if expected:',
|
' if expected:',
|
||||||
' import hashlib',
|
' import hashlib',
|
||||||
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
|
' def checksum(file_path):',
|
||||||
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
|
' with open(file_path, "rb") as fp: return hashlib.sha256(fp.read()).hexdigest().lower()',
|
||||||
|
' actual = checksum(path)',
|
||||||
|
' if actual != expected:',
|
||||||
|
' # Stable artifact URLs may have a newer object than the local cache.',
|
||||||
|
' os.unlink(path)',
|
||||||
|
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
||||||
|
' actual = checksum(path)',
|
||||||
|
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
|
||||||
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
|
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
|
||||||
' for entry in entries.splitlines():',
|
' for entry in entries.splitlines():',
|
||||||
' entry = entry.strip()',
|
' entry = entry.strip()',
|
||||||
@@ -3512,13 +3573,13 @@ function buildNav2DockerRunScript(): string {
|
|||||||
'esac',
|
'esac',
|
||||||
'fi',
|
'fi',
|
||||||
'',
|
'',
|
||||||
'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* ]]; then',
|
'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* || "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* ]]; then',
|
||||||
' mkdir -p "$MODULE_ROOT/maps"',
|
' mkdir -p "$MODULE_ROOT/maps"',
|
||||||
' has_database_path=false',
|
' has_database_path=false',
|
||||||
' for arg in "$@"; do',
|
' for arg in "$@"; do',
|
||||||
' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi',
|
' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi',
|
||||||
' done',
|
' done',
|
||||||
' if [ "$has_database_path" = false ]; then',
|
' if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* && "$has_database_path" = false ]] || [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* && "$has_database_path" = false ]]; then',
|
||||||
' set -- "database_path:=/modules/maps/rtabmap-active.db" "$@"',
|
' set -- "database_path:=/modules/maps/rtabmap-active.db" "$@"',
|
||||||
' fi',
|
' fi',
|
||||||
'fi',
|
'fi',
|
||||||
@@ -3621,11 +3682,19 @@ function buildNav2DockerBuildScript(): string {
|
|||||||
' set -e',
|
' set -e',
|
||||||
' source /opt/ros/humble/setup.bash',
|
' source /opt/ros/humble/setup.bash',
|
||||||
' mapfile -t paths < <(colcon list --base-paths src --paths-only)',
|
' mapfile -t paths < <(colcon list --base-paths src --paths-only)',
|
||||||
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \\",
|
' ROSDEP_SKIP_ARGS=()',
|
||||||
|
' if ! apt-cache show ros-humble-gazebo-ros >/dev/null 2>&1; then',
|
||||||
|
' ROSDEP_SKIP_ARGS=(--skip-keys gazebo_ros)',
|
||||||
|
' fi',
|
||||||
|
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\",
|
||||||
" --dependency-types buildtool --dependency-types build \\",
|
" --dependency-types buildtool --dependency-types build \\",
|
||||||
' --dependency-types build_export --dependency-types exec -r -y',
|
' --dependency-types build_export --dependency-types exec -r -y',
|
||||||
|
' BUILD_TARGETS=("$2")',
|
||||||
|
' if [[ "$1" == *lms-nav2-gazebo-slam* && "$1" != *lms-nav2-gazebo-slam3d* ]]; then',
|
||||||
|
' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)',
|
||||||
|
' fi',
|
||||||
" colcon --log-base log build --base-paths src --build-base build --install-base install \\",
|
" colcon --log-base log build --base-paths src --build-base build --install-base install \\",
|
||||||
' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "$2"',
|
' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "${BUILD_TARGETS[@]}"',
|
||||||
"' bash \"$MODULE_FOLDER\" \"$TARGET\"",
|
"' bash \"$MODULE_FOLDER\" \"$TARGET\"",
|
||||||
''
|
''
|
||||||
].join('\n')
|
].join('\n')
|
||||||
@@ -3785,6 +3854,22 @@ async function findRunningNav2SimulatorContainer(): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function findNav2SimulatorRobotModel(containerName: string): Promise<string> {
|
async function findNav2SimulatorRobotModel(containerName: string): Promise<string> {
|
||||||
|
// Query Gazebo directly first. The ROS GetModelList service can be slow or
|
||||||
|
// unavailable while DDS participants are still discovering each other, even
|
||||||
|
// though the simulator itself is already able to accept a pose update.
|
||||||
|
const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger']
|
||||||
|
for (const modelName of knownModels) {
|
||||||
|
const pose = await runCommandOutput(
|
||||||
|
'docker',
|
||||||
|
['exec', containerName, 'gz', 'model', '-m', modelName, '-p'],
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
5_000,
|
||||||
|
).catch(() => '')
|
||||||
|
const values = pose.trim().split(/\s+/).map(Number)
|
||||||
|
if (values.length === 6 && values.every(Number.isFinite)) return modelName
|
||||||
|
}
|
||||||
|
|
||||||
const output = await runCommandOutput(
|
const output = await runCommandOutput(
|
||||||
'docker',
|
'docker',
|
||||||
[
|
[
|
||||||
@@ -3798,7 +3883,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
|
|||||||
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
|
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
|
||||||
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
|
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
|
||||||
const robotModel = modelNames.find((modelName) => (
|
const robotModel = modelNames.find((modelName) => (
|
||||||
/turtlebot|burger|robot/i.test(modelName) && !/world/i.test(modelName)
|
/turtlebot|burger|mkmini|mk-mini|robot/i.test(modelName) && !/world/i.test(modelName)
|
||||||
))
|
))
|
||||||
if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.')
|
if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.')
|
||||||
return robotModel
|
return robotModel
|
||||||
@@ -3815,6 +3900,10 @@ async function setNav2SimulatorPose(
|
|||||||
const containerName = await findRunningNav2SimulatorContainer()
|
const containerName = await findRunningNav2SimulatorContainer()
|
||||||
if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.')
|
if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.')
|
||||||
const robotModel = await findNav2SimulatorRobotModel(containerName)
|
const robotModel = await findNav2SimulatorRobotModel(containerName)
|
||||||
|
// MK-mini's model origin is higher than TurtleBot's. Teleporting it at the
|
||||||
|
// TurtleBot ground height puts its chassis and wheels into the floor, which
|
||||||
|
// makes Gazebo eject or tip the vehicle while resolving the collision.
|
||||||
|
const spawnHeight = /^mkmini/i.test(robotModel) ? '0.16' : '0.01'
|
||||||
|
|
||||||
await runCommand(
|
await runCommand(
|
||||||
'docker',
|
'docker',
|
||||||
@@ -3823,7 +3912,7 @@ async function setNav2SimulatorPose(
|
|||||||
'gz', 'model', '-m', robotModel,
|
'gz', 'model', '-m', robotModel,
|
||||||
'-x', String(x),
|
'-x', String(x),
|
||||||
'-y', String(y),
|
'-y', String(y),
|
||||||
'-z', '0.01',
|
'-z', spawnHeight,
|
||||||
'-R', '0',
|
'-R', '0',
|
||||||
'-P', '0',
|
'-P', '0',
|
||||||
'-Y', String(yaw),
|
'-Y', String(yaw),
|
||||||
@@ -5141,6 +5230,9 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => (
|
ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => (
|
||||||
readRemoteSavedMapArtifacts(target, artifacts)
|
readRemoteSavedMapArtifacts(target, artifacts)
|
||||||
))
|
))
|
||||||
|
ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => (
|
||||||
|
copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath)
|
||||||
|
))
|
||||||
ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => (
|
ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => (
|
||||||
syncRemoteSavedMapArtifacts(target, artifacts)
|
syncRemoteSavedMapArtifacts(target, artifacts)
|
||||||
))
|
))
|
||||||
@@ -5204,7 +5296,7 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
return { ok: true }
|
return { ok: true }
|
||||||
})
|
})
|
||||||
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
|
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
|
||||||
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork))
|
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath))
|
||||||
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
|
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
|
||||||
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
|
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
|
||||||
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
|
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
},
|
},
|
||||||
visualization: {
|
visualization: {
|
||||||
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
|
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath),
|
||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
||||||
},
|
},
|
||||||
git: {
|
git: {
|
||||||
@@ -266,6 +266,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
},
|
},
|
||||||
maps: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
||||||
|
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
|
||||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
||||||
},
|
},
|
||||||
modules: {
|
modules: {
|
||||||
|
|||||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+108
-73
@@ -428,6 +428,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
platformEnvironmentProfiles,
|
platformEnvironmentProfiles,
|
||||||
platformEnvironmentConfigOverride,
|
platformEnvironmentConfigOverride,
|
||||||
platformOptions,
|
platformOptions,
|
||||||
|
workspaceVisible,
|
||||||
skipInitialLoading,
|
skipInitialLoading,
|
||||||
visualizationOpen,
|
visualizationOpen,
|
||||||
onVisualizationOpenChange,
|
onVisualizationOpenChange,
|
||||||
@@ -476,6 +477,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
platformEnvironmentProfiles?: EnvironmentProfile[]
|
platformEnvironmentProfiles?: EnvironmentProfile[]
|
||||||
platformEnvironmentConfigOverride?: Record<string, unknown> | null
|
platformEnvironmentConfigOverride?: Record<string, unknown> | null
|
||||||
platformOptions: PlatformOption[]
|
platformOptions: PlatformOption[]
|
||||||
|
workspaceVisible: boolean
|
||||||
skipInitialLoading?: boolean
|
skipInitialLoading?: boolean
|
||||||
visualizationOpen: boolean
|
visualizationOpen: boolean
|
||||||
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
|
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
|
||||||
@@ -527,6 +529,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
clearQaConversation
|
clearQaConversation
|
||||||
} = useQaChat({
|
} = useQaChat({
|
||||||
accessToken,
|
accessToken,
|
||||||
|
courseId: courseKey,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
apiBaseUrl: LMS_API_BASE_URL,
|
apiBaseUrl: LMS_API_BASE_URL,
|
||||||
socketBaseUrl: LMS_SOCKET_ORIGIN,
|
socketBaseUrl: LMS_SOCKET_ORIGIN,
|
||||||
@@ -804,6 +807,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
const configuredMaps = (curriculumWeek.maps ?? [])
|
const configuredMaps = (curriculumWeek.maps ?? [])
|
||||||
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
|
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
|
||||||
const baseMaps = [...configuredMaps]
|
const baseMaps = [...configuredMaps]
|
||||||
|
const usesThreeDimensionalSlam = (curriculumWeek.modules ?? []).some((module) => (
|
||||||
|
/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')
|
||||||
|
))
|
||||||
const platformSlamMap: WorkspaceMapOption = {
|
const platformSlamMap: WorkspaceMapOption = {
|
||||||
key: 'nav2-platform-slam',
|
key: 'nav2-platform-slam',
|
||||||
platformKey: 'NAV2',
|
platformKey: 'NAV2',
|
||||||
@@ -812,7 +818,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
worldFile: '',
|
worldFile: '',
|
||||||
viewerModel: 'turtlebot3_world',
|
viewerModel: 'turtlebot3_world',
|
||||||
remoteSlam: true,
|
remoteSlam: true,
|
||||||
runtimeKind: 'PLATFORM_2D',
|
runtimeKind: usesThreeDimensionalSlam ? 'PLATFORM_3D' : 'PLATFORM_2D',
|
||||||
}
|
}
|
||||||
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps]
|
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps]
|
||||||
const keys = new Set<string>()
|
const keys = new Set<string>()
|
||||||
@@ -821,7 +827,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
keys.add(map.key)
|
keys.add(map.key)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [curriculumWeek.maps, savedSlamMaps])
|
}, [curriculumWeek.maps, curriculumWeek.modules, savedSlamMaps])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
|
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
|
||||||
?? window.localStorage.getItem(simulationMapStorageKey)
|
?? window.localStorage.getItem(simulationMapStorageKey)
|
||||||
@@ -839,6 +845,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
})
|
})
|
||||||
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey])
|
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey])
|
||||||
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0]
|
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0]
|
||||||
|
const prepareVisualizationSavedMapRef = useRef<(
|
||||||
|
() => Promise<{ savedMapDatabasePath?: string }>
|
||||||
|
) | null>(null)
|
||||||
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D'
|
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D'
|
||||||
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
||||||
|| selectedWorldMap?.remoteSlam === true
|
|| selectedWorldMap?.remoteSlam === true
|
||||||
@@ -1004,6 +1013,11 @@ function LocalPcWorkspaceMockup({
|
|||||||
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
|
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
|
||||||
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
|
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
|
||||||
platform: visualizationPlatform,
|
platform: visualizationPlatform,
|
||||||
|
threeDimensional: Boolean(
|
||||||
|
selectedWorldMap.databasePath || selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||||
|
),
|
||||||
|
savedMapDatabasePath: selectedWorldMap.databasePath,
|
||||||
|
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
|
||||||
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|
||||||
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||||
|| selectedWorldMap.remoteSlam
|
|| selectedWorldMap.remoteSlam
|
||||||
@@ -1023,11 +1037,16 @@ function LocalPcWorkspaceMockup({
|
|||||||
if (initializedVisualizationMapRef.current === mapIdentity) return
|
if (initializedVisualizationMapRef.current === mapIdentity) return
|
||||||
initializedVisualizationMapRef.current = mapIdentity
|
initializedVisualizationMapRef.current = mapIdentity
|
||||||
const navigationMap = Boolean(selectedWorldMap?.saved)
|
const navigationMap = Boolean(selectedWorldMap?.saved)
|
||||||
|
const threeDimensionalMap = Boolean(
|
||||||
|
selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
||||||
|
)
|
||||||
|
// Saved RTAB-Map databases are navigable maps. Keep the 3D viewer while
|
||||||
|
// enabling the same pose and goal controls used by NAV2 navigation.
|
||||||
const nextMode = navigationMap ? 'navigation' : 'slam'
|
const nextMode = navigationMap ? 'navigation' : 'slam'
|
||||||
const nextStack = navigationMap
|
const nextStack = threeDimensionalMap
|
||||||
? 'nav2'
|
? 'slam3d'
|
||||||
: selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
: navigationMap
|
||||||
? 'slam3d'
|
? 'nav2'
|
||||||
: 'slam'
|
: 'slam'
|
||||||
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
|
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
|
||||||
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
|
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
|
||||||
@@ -1108,7 +1127,14 @@ function LocalPcWorkspaceMockup({
|
|||||||
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps?courseId=${encodeURIComponent(courseKey)}&week=${weekNumber}`)
|
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps?courseId=${encodeURIComponent(courseKey)}&week=${weekNumber}`)
|
||||||
if (!response.ok) throw new Error(`saved_maps_failed_${response.status}`)
|
if (!response.ok) throw new Error(`saved_maps_failed_${response.status}`)
|
||||||
const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }>
|
const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }>
|
||||||
setSavedSlamMaps(maps.map((map) => ({
|
setSavedSlamMaps(maps.map((map) => {
|
||||||
|
const isThreeDimensional = map.runtimeKind === 'PLATFORM_3D'
|
||||||
|
|| Boolean(map.databasePath || map.databaseUrl)
|
||||||
|
// Older 3D map rows can omit databasePath even though the database
|
||||||
|
// artifact follows the YAML filename in the workspace maps directory.
|
||||||
|
const databasePath = map.databasePath
|
||||||
|
?? (isThreeDimensional ? map.yamlPath.replace(/\.yaml$/i, '.db') : undefined)
|
||||||
|
return {
|
||||||
key: `saved-slam-${map.id}`,
|
key: `saved-slam-${map.id}`,
|
||||||
platformKey: 'NAV2',
|
platformKey: 'NAV2',
|
||||||
name: map.name,
|
name: map.name,
|
||||||
@@ -1124,11 +1150,12 @@ function LocalPcWorkspaceMockup({
|
|||||||
yamlUrl: map.yamlUrl ?? undefined,
|
yamlUrl: map.yamlUrl ?? undefined,
|
||||||
pgmUrl: map.pgmUrl ?? undefined,
|
pgmUrl: map.pgmUrl ?? undefined,
|
||||||
previewPgm: map.previewPgm ?? undefined,
|
previewPgm: map.previewPgm ?? undefined,
|
||||||
databasePath: map.databasePath ?? undefined,
|
databasePath,
|
||||||
databaseUrl: map.databaseUrl ?? undefined,
|
databaseUrl: map.databaseUrl ?? undefined,
|
||||||
runtimeKind: map.runtimeKind
|
runtimeKind: map.runtimeKind
|
||||||
?? (map.databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
|
?? (databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
|
||||||
})))
|
}
|
||||||
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[studio] saved SLAM maps load failed', error)
|
console.warn('[studio] saved SLAM maps load failed', error)
|
||||||
} finally {
|
} finally {
|
||||||
@@ -1150,20 +1177,28 @@ function LocalPcWorkspaceMockup({
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
if (payload?.source !== 'autonomy-slam-viewer') return
|
if (payload?.source !== 'autonomy-slam-viewer') return
|
||||||
if (payload.type === 'navigation-init') {
|
if (payload.type === 'simulator-pose-request' && payload.requestId && payload.pose) {
|
||||||
const isPlatformMap = selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|
const reply = (ok: boolean, message?: string) => {
|
||||||
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
const target = event.source as Window | null
|
||||||
|| selectedWorldMap.remoteSlam
|
target?.postMessage({
|
||||||
// Only the Gazebo simulation has a model pose to reset. A platform
|
source: 'autonomy-studio',
|
||||||
// map can also be opened from the local workspace, where this IPC
|
type: 'simulator-pose-result',
|
||||||
// would incorrectly fail because no Simulator container is running.
|
requestId: payload.requestId,
|
||||||
if (remoteWorkspace || isPlatformMap) return
|
ok,
|
||||||
void Promise.all([
|
message,
|
||||||
window.localWorkspace.nav2.setSimulatorPose({ x: -2, y: -0.5, yaw: 0 }),
|
}, '*')
|
||||||
window.localWorkspace.nav2.clearRuntimeObstacles(),
|
}
|
||||||
]).catch((error) => {
|
if (remoteWorkspace) {
|
||||||
console.warn('[studio] nav2 initialization failed', error)
|
reply(false, '원격 플랫폼의 로봇 위치는 시각화에서 변경할 수 없습니다.')
|
||||||
onTitlebarNotice('NAV2 초기화에 실패했습니다.', 'error')
|
return
|
||||||
|
}
|
||||||
|
void window.localWorkspace.nav2.setSimulatorPose({
|
||||||
|
x: Number(payload.pose.x),
|
||||||
|
y: Number(payload.pose.y),
|
||||||
|
yaw: Number(payload.pose.yaw ?? 0),
|
||||||
|
}).then(() => reply(true)).catch((error) => {
|
||||||
|
console.warn('[studio] nav2 simulator pose update failed', error)
|
||||||
|
reply(false, error instanceof Error ? error.message : '가제보 로봇 위치 적용에 실패했습니다.')
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1191,42 +1226,6 @@ function LocalPcWorkspaceMockup({
|
|||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.type === 'initial-pose-selected') {
|
|
||||||
const x = Number(payload.pose?.x)
|
|
||||||
const y = Number(payload.pose?.y)
|
|
||||||
const yaw = Number(payload.pose?.yaw ?? 0)
|
|
||||||
if (![x, y, yaw].every(Number.isFinite)) return
|
|
||||||
if (remoteWorkspace) {
|
|
||||||
;(event.source as WindowProxy | null)?.postMessage({
|
|
||||||
source: 'autonomy-lms-shell',
|
|
||||||
type: 'initial-pose-applied',
|
|
||||||
requestId: payload.requestId,
|
|
||||||
ok: true,
|
|
||||||
}, '*')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void window.localWorkspace.nav2.setSimulatorPose({ x, y, yaw })
|
|
||||||
.then(() => {
|
|
||||||
(event.source as WindowProxy | null)?.postMessage({
|
|
||||||
source: 'autonomy-lms-shell',
|
|
||||||
type: 'initial-pose-applied',
|
|
||||||
requestId: payload.requestId,
|
|
||||||
ok: true,
|
|
||||||
}, '*')
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.warn('[studio] nav2 simulator pose update failed', error)
|
|
||||||
onTitlebarNotice('Gazebo 로봇 위치 적용에 실패했습니다.', 'error')
|
|
||||||
;(event.source as WindowProxy | null)?.postMessage({
|
|
||||||
source: 'autonomy-lms-shell',
|
|
||||||
type: 'initial-pose-applied',
|
|
||||||
requestId: payload.requestId,
|
|
||||||
ok: false,
|
|
||||||
message: error instanceof Error ? error.message : 'Gazebo 로봇 위치 적용에 실패했습니다.',
|
|
||||||
}, '*')
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (payload.type === 'map-save-failed') {
|
if (payload.type === 'map-save-failed') {
|
||||||
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
|
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
|
||||||
return
|
return
|
||||||
@@ -1234,14 +1233,33 @@ function LocalPcWorkspaceMockup({
|
|||||||
if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return
|
if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
let databasePath = payload.map.databasePath ?? null
|
||||||
|
const savesThreeDimensionalMap = remoteWorkspace
|
||||||
|
&& visualizationBridgeLocation === 'local'
|
||||||
|
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||||
|
if (savesThreeDimensionalMap && !databasePath) {
|
||||||
|
const databaseFileName = payload.map.yamlPath.replace(/\.yaml$/i, '.db')
|
||||||
|
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
|
||||||
|
getRemoteWorkspaceTarget(),
|
||||||
|
localVisualizationWorkspaceRoot,
|
||||||
|
databaseFileName,
|
||||||
|
)
|
||||||
|
databasePath = copied.databasePath
|
||||||
|
}
|
||||||
const sourceArtifacts = [
|
const sourceArtifacts = [
|
||||||
{ name: 'yaml' as const, path: payload.map.yamlPath },
|
{ name: 'yaml' as const, path: payload.map.yamlPath },
|
||||||
{ name: 'pgm' as const, path: payload.map.pgmPath },
|
{ name: 'pgm' as const, path: payload.map.pgmPath },
|
||||||
...(payload.map.databasePath ? [{ name: 'database' as const, path: payload.map.databasePath }] : []),
|
...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
|
||||||
]
|
]
|
||||||
const artifacts = remoteWorkspace
|
// A remote ROS session can still use a bridge running on this PC.
|
||||||
|
// Read saved artifacts from the bridge host, not merely from the
|
||||||
|
// currently selected workspace.
|
||||||
|
const artifacts = remoteWorkspace && visualizationBridgeLocation === 'platform'
|
||||||
? await window.remoteWorkspace.maps.readArtifacts(getRemoteWorkspaceTarget(), sourceArtifacts)
|
? await window.remoteWorkspace.maps.readArtifacts(getRemoteWorkspaceTarget(), sourceArtifacts)
|
||||||
: await window.localWorkspace.maps.readArtifacts(clonePathPreview, sourceArtifacts)
|
: await window.localWorkspace.maps.readArtifacts(
|
||||||
|
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
|
||||||
|
sourceArtifacts,
|
||||||
|
)
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.set('courseId', courseKey)
|
form.set('courseId', courseKey)
|
||||||
form.set('week', String(weekNumber))
|
form.set('week', String(weekNumber))
|
||||||
@@ -1249,12 +1267,12 @@ function LocalPcWorkspaceMockup({
|
|||||||
form.set('worldFile', selectedWorldMap.worldFile)
|
form.set('worldFile', selectedWorldMap.worldFile)
|
||||||
form.set('viewerModel', selectedWorldMap.viewerModel)
|
form.set('viewerModel', selectedWorldMap.viewerModel)
|
||||||
form.set('runtimeKind', remoteWorkspace
|
form.set('runtimeKind', remoteWorkspace
|
||||||
? (payload.map.databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
|
? (databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
|
||||||
: 'SIMULATION')
|
: 'SIMULATION')
|
||||||
form.set('yamlPath', payload.map.yamlPath)
|
form.set('yamlPath', payload.map.yamlPath)
|
||||||
form.set('pgmPath', payload.map.pgmPath)
|
form.set('pgmPath', payload.map.pgmPath)
|
||||||
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
|
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
|
||||||
if (payload.map.databasePath) form.set('databasePath', payload.map.databasePath)
|
if (databasePath) form.set('databasePath', databasePath)
|
||||||
artifacts.forEach((artifact) => {
|
artifacts.forEach((artifact) => {
|
||||||
const binary = atob(artifact.base64)
|
const binary = atob(artifact.base64)
|
||||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||||
@@ -1281,7 +1299,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
window.addEventListener('message', handleViewerMessage)
|
window.addEventListener('message', handleViewerMessage)
|
||||||
return () => window.removeEventListener('message', handleViewerMessage)
|
return () => window.removeEventListener('message', handleViewerMessage)
|
||||||
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, weekNumber])
|
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
|
||||||
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
|
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
|
||||||
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
|
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
|
||||||
const hasRestorableWorkspaceUi = Boolean(
|
const hasRestorableWorkspaceUi = Boolean(
|
||||||
@@ -1290,7 +1308,10 @@ function LocalPcWorkspaceMockup({
|
|||||||
)
|
)
|
||||||
const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi)
|
const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi)
|
||||||
const workspaceRestoreStartedAtRef = useRef(Date.now())
|
const workspaceRestoreStartedAtRef = useRef(Date.now())
|
||||||
const getModuleParameterStorageKey = (module: ModuleRow): string => makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name)
|
const moduleParameterScope = remoteWorkspace ? `platform:${platformId}` : 'local'
|
||||||
|
const getModuleParameterStorageKey = (module: ModuleRow): string => (
|
||||||
|
makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name, moduleParameterScope)
|
||||||
|
)
|
||||||
const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => {
|
const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => {
|
||||||
const storedParameters = readStoredModuleParameters()
|
const storedParameters = readStoredModuleParameters()
|
||||||
return modules.map((module) => {
|
return modules.map((module) => {
|
||||||
@@ -1304,7 +1325,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
: defaultParameters.length ? defaultParameters : module.parameters
|
: defaultParameters.length ? defaultParameters : module.parameters
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}, [courseKey, runtimeModules, weekNumber])
|
}, [courseKey, moduleParameterScope, runtimeModules, weekNumber])
|
||||||
const curriculumModuleKey = runtimeModules
|
const curriculumModuleKey = runtimeModules
|
||||||
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
|
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
|
||||||
.join('|')
|
.join('|')
|
||||||
@@ -1312,6 +1333,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
availableModules,
|
availableModules,
|
||||||
setAvailableModules,
|
setAvailableModules,
|
||||||
markModuleState,
|
markModuleState,
|
||||||
|
removeModule,
|
||||||
modulesLoading,
|
modulesLoading,
|
||||||
moduleListError,
|
moduleListError,
|
||||||
clearModuleListError,
|
clearModuleListError,
|
||||||
@@ -2408,16 +2430,21 @@ function LocalPcWorkspaceMockup({
|
|||||||
artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl })
|
artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl })
|
||||||
databasePath = `/workspace/maps/${databaseFileName}`
|
databasePath = `/workspace/maps/${databaseFileName}`
|
||||||
}
|
}
|
||||||
if (remoteWorkspace) {
|
const syncToPlatformWorkspace = remoteWorkspace && visualizationBridgeLocation === 'platform'
|
||||||
|
if (syncToPlatformWorkspace) {
|
||||||
await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts)
|
await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts)
|
||||||
} else {
|
} else {
|
||||||
await window.localWorkspace.maps.sync(clonePathPreview, artifacts)
|
await window.localWorkspace.maps.sync(
|
||||||
|
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
|
||||||
|
artifacts,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
|
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
|
||||||
savedMapDatabasePath: databasePath,
|
savedMapDatabasePath: databasePath,
|
||||||
}
|
}
|
||||||
}, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace, selectedWorldMap])
|
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
|
||||||
|
prepareVisualizationSavedMapRef.current = prepareSavedMap
|
||||||
const vehicleRuntimeProfiles = useMemo(() => {
|
const vehicleRuntimeProfiles = useMemo(() => {
|
||||||
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
|
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
|
||||||
platformOptions.forEach((platform) => {
|
platformOptions.forEach((platform) => {
|
||||||
@@ -2447,6 +2474,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
weekNumber,
|
weekNumber,
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
remoteWorkspace,
|
remoteWorkspace,
|
||||||
|
moduleParameterScope,
|
||||||
|
workspaceActive: workspaceVisible,
|
||||||
sourceSettings: runtimeSourceSettings,
|
sourceSettings: runtimeSourceSettings,
|
||||||
rosNetwork: moduleRosNetworkConfig,
|
rosNetwork: moduleRosNetworkConfig,
|
||||||
simulatorWorldFile: selectedWorldMap.worldFile,
|
simulatorWorldFile: selectedWorldMap.worldFile,
|
||||||
@@ -2483,7 +2512,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
if (map.saved) {
|
if (map.saved) {
|
||||||
setNav2VisualizationMode('navigation')
|
setNav2VisualizationMode('navigation')
|
||||||
setVisualizationStack('nav2')
|
setVisualizationStack(map.databasePath ? 'slam3d' : 'nav2')
|
||||||
} else {
|
} else {
|
||||||
setNav2VisualizationMode('slam')
|
setNav2VisualizationMode('slam')
|
||||||
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d')
|
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d')
|
||||||
@@ -2575,7 +2604,11 @@ function LocalPcWorkspaceMockup({
|
|||||||
delete nextRoots[module.name]
|
delete nextRoots[module.name]
|
||||||
return nextRoots
|
return nextRoots
|
||||||
})
|
})
|
||||||
await loadAvailableModules()
|
if (module.sourceType === 'CUSTOM') {
|
||||||
|
removeModule(module)
|
||||||
|
} else {
|
||||||
|
markModuleState(module, 'uninstalled')
|
||||||
|
}
|
||||||
onTitlebarNotice(options.deleteRepository ? `[${module.name}] 모듈과 저장소가 삭제되었습니다` : `[${module.name}] 삭제되었습니다`, 'info')
|
onTitlebarNotice(options.deleteRepository ? `[${module.name}] 모듈과 저장소가 삭제되었습니다` : `[${module.name}] 삭제되었습니다`, 'info')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[modules] delete failed', error)
|
console.error('[modules] delete failed', error)
|
||||||
@@ -4178,6 +4211,7 @@ export default function App(): React.JSX.Element {
|
|||||||
deleteAiConversation: deleteGuideAiConversation
|
deleteAiConversation: deleteGuideAiConversation
|
||||||
} = useQaChat({
|
} = useQaChat({
|
||||||
accessToken: authSession?.accessToken ?? '',
|
accessToken: authSession?.accessToken ?? '',
|
||||||
|
courseId: selectedCourseId,
|
||||||
weekNumber: 0,
|
weekNumber: 0,
|
||||||
globalScope: true,
|
globalScope: true,
|
||||||
apiBaseUrl: LMS_API_BASE_URL,
|
apiBaseUrl: LMS_API_BASE_URL,
|
||||||
@@ -5605,6 +5639,7 @@ export default function App(): React.JSX.Element {
|
|||||||
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
|
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
|
||||||
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
|
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
|
||||||
platformOptions={platformOptions}
|
platformOptions={platformOptions}
|
||||||
|
workspaceVisible={localPcLayoutOpen}
|
||||||
skipInitialLoading={skipWorkspaceInitialLoading}
|
skipInitialLoading={skipWorkspaceInitialLoading}
|
||||||
visualizationOpen={workspaceVisualizationOpen}
|
visualizationOpen={workspaceVisualizationOpen}
|
||||||
onVisualizationOpenChange={setWorkspaceVisualizationOpen}
|
onVisualizationOpenChange={setWorkspaceVisualizationOpen}
|
||||||
|
|||||||
Vendored
+2
-1
@@ -235,7 +235,7 @@ interface Window {
|
|||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | null
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{
|
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | null
|
||||||
@@ -280,6 +280,7 @@ interface Window {
|
|||||||
}
|
}
|
||||||
maps: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||||
|
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
||||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
modules: {
|
modules: {
|
||||||
|
|||||||
@@ -338,7 +338,7 @@ function WorkspaceSourceControlView({
|
|||||||
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
|
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
|
||||||
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
|
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
|
||||||
>
|
>
|
||||||
파일 열기
|
열기
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -212,6 +212,12 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
|||||||
{maps.map((map) => {
|
{maps.map((map) => {
|
||||||
const pending = map.key === pendingMap?.key
|
const pending = map.key === pendingMap?.key
|
||||||
const selected = map.key === selectedMapKey
|
const selected = map.key === selectedMapKey
|
||||||
|
// "새 SLAM" is a launch target, not a saved map yet. Its 2D/3D
|
||||||
|
// runtime is chosen by the active SLAM stack, so only persisted
|
||||||
|
// map results receive a dimensional badge.
|
||||||
|
const mapDimension = map.saved
|
||||||
|
? (map.runtimeKind === 'PLATFORM_3D' || Boolean(map.databasePath) ? '3D' : '2D')
|
||||||
|
: null
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={map.key}
|
key={map.key}
|
||||||
@@ -224,7 +230,15 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
|||||||
>
|
>
|
||||||
<WorkspaceMapPreview map={map} selected={pending} />
|
<WorkspaceMapPreview map={map} selected={pending} />
|
||||||
<span className="min-w-0 flex-1">
|
<span className="min-w-0 flex-1">
|
||||||
<span className="block text-[14px] font-bold">{map.name}</span>
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
|
<span className="truncate text-[14px] font-bold">{map.name}</span>
|
||||||
|
{mapDimension && (
|
||||||
|
<span className={[
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold',
|
||||||
|
mapDimension === '3D' ? 'bg-cyan-400/15 text-cyan-200' : 'bg-white/10 text-white/55',
|
||||||
|
].join(' ')}>{mapDimension}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
|
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
|
||||||
</span>
|
</span>
|
||||||
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
|
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
|
||||||
|
|||||||
@@ -54,6 +54,11 @@ export function storeModuleParameters(key: string, parameters: ModuleParameterPa
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function makeModuleParameterKey(courseKey: string, week: number, folderName?: string): string {
|
export function makeModuleParameterKey(
|
||||||
return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}`
|
courseKey: string,
|
||||||
|
week: number,
|
||||||
|
folderName?: string,
|
||||||
|
workspaceScope = 'local'
|
||||||
|
): string {
|
||||||
|
return `${courseKey || 'default-course'}:week-${week}:${workspaceScope}:${folderName || 'module'}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type ChatNotificationPayload = {
|
|||||||
|
|
||||||
type UseQaChatOptions = {
|
type UseQaChatOptions = {
|
||||||
accessToken: string
|
accessToken: string
|
||||||
|
courseId: string
|
||||||
weekNumber: number
|
weekNumber: number
|
||||||
globalScope?: boolean
|
globalScope?: boolean
|
||||||
apiBaseUrl: string
|
apiBaseUrl: string
|
||||||
@@ -73,6 +74,7 @@ const qaVisibleMessageLimit = 20
|
|||||||
|
|
||||||
export function useQaChat({
|
export function useQaChat({
|
||||||
accessToken,
|
accessToken,
|
||||||
|
courseId,
|
||||||
weekNumber,
|
weekNumber,
|
||||||
globalScope = false,
|
globalScope = false,
|
||||||
apiBaseUrl,
|
apiBaseUrl,
|
||||||
@@ -100,9 +102,12 @@ export function useQaChat({
|
|||||||
const aiRevealCancelRef = useRef(false)
|
const aiRevealCancelRef = useRef(false)
|
||||||
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
|
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
const normalizedCourseId = courseId.trim()
|
||||||
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
|
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
|
||||||
const chatAvailable = globalScope || validChatWeek !== null
|
const chatAvailable = Boolean(normalizedCourseId) && (globalScope || validChatWeek !== null)
|
||||||
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : ''
|
const chatQuery = new URLSearchParams({ courseId: normalizedCourseId })
|
||||||
|
if (validChatWeek) chatQuery.set('week', String(validChatWeek))
|
||||||
|
const chatQueryString = chatQuery.toString()
|
||||||
|
|
||||||
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
|
||||||
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
|
const requestAccessToken = accessToken ? await getFreshAccessToken() : null
|
||||||
@@ -166,7 +171,7 @@ export function useQaChat({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
setAiMessagesLoading(true)
|
setAiMessagesLoading(true)
|
||||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`)
|
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=ai&includeArchived=true`)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const conversations = payload.threads.map((thread) => ({
|
const conversations = payload.threads.map((thread) => ({
|
||||||
@@ -191,7 +196,7 @@ export function useQaChat({
|
|||||||
})
|
})
|
||||||
|
|
||||||
setTeacherMessagesLoading(true)
|
setTeacherMessagesLoading(true)
|
||||||
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`)
|
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=teacher`)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const thread = payload.threads[0]
|
const thread = payload.threads[0]
|
||||||
@@ -211,7 +216,7 @@ export function useQaChat({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery])
|
}, [accessToken, chatAvailable, chatQueryString, chatRefreshSignal])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const target = workspaceChatScrollRef.current
|
const target = workspaceChatScrollRef.current
|
||||||
@@ -264,7 +269,7 @@ export function useQaChat({
|
|||||||
setAiPending(false)
|
setAiPending(false)
|
||||||
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
|
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {})
|
body: JSON.stringify({ courseId: normalizedCourseId, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||||
}).catch(() => {})
|
}).catch(() => {})
|
||||||
setAiMessages((messages) => [
|
setAiMessages((messages) => [
|
||||||
...messages,
|
...messages,
|
||||||
@@ -294,7 +299,7 @@ export function useQaChat({
|
|||||||
try {
|
try {
|
||||||
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
|
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
body: JSON.stringify({ courseId: normalizedCourseId, mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
|
||||||
})
|
})
|
||||||
setTeacherMessages((messages) => messages.map((message) => (
|
setTeacherMessages((messages) => messages.map((message) => (
|
||||||
message.id === userMessage.id
|
message.id === userMessage.id
|
||||||
@@ -333,6 +338,7 @@ export function useQaChat({
|
|||||||
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
|
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
courseId: normalizedCourseId,
|
||||||
content,
|
content,
|
||||||
...(validChatWeek ? { week: validChatWeek } : {}),
|
...(validChatWeek ? { week: validChatWeek } : {}),
|
||||||
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
|
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
|
||||||
@@ -429,7 +435,7 @@ export function useQaChat({
|
|||||||
setAiTypingMessageId(null)
|
setAiTypingMessageId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, {
|
await requestChatApi<{ deletedCount: number }>(`/chat/threads?${chatQueryString}&mode=${qaMode}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import type { ModuleRow } from './types'
|
import type { ModuleRow } from './types'
|
||||||
import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
|
import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
|
||||||
|
|
||||||
@@ -36,6 +36,7 @@ export function useSourceControl({
|
|||||||
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
|
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
|
||||||
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
|
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
|
||||||
const [gitSubmitting, setGitSubmitting] = useState(false)
|
const [gitSubmitting, setGitSubmitting] = useState(false)
|
||||||
|
const gitFileSelectionRequestRef = useRef(0)
|
||||||
|
|
||||||
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|')
|
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|')
|
||||||
const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|')
|
const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|')
|
||||||
@@ -87,8 +88,30 @@ export function useSourceControl({
|
|||||||
const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => {
|
const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => {
|
||||||
const target = { moduleName, filePath }
|
const target = { moduleName, filePath }
|
||||||
setSelectedGitTarget(target)
|
setSelectedGitTarget(target)
|
||||||
await refreshGitStatuses(target)
|
const cachedStatus = gitModuleStatuses[moduleName]
|
||||||
}, [refreshGitStatuses])
|
if (cachedStatus?.selectedFilePath === filePath) return
|
||||||
|
|
||||||
|
const module = installedGitModules.find((item) => item.name === moduleName)
|
||||||
|
if (!module) return
|
||||||
|
|
||||||
|
const requestId = ++gitFileSelectionRequestRef.current
|
||||||
|
setGitLoading(true)
|
||||||
|
setGitError(null)
|
||||||
|
try {
|
||||||
|
// Selecting a file used to refresh every module's status and history.
|
||||||
|
// Only the selected module needs its diff refreshed here.
|
||||||
|
const status = await readGitStatus(module, filePath)
|
||||||
|
if (requestId !== gitFileSelectionRequestRef.current) return
|
||||||
|
setGitModuleStatuses((statuses) => ({ ...statuses, [moduleName]: status }))
|
||||||
|
if (!status.ok) setGitError(status.message ?? '파일 변경 내용을 불러오지 못했습니다.')
|
||||||
|
} catch (error) {
|
||||||
|
if (requestId !== gitFileSelectionRequestRef.current) return
|
||||||
|
console.error('[git] selected file status failed', error)
|
||||||
|
setGitError(error instanceof Error ? error.message : '파일 변경 내용을 불러오지 못했습니다.')
|
||||||
|
} finally {
|
||||||
|
if (requestId === gitFileSelectionRequestRef.current) setGitLoading(false)
|
||||||
|
}
|
||||||
|
}, [gitModuleStatuses, installedGitModules, readGitStatus])
|
||||||
|
|
||||||
const refreshGitSubmissions = useCallback(async (): Promise<void> => {
|
const refreshGitSubmissions = useCallback(async (): Promise<void> => {
|
||||||
if (!installedGitModules.length) {
|
if (!installedGitModules.length) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useRef, useState } from 'react'
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { LMS_API_BASE_URL } from '../../config/appConfig'
|
import { LMS_API_BASE_URL } from '../../config/appConfig'
|
||||||
import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
|
import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
|
||||||
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
|
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
|
||||||
@@ -40,6 +40,8 @@ type UseWorkspaceModuleControlOptions = {
|
|||||||
weekNumber: number
|
weekNumber: number
|
||||||
clonePathPreview: string
|
clonePathPreview: string
|
||||||
remoteWorkspace: boolean
|
remoteWorkspace: boolean
|
||||||
|
moduleParameterScope: string
|
||||||
|
workspaceActive?: boolean
|
||||||
sourceSettings: StudioSourceSettings
|
sourceSettings: StudioSourceSettings
|
||||||
rosNetwork?: RosNetworkConfig | null
|
rosNetwork?: RosNetworkConfig | null
|
||||||
simulatorWorldFile?: string | null
|
simulatorWorldFile?: string | null
|
||||||
@@ -80,6 +82,8 @@ export function useWorkspaceModuleControl({
|
|||||||
weekNumber,
|
weekNumber,
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
remoteWorkspace,
|
remoteWorkspace,
|
||||||
|
moduleParameterScope,
|
||||||
|
workspaceActive = true,
|
||||||
sourceSettings,
|
sourceSettings,
|
||||||
rosNetwork,
|
rosNetwork,
|
||||||
simulatorWorldFile,
|
simulatorWorldFile,
|
||||||
@@ -99,7 +103,23 @@ export function useWorkspaceModuleControl({
|
|||||||
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
|
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
|
||||||
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
|
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
|
||||||
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
|
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
|
||||||
const queuedModuleNamesRef = useRef(new Set<string>())
|
const queuedModuleActionsRef = useRef(new Map<string, number>())
|
||||||
|
const moduleActionIdRef = useRef(0)
|
||||||
|
const controlGenerationRef = useRef(0)
|
||||||
|
const workspaceActiveRef = useRef(workspaceActive)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
workspaceActiveRef.current = workspaceActive
|
||||||
|
if (workspaceActive) return
|
||||||
|
|
||||||
|
// Workspace pages remain mounted while another LMS page is shown. Do not
|
||||||
|
// leave their control buttons locked behind a detached IPC request.
|
||||||
|
controlGenerationRef.current += 1
|
||||||
|
queuedModuleActionsRef.current.clear()
|
||||||
|
moduleActionQueueRef.current = Promise.resolve()
|
||||||
|
setControllingModuleNames([])
|
||||||
|
setInstallingModuleName(null)
|
||||||
|
}, [workspaceActive])
|
||||||
const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => (
|
const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => (
|
||||||
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
|
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
|
||||||
), [bulkExcludedModuleKeys])
|
), [bulkExcludedModuleKeys])
|
||||||
@@ -120,9 +140,18 @@ export function useWorkspaceModuleControl({
|
|||||||
!parameter.startsWith('runtime_wheelbase:=')
|
!parameter.startsWith('runtime_wheelbase:=')
|
||||||
&& !parameter.startsWith('runtime_max_speed:=')
|
&& !parameter.startsWith('runtime_max_speed:=')
|
||||||
&& !parameter.startsWith('runtime_max_steering_angle:=')
|
&& !parameter.startsWith('runtime_max_steering_angle:=')
|
||||||
|
&& (remoteWorkspace || (
|
||||||
|
!parameter.startsWith('sim_mode:=')
|
||||||
|
&& !parameter.startsWith('simulator:=')
|
||||||
|
))
|
||||||
))
|
))
|
||||||
))
|
))
|
||||||
if (isMkMiniModule && mkMiniProfile) {
|
if (isMkMiniModule && mkMiniProfile) {
|
||||||
|
if (!remoteWorkspace) {
|
||||||
|
// The local workspace pairs MK-mini with NAV2-Gazebo Simulator. Do not
|
||||||
|
// let the real-platform module default start hardware CAN/LiDAR here.
|
||||||
|
parameters.push('sim_mode:=true', 'simulator:=gazebo')
|
||||||
|
}
|
||||||
parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`)
|
parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`)
|
||||||
parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
|
parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
|
||||||
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
|
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
|
||||||
@@ -134,12 +163,15 @@ export function useWorkspaceModuleControl({
|
|||||||
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)
|
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)
|
||||||
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
|
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
|
||||||
}
|
}
|
||||||
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '') && mapDatabasePath) {
|
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
|
||||||
parameters.push(`database_path:=${mapDatabasePath.replace(/^\/workspace\//, '/modules/')}`)
|
const databasePath = mapDatabasePath
|
||||||
parameters.push('localization:=true')
|
?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
|
||||||
|
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
|
||||||
|
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
|
||||||
|
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
|
||||||
}
|
}
|
||||||
return parameters.join(' ')
|
return parameters.join(' ')
|
||||||
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
|
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
|
||||||
|
|
||||||
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
|
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
|
||||||
if (!module.folderName || !module.packageName || !module.launchFile) {
|
if (!module.folderName || !module.packageName || !module.launchFile) {
|
||||||
@@ -427,6 +459,7 @@ export function useWorkspaceModuleControl({
|
|||||||
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
|
||||||
|
|
||||||
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
||||||
|
if (!workspaceActiveRef.current) return
|
||||||
// The parking area only opts modules out of bulk actions; those modules
|
// The parking area only opts modules out of bulk actions; those modules
|
||||||
// must remain individually controllable while the rest are stopping.
|
// must remain individually controllable while the rest are stopping.
|
||||||
const canControlExcludedModuleDuringBulkStop = (
|
const canControlExcludedModuleDuringBulkStop = (
|
||||||
@@ -435,14 +468,22 @@ export function useWorkspaceModuleControl({
|
|||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|
||||||
|| queuedModuleNamesRef.current.has(module.name)
|
|| queuedModuleActionsRef.current.has(module.name)
|
||||||
) return
|
) return
|
||||||
|
|
||||||
queuedModuleNamesRef.current.add(module.name)
|
const actionId = ++moduleActionIdRef.current
|
||||||
|
const controlGeneration = controlGenerationRef.current
|
||||||
|
queuedModuleActionsRef.current.set(module.name, actionId)
|
||||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||||
setModuleInstallError(null)
|
setModuleInstallError(null)
|
||||||
const execute = async (): Promise<void> => {
|
const execute = async (): Promise<void> => {
|
||||||
|
const isCurrentControl = (): boolean => (
|
||||||
|
workspaceActiveRef.current
|
||||||
|
&& controlGenerationRef.current === controlGeneration
|
||||||
|
&& queuedModuleActionsRef.current.get(module.name) === actionId
|
||||||
|
)
|
||||||
try {
|
try {
|
||||||
|
if (!isCurrentControl()) return
|
||||||
const savedMapOverrides = action === 'start' || action === 'restart'
|
const savedMapOverrides = action === 'start' || action === 'restart'
|
||||||
? await prepareSavedMap?.()
|
? await prepareSavedMap?.()
|
||||||
: undefined
|
: undefined
|
||||||
@@ -462,6 +503,7 @@ export function useWorkspaceModuleControl({
|
|||||||
? await remoteModuleStart(module, savedMapOverrides)
|
? await remoteModuleStart(module, savedMapOverrides)
|
||||||
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
|
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
|
||||||
if (!result.ok) throw new Error(result.message)
|
if (!result.ok) throw new Error(result.message)
|
||||||
|
if (!isCurrentControl()) return
|
||||||
|
|
||||||
if (action === 'start' || action === 'restart') {
|
if (action === 'start' || action === 'restart') {
|
||||||
markModuleState(module, 'running')
|
markModuleState(module, 'running')
|
||||||
@@ -480,7 +522,9 @@ export function useWorkspaceModuleControl({
|
|||||||
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
|
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (!isCurrentControl()) return
|
||||||
await loadAvailableModules({ silent: true })
|
await loadAvailableModules({ silent: true })
|
||||||
|
if (!isCurrentControl()) return
|
||||||
const actionLabel = action === 'stop'
|
const actionLabel = action === 'stop'
|
||||||
? '종료되었습니다'
|
? '종료되었습니다'
|
||||||
: action === 'build'
|
: action === 'build'
|
||||||
@@ -491,6 +535,7 @@ export function useWorkspaceModuleControl({
|
|||||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[modules] ${action} failed`, error)
|
console.error(`[modules] ${action} failed`, error)
|
||||||
|
if (!isCurrentControl()) return
|
||||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||||
const actionLabel = action === 'stop'
|
const actionLabel = action === 'stop'
|
||||||
? '종료 실패'
|
? '종료 실패'
|
||||||
@@ -502,8 +547,10 @@ export function useWorkspaceModuleControl({
|
|||||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
||||||
await loadAvailableModules({ silent: true })
|
await loadAvailableModules({ silent: true })
|
||||||
} finally {
|
} finally {
|
||||||
queuedModuleNamesRef.current.delete(module.name)
|
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
|
||||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
queuedModuleActionsRef.current.delete(module.name)
|
||||||
|
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -540,14 +587,17 @@ export function useWorkspaceModuleControl({
|
|||||||
const normalizedParameters = parameters
|
const normalizedParameters = parameters
|
||||||
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
|
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
|
||||||
.filter((parameter) => parameter.name)
|
.filter((parameter) => parameter.name)
|
||||||
storeModuleParameters(makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name), normalizedParameters)
|
storeModuleParameters(
|
||||||
|
makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name, moduleParameterScope),
|
||||||
|
normalizedParameters
|
||||||
|
)
|
||||||
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
|
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
|
||||||
setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item))
|
setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[modules] update parameters failed', error)
|
console.error('[modules] update parameters failed', error)
|
||||||
setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패')
|
setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패')
|
||||||
}
|
}
|
||||||
}, [courseKey, setAvailableModules, weekNumber])
|
}, [courseKey, moduleParameterScope, setAvailableModules, weekNumber])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
installingModuleName,
|
installingModuleName,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type UseWorkspaceModuleListResult = {
|
|||||||
availableModules: ModuleRow[]
|
availableModules: ModuleRow[]
|
||||||
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
|
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
|
||||||
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
|
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
|
||||||
|
removeModule: (module: ModuleRow) => void
|
||||||
modulesLoading: boolean
|
modulesLoading: boolean
|
||||||
moduleListError: string | null
|
moduleListError: string | null
|
||||||
clearModuleListError: () => void
|
clearModuleListError: () => void
|
||||||
@@ -130,6 +131,16 @@ export function useWorkspaceModuleList({
|
|||||||
})
|
})
|
||||||
}, [moduleCacheKey])
|
}, [moduleCacheKey])
|
||||||
|
|
||||||
|
const removeModule = useCallback((module: ModuleRow): void => {
|
||||||
|
const key = moduleStateKey(module)
|
||||||
|
pendingModuleStatesRef.current.delete(key)
|
||||||
|
setAvailableModules((current) => {
|
||||||
|
const next = current.filter((item) => moduleStateKey(item) !== key)
|
||||||
|
storeRemoteModuleCache(moduleCacheKey, next)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [moduleCacheKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
|
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
|
||||||
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
|
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
|
||||||
@@ -219,6 +230,7 @@ export function useWorkspaceModuleList({
|
|||||||
availableModules,
|
availableModules,
|
||||||
setAvailableModules,
|
setAvailableModules,
|
||||||
markModuleState,
|
markModuleState,
|
||||||
|
removeModule,
|
||||||
modulesLoading,
|
modulesLoading,
|
||||||
moduleListError,
|
moduleListError,
|
||||||
clearModuleListError: () => setModuleListError(null),
|
clearModuleListError: () => setModuleListError(null),
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ type UseWorkspaceVisualizationOptions = {
|
|||||||
rosNetwork?: RosNetworkConfig | null
|
rosNetwork?: RosNetworkConfig | null
|
||||||
mapKey?: string | null
|
mapKey?: string | null
|
||||||
platform?: string | null
|
platform?: string | null
|
||||||
|
threeDimensional?: boolean
|
||||||
|
savedMapDatabasePath?: string | null
|
||||||
|
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }>
|
||||||
visualizationRuntime?: 'real' | 'simulation'
|
visualizationRuntime?: 'real' | 'simulation'
|
||||||
worldLayoutVersion?: number
|
worldLayoutVersion?: number
|
||||||
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
|
||||||
@@ -114,6 +117,9 @@ export function useWorkspaceVisualization({
|
|||||||
rosNetwork,
|
rosNetwork,
|
||||||
mapKey,
|
mapKey,
|
||||||
platform,
|
platform,
|
||||||
|
threeDimensional = false,
|
||||||
|
savedMapDatabasePath,
|
||||||
|
prepareSavedMap,
|
||||||
visualizationRuntime,
|
visualizationRuntime,
|
||||||
worldLayoutVersion,
|
worldLayoutVersion,
|
||||||
onTitlebarNotice
|
onTitlebarNotice
|
||||||
@@ -165,7 +171,10 @@ export function useWorkspaceVisualization({
|
|||||||
const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
|
||||||
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
const visualizationBackendUrl = visualizationBridgeHttpUrl
|
||||||
const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
|
const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
|
||||||
const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : ''
|
// A saved RTAB-Map database can be selected while the profile itself still
|
||||||
|
// resolves to the generic NAV2 viewer. Keep the viewer mode tied to the map,
|
||||||
|
// not only to the profile label, so 3D state is never rendered as /scan.
|
||||||
|
const pointCloudQuery = visualizationStack === 'slam3d' || threeDimensional ? '&pointCloud=1' : ''
|
||||||
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
|
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
|
||||||
const localBridgeProfile = useMemo(() => {
|
const localBridgeProfile = useMemo(() => {
|
||||||
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
|
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
|
||||||
@@ -184,7 +193,7 @@ export function useWorkspaceVisualization({
|
|||||||
}
|
}
|
||||||
}, [activeEnvironmentConfig, visualizationRuntimeStack])
|
}, [activeEnvironmentConfig, visualizationRuntimeStack])
|
||||||
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
|
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
|
||||||
? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
|
? `./slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
|
||||||
: `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
: `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -244,11 +253,15 @@ export function useWorkspaceVisualization({
|
|||||||
setVisualizationBusy(true)
|
setVisualizationBusy(true)
|
||||||
setVisualizationStatusMessage('시작 중')
|
setVisualizationStatusMessage('시작 중')
|
||||||
try {
|
try {
|
||||||
|
// Download the selected map artifacts before starting the bridge. The
|
||||||
|
// bridge only receives workspace-local paths, never an R2 URL.
|
||||||
|
const preparedMap = await prepareSavedMap?.()
|
||||||
|
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
|
||||||
const status = bridgeRunsOnPlatform
|
const status = bridgeRunsOnPlatform
|
||||||
? bridgeTarget?.host
|
? bridgeTarget?.host
|
||||||
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
|
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
|
||||||
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
|
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
|
||||||
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork)
|
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork, bridgeSavedMapDatabasePath)
|
||||||
if (!status.ok) throw new Error(status.message ?? '시작 실패')
|
if (!status.ok) throw new Error(status.message ?? '시작 실패')
|
||||||
setVisualizationRunning(Boolean(status.running))
|
setVisualizationRunning(Boolean(status.running))
|
||||||
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
|
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
|
||||||
|
|||||||
Reference in New Issue
Block a user