synchronize source..

This commit is contained in:
2026-08-11 14:23:29 +09:00
parent a88f0920cf
commit f6be625a8f
18 changed files with 1485 additions and 273 deletions
+1 -1
View File
@@ -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": {
+1 -1
View File
@@ -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/*
+396 -32
View File
@@ -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,9 +427,10 @@ 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
if not has_3d_localization:
with self._lock: with self._lock:
# Use this only until AMCL starts publishing map -> odom. It is a # Use this only until AMCL starts publishing map -> odom. It
# short-lived viewer fallback, not a replacement for live TF. # is a short-lived viewer fallback, not a replacement for TF.
self._requested_initial_pose = { self._requested_initial_pose = {
"x": float(x), "x": float(x),
"y": float(y), "y": float(y),
@@ -296,8 +440,20 @@ class SlamNode(Node):
"t": time.time(), "t": time.time(),
"expires_at": time.monotonic() + 2.0, "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")
+104 -12
View File
@@ -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,7 +3155,14 @@ 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):',
' 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}")', ' 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():',
@@ -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))
+2 -1
View File
@@ -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
+107 -72
View File
@@ -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'
: selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
? 'slam3d' ? 'slam3d'
: navigationMap
? '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}
+2 -1
View File
@@ -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,10 +547,12 @@ 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) {
queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name)) setControllingModuleNames((names) => names.filter((name) => name !== module.name))
} }
} }
}
if (action === 'stop') { if (action === 'stop') {
await execute() await execute()
@@ -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'