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",
"version": "1.1.7",
"version": "1.1.8",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
+1 -1
View File
@@ -6,7 +6,7 @@ COPY requirements.txt /app/requirements.txt
COPY bridge.py /app/bridge.py
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 && \
rm -rf /var/lib/apt/lists/*
+396 -32
View File
@@ -9,6 +9,8 @@ import os
import shutil
import sqlite3
import struct
import subprocess
import tempfile
import threading
import time
import traceback
@@ -17,7 +19,7 @@ from pathlib import Path
from typing import Optional
import rclpy
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi import FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
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 pydantic import BaseModel
from rclpy.action import ActionClient
from rclpy.duration import Duration
from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node
from rclpy.parameter import Parameter
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
from rclpy.time import Time
from sensor_msgs.msg import JointState, LaserScan, PointCloud2
from std_msgs.msg import Float64
from sensor_msgs.msg import CompressedImage, JointState, LaserScan, PointCloud2
from std_msgs.msg import Float64, String
from std_srvs.srv import Empty
from tf2_msgs.msg import TFMessage
import uvicorn
logging.basicConfig(level=logging.INFO)
_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:
@@ -159,6 +162,8 @@ class SlamNode(Node):
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.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.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
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.steering_topic = os.environ.get("STEERING_TOPIC", "/mkmini/steering_command")
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.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(
depth=1,
@@ -195,6 +202,8 @@ class SlamNode(Node):
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
self._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._map: Optional[OccupancyGrid] = None
self._plan: Optional[NavPath] = None
@@ -207,6 +216,8 @@ class SlamNode(Node):
self._amcl_pose_count = 0
self._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._map_count = 0
self._tf_count = 0
@@ -215,6 +226,8 @@ class SlamNode(Node):
self._last_amcl_pose_wall_time = None
self._last_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_map_wall_time = None
self._plan_count = 0
@@ -224,17 +237,25 @@ class SlamNode(Node):
self._navigation = {"status": "idle", "goal_id": None}
self._active_goal_handle = 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._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, 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, 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(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
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)
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, reliable_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(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(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_static", self._on_tf, tf_static_qos)
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._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
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(
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):
with self._lock:
self._odom = msg
self._odom_count += 1
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:
# AMCL transforms the initial pose at the message timestamp. On a real
# 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.
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
"""Provide a map-frame seed without overwriting live ICP odometry."""
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()
if odom_stamp:
# 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.stamp = Time().to_msg()
message.header.frame_id = frame_id
message.pose.pose.position.x = x
message.pose.pose.position.y = y
@@ -284,9 +427,10 @@ class SlamNode(Node):
message.pose.covariance[0] = 0.25
message.pose.covariance[7] = 0.25
message.pose.covariance[35] = 0.0685
if not has_3d_localization:
with self._lock:
# Use this only until AMCL starts publishing map -> odom. It is a
# short-lived viewer fallback, not a replacement for live TF.
# Use this only until AMCL starts publishing map -> odom. It
# is a short-lived viewer fallback, not a replacement for TF.
self._requested_initial_pose = {
"x": float(x),
"y": float(y),
@@ -296,8 +440,20 @@ class SlamNode(Node):
"t": time.time(),
"expires_at": time.monotonic() + 2.0,
}
# In 3D this is consumed by the localization module's local relay,
# which can call ICP odometry without crossing the DDS peer boundary.
self._initial_pose_pub.publish(message)
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:
with self._lock:
grid = self._map
@@ -369,6 +525,10 @@ class SlamNode(Node):
database_path = None
database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", ""))
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"
source_connection = None
destination_connection = None
@@ -386,6 +546,11 @@ class SlamNode(Node):
destination_connection.close()
if source_connection is not None:
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)
return {
@@ -399,6 +564,28 @@ class SlamNode(Node):
"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):
with self._lock:
self._scan = msg
@@ -423,6 +610,18 @@ class SlamNode(Node):
self._fallback_point_cloud_count += 1
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]:
with self._lock:
primary = self._point_cloud
@@ -478,6 +677,8 @@ class SlamNode(Node):
amcl_pose_last = self._last_amcl_pose_wall_time
point_cloud_last = self._last_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
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)},
"point_cloud": {
"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},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
@@ -725,8 +943,14 @@ class SlamNode(Node):
plan = self._plan
amcl_pose = self._amcl_pose
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)
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)
navigation = dict(self._navigation)
effective_map = self._effective_map(grid, scan, odom)
@@ -897,15 +1121,14 @@ class SlamNode(Node):
"yaw": euler["yaw"],
"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 = tf_snapshot.get((cloud_frame, icp_odom.header.frame_id)) if cloud_frame else None
map_to_icp_odom = resolve_2d_transform(tf_snapshot, "map", icp_odom.header.frame_id)
if map_to_icp_odom is not None:
# RTAB-Map publishes /cloud_map in map, while ICP odometry is
# expressed in icp_odom. Render both in the cloud frame.
# ICP is local odometry. RTAB-Map localization publishes the
# map -> icp_odom correction needed to render it on the saved map.
pose_3d = {
**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,
}
@@ -943,12 +1166,38 @@ class SlamNode(Node):
"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 {
"ok": True,
"topics": {
"odom": self.odom_topic,
"scan": self.scan_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,
"map": self.map_topic,
"cmd_vel": self.cmd_vel_topic,
@@ -962,11 +1211,17 @@ class SlamNode(Node):
"vehicle": vehicle,
"scan": scan_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,
"tf": {
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
},
"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(),
"plan": {
"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]:
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:
return None
@@ -998,6 +1303,9 @@ class SlamNode(Node):
prefix = ">" if cloud.is_bigendian else "<"
data = cloud.data
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):
offset = index * cloud.point_step
if offset + cloud.point_step > len(data):
@@ -1010,12 +1318,27 @@ class SlamNode(Node):
break
if math.isfinite(x) and math.isfinite(y) and math.isfinite(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,
"stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9,
"points": points,
}
if include_colors:
payload["colors"] = colors
return payload
def map_binary(self) -> Optional[bytes]:
with self._lock:
@@ -1049,7 +1372,7 @@ class SlamNode(Node):
def topic_debug(self) -> dict:
topics = {}
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
return {
@@ -1058,6 +1381,8 @@ class SlamNode(Node):
"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,
"icp_odom": self.icp_odom_topic,
"map": self.map_topic,
"cmd_vel": self.cmd_vel_topic,
@@ -1067,6 +1392,8 @@ class SlamNode(Node):
"scan_publishers": self.count_publishers(self.scan_topic),
"point_cloud_publishers": self.count_publishers(self.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),
"odom_publishers": self.count_publishers(self.odom_topic),
"map_publishers": self.count_publishers(self.map_topic),
@@ -1075,6 +1402,8 @@ class SlamNode(Node):
"scan_count": self._scan_count,
"point_cloud_count": self._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,
"odom_count": self._odom_count,
"map_count": self._map_count,
@@ -1082,6 +1411,8 @@ class SlamNode(Node):
"last_scan_wall_time": self._last_scan_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_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_odom_wall_time": self._last_odom_wall_time,
"last_map_wall_time": self._last_map_wall_time,
@@ -1155,6 +1486,36 @@ def point_cloud():
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")
def world_layout():
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")
try:
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
@@ -1239,8 +1600,11 @@ def initial_pose(payload: InitialPosePayload):
raise HTTPException(status_code=503, detail="ROS node is not ready")
if slam_node.read_only:
raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.")
slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id)
return {"ok": True}
try:
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")
+104 -12
View File
@@ -352,7 +352,11 @@ async function syncWorkspaceSavedMapArtifacts(
}
await Promise.all(artifacts.map(async (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 }
}
@@ -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(
targetInput: RemoteWorkspaceTarget,
artifacts: SavedMapArtifactDownload[],
@@ -1347,7 +1390,13 @@ async function syncRemoteSavedMapArtifacts(
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
'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',
' 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',
@@ -1460,7 +1509,7 @@ async function startRemoteVisualizationBridge(
`-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',
...(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,
'-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 [
'source /opt/ros/humble/setup.bash',
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}`
].filter(Boolean).join(' && ')
}
@@ -2739,6 +2788,7 @@ async function startVisualizationBridge(
rosDomainId?: string | null,
environmentProfile?: EnvironmentProfileConfig | null,
rosNetwork?: RosNetworkConfig | null,
savedMapDatabasePath?: string | null,
): Promise<VisualizationBridgeStatus> {
const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput)
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
@@ -2840,6 +2890,10 @@ async function startVisualizationBridge(
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
)
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)
if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) {
@@ -3101,7 +3155,14 @@ async function installRemoteWorkspaceModule(
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' 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}")',
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
' for entry in entries.splitlines():',
@@ -3512,13 +3573,13 @@ function buildNav2DockerRunScript(): string {
'esac',
'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"',
' has_database_path=false',
' for arg in "$@"; do',
' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi',
' 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" "$@"',
' fi',
'fi',
@@ -3621,11 +3682,19 @@ function buildNav2DockerBuildScript(): string {
' set -e',
' source /opt/ros/humble/setup.bash',
' 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 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 \\",
' --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\"",
''
].join('\n')
@@ -3785,6 +3854,22 @@ async function findRunningNav2SimulatorContainer(): Promise<string | null> {
}
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(
'docker',
[
@@ -3798,7 +3883,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
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 로봇 모델을 찾을 수 없습니다.')
return robotModel
@@ -3815,6 +3900,10 @@ async function setNav2SimulatorPose(
const containerName = await findRunningNav2SimulatorContainer()
if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.')
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(
'docker',
@@ -3823,7 +3912,7 @@ async function setNav2SimulatorPose(
'gz', 'model', '-m', robotModel,
'-x', String(x),
'-y', String(y),
'-z', '0.01',
'-z', spawnHeight,
'-R', '0',
'-P', '0',
'-Y', String(yaw),
@@ -5141,6 +5230,9 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => (
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[]) => (
syncRemoteSavedMapArtifacts(target, artifacts)
))
@@ -5204,7 +5296,7 @@ function registerLocalWorkspaceHandlers(): void {
return { ok: true }
})
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: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))
+2 -1
View File
@@ -220,7 +220,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
},
visualization: {
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)
},
git: {
@@ -266,6 +266,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
},
maps: {
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)
},
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,
platformEnvironmentConfigOverride,
platformOptions,
workspaceVisible,
skipInitialLoading,
visualizationOpen,
onVisualizationOpenChange,
@@ -476,6 +477,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentProfiles?: EnvironmentProfile[]
platformEnvironmentConfigOverride?: Record<string, unknown> | null
platformOptions: PlatformOption[]
workspaceVisible: boolean
skipInitialLoading?: boolean
visualizationOpen: boolean
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
@@ -527,6 +529,7 @@ function LocalPcWorkspaceMockup({
clearQaConversation
} = useQaChat({
accessToken,
courseId: courseKey,
weekNumber,
apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN,
@@ -804,6 +807,9 @@ function LocalPcWorkspaceMockup({
const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
const baseMaps = [...configuredMaps]
const usesThreeDimensionalSlam = (curriculumWeek.modules ?? []).some((module) => (
/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')
))
const platformSlamMap: WorkspaceMapOption = {
key: 'nav2-platform-slam',
platformKey: 'NAV2',
@@ -812,7 +818,7 @@ function LocalPcWorkspaceMockup({
worldFile: '',
viewerModel: 'turtlebot3_world',
remoteSlam: true,
runtimeKind: 'PLATFORM_2D',
runtimeKind: usesThreeDimensionalSlam ? 'PLATFORM_3D' : 'PLATFORM_2D',
}
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps]
const keys = new Set<string>()
@@ -821,7 +827,7 @@ function LocalPcWorkspaceMockup({
keys.add(map.key)
return true
})
}, [curriculumWeek.maps, savedSlamMaps])
}, [curriculumWeek.maps, curriculumWeek.modules, savedSlamMaps])
useEffect(() => {
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey)
@@ -839,6 +845,9 @@ function LocalPcWorkspaceMockup({
})
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey])
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0]
const prepareVisualizationSavedMapRef = useRef<(
() => Promise<{ savedMapDatabasePath?: string }>
) | null>(null)
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap?.remoteSlam === true
@@ -1004,6 +1013,11 @@ function LocalPcWorkspaceMockup({
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
platform: visualizationPlatform,
threeDimensional: Boolean(
selectedWorldMap.databasePath || selectedWorldMap.runtimeKind === 'PLATFORM_3D'
),
savedMapDatabasePath: selectedWorldMap.databasePath,
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap.remoteSlam
@@ -1023,11 +1037,16 @@ function LocalPcWorkspaceMockup({
if (initializedVisualizationMapRef.current === mapIdentity) return
initializedVisualizationMapRef.current = mapIdentity
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 nextStack = navigationMap
? 'nav2'
: selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
const nextStack = threeDimensionalMap
? 'slam3d'
: navigationMap
? 'nav2'
: 'slam'
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
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}`)
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 }>
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}`,
platformKey: 'NAV2',
name: map.name,
@@ -1124,11 +1150,12 @@ function LocalPcWorkspaceMockup({
yamlUrl: map.yamlUrl ?? undefined,
pgmUrl: map.pgmUrl ?? undefined,
previewPgm: map.previewPgm ?? undefined,
databasePath: map.databasePath ?? undefined,
databasePath,
databaseUrl: map.databaseUrl ?? undefined,
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) {
console.warn('[studio] saved SLAM maps load failed', error)
} finally {
@@ -1150,20 +1177,28 @@ function LocalPcWorkspaceMockup({
message?: string
}
if (payload?.source !== 'autonomy-slam-viewer') return
if (payload.type === 'navigation-init') {
const isPlatformMap = selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap.remoteSlam
// Only the Gazebo simulation has a model pose to reset. A platform
// map can also be opened from the local workspace, where this IPC
// would incorrectly fail because no Simulator container is running.
if (remoteWorkspace || isPlatformMap) return
void Promise.all([
window.localWorkspace.nav2.setSimulatorPose({ x: -2, y: -0.5, yaw: 0 }),
window.localWorkspace.nav2.clearRuntimeObstacles(),
]).catch((error) => {
console.warn('[studio] nav2 initialization failed', error)
onTitlebarNotice('NAV2 초기화에 실패했습니다.', 'error')
if (payload.type === 'simulator-pose-request' && payload.requestId && payload.pose) {
const reply = (ok: boolean, message?: string) => {
const target = event.source as Window | null
target?.postMessage({
source: 'autonomy-studio',
type: 'simulator-pose-result',
requestId: payload.requestId,
ok,
message,
}, '*')
}
if (remoteWorkspace) {
reply(false, '원격 플랫폼의 로봇 위치는 시각화에서 변경할 수 없습니다.')
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
}
@@ -1191,42 +1226,6 @@ function LocalPcWorkspaceMockup({
})
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') {
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
return
@@ -1234,14 +1233,33 @@ function LocalPcWorkspaceMockup({
if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return
void (async () => {
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 = [
{ name: 'yaml' as const, path: payload.map.yamlPath },
{ 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.localWorkspace.maps.readArtifacts(clonePathPreview, sourceArtifacts)
: await window.localWorkspace.maps.readArtifacts(
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
sourceArtifacts,
)
const form = new FormData()
form.set('courseId', courseKey)
form.set('week', String(weekNumber))
@@ -1249,12 +1267,12 @@ function LocalPcWorkspaceMockup({
form.set('worldFile', selectedWorldMap.worldFile)
form.set('viewerModel', selectedWorldMap.viewerModel)
form.set('runtimeKind', remoteWorkspace
? (payload.map.databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
? (databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
: 'SIMULATION')
form.set('yamlPath', payload.map.yamlPath)
form.set('pgmPath', payload.map.pgmPath)
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) => {
const binary = atob(artifact.base64)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
@@ -1281,7 +1299,7 @@ function LocalPcWorkspaceMockup({
}
window.addEventListener('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 workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
const hasRestorableWorkspaceUi = Boolean(
@@ -1290,7 +1308,10 @@ function LocalPcWorkspaceMockup({
)
const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi)
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 storedParameters = readStoredModuleParameters()
return modules.map((module) => {
@@ -1304,7 +1325,7 @@ function LocalPcWorkspaceMockup({
: defaultParameters.length ? defaultParameters : module.parameters
}
})
}, [courseKey, runtimeModules, weekNumber])
}, [courseKey, moduleParameterScope, runtimeModules, weekNumber])
const curriculumModuleKey = runtimeModules
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
.join('|')
@@ -1312,6 +1333,7 @@ function LocalPcWorkspaceMockup({
availableModules,
setAvailableModules,
markModuleState,
removeModule,
modulesLoading,
moduleListError,
clearModuleListError,
@@ -2408,16 +2430,21 @@ function LocalPcWorkspaceMockup({
artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl })
databasePath = `/workspace/maps/${databaseFileName}`
}
if (remoteWorkspace) {
const syncToPlatformWorkspace = remoteWorkspace && visualizationBridgeLocation === 'platform'
if (syncToPlatformWorkspace) {
await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts)
} else {
await window.localWorkspace.maps.sync(clonePathPreview, artifacts)
await window.localWorkspace.maps.sync(
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
artifacts,
)
}
return {
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
savedMapDatabasePath: databasePath,
}
}, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace, selectedWorldMap])
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
prepareVisualizationSavedMapRef.current = prepareSavedMap
const vehicleRuntimeProfiles = useMemo(() => {
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
platformOptions.forEach((platform) => {
@@ -2447,6 +2474,8 @@ function LocalPcWorkspaceMockup({
weekNumber,
clonePathPreview,
remoteWorkspace,
moduleParameterScope,
workspaceActive: workspaceVisible,
sourceSettings: runtimeSourceSettings,
rosNetwork: moduleRosNetworkConfig,
simulatorWorldFile: selectedWorldMap.worldFile,
@@ -2483,7 +2512,7 @@ function LocalPcWorkspaceMockup({
}
if (map.saved) {
setNav2VisualizationMode('navigation')
setVisualizationStack('nav2')
setVisualizationStack(map.databasePath ? 'slam3d' : 'nav2')
} else {
setNav2VisualizationMode('slam')
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d')
@@ -2575,7 +2604,11 @@ function LocalPcWorkspaceMockup({
delete nextRoots[module.name]
return nextRoots
})
await loadAvailableModules()
if (module.sourceType === 'CUSTOM') {
removeModule(module)
} else {
markModuleState(module, 'uninstalled')
}
onTitlebarNotice(options.deleteRepository ? `[${module.name}] 모듈과 저장소가 삭제되었습니다` : `[${module.name}] 삭제되었습니다`, 'info')
} catch (error) {
console.error('[modules] delete failed', error)
@@ -4178,6 +4211,7 @@ export default function App(): React.JSX.Element {
deleteAiConversation: deleteGuideAiConversation
} = useQaChat({
accessToken: authSession?.accessToken ?? '',
courseId: selectedCourseId,
weekNumber: 0,
globalScope: true,
apiBaseUrl: LMS_API_BASE_URL,
@@ -5605,6 +5639,7 @@ export default function App(): React.JSX.Element {
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
platformOptions={platformOptions}
workspaceVisible={localPcLayoutOpen}
skipInitialLoading={skipWorkspaceInitialLoading}
visualizationOpen={workspaceVisualizationOpen}
onVisualizationOpenChange={setWorkspaceVisualizationOpen}
+2 -1
View File
@@ -235,7 +235,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | null
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
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null
@@ -280,6 +280,7 @@ interface Window {
}
maps: {
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 }>
}
modules: {
@@ -338,7 +338,7 @@ function WorkspaceSourceControlView({
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(' ')}
>
파일 열기
열기
</button>
)}
</div>
@@ -212,6 +212,12 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
{maps.map((map) => {
const pending = map.key === pendingMap?.key
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 (
<button
key={map.key}
@@ -224,7 +230,15 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
>
<WorkspaceMapPreview map={map} selected={pending} />
<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>
{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 {
return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}`
export function makeModuleParameterKey(
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 = {
accessToken: string
courseId: string
weekNumber: number
globalScope?: boolean
apiBaseUrl: string
@@ -73,6 +74,7 @@ const qaVisibleMessageLimit = 20
export function useQaChat({
accessToken,
courseId,
weekNumber,
globalScope = false,
apiBaseUrl,
@@ -100,9 +102,12 @@ export function useQaChat({
const aiRevealCancelRef = useRef(false)
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
const normalizedCourseId = courseId.trim()
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
const chatAvailable = globalScope || validChatWeek !== null
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : ''
const chatAvailable = Boolean(normalizedCourseId) && (globalScope || validChatWeek !== null)
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 requestAccessToken = accessToken ? await getFreshAccessToken() : null
@@ -166,7 +171,7 @@ export function useQaChat({
let cancelled = false
setAiMessagesLoading(true)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=ai&includeArchived=true`)
.then((payload) => {
if (cancelled) return
const conversations = payload.threads.map((thread) => ({
@@ -191,7 +196,7 @@ export function useQaChat({
})
setTeacherMessagesLoading(true)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=teacher`)
.then((payload) => {
if (cancelled) return
const thread = payload.threads[0]
@@ -211,7 +216,7 @@ export function useQaChat({
return () => {
cancelled = true
}
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery])
}, [accessToken, chatAvailable, chatQueryString, chatRefreshSignal])
useEffect(() => {
const target = workspaceChatScrollRef.current
@@ -264,7 +269,7 @@ export function useQaChat({
setAiPending(false)
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
method: 'POST',
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {})
body: JSON.stringify({ courseId: normalizedCourseId, ...(validChatWeek ? { week: validChatWeek } : {}) })
}).catch(() => {})
setAiMessages((messages) => [
...messages,
@@ -294,7 +299,7 @@ export function useQaChat({
try {
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
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) => (
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', {
method: 'POST',
body: JSON.stringify({
courseId: normalizedCourseId,
content,
...(validChatWeek ? { week: validChatWeek } : {}),
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
@@ -429,7 +435,7 @@ export function useQaChat({
setAiTypingMessageId(null)
}
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, {
await requestChatApi<{ deletedCount: number }>(`/chat/threads?${chatQueryString}&mode=${qaMode}`, {
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 { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
@@ -36,6 +36,7 @@ export function useSourceControl({
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
const [gitSubmitting, setGitSubmitting] = useState(false)
const gitFileSelectionRequestRef = useRef(0)
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).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 target = { moduleName, filePath }
setSelectedGitTarget(target)
await refreshGitStatuses(target)
}, [refreshGitStatuses])
const cachedStatus = gitModuleStatuses[moduleName]
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> => {
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 type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
@@ -40,6 +40,8 @@ type UseWorkspaceModuleControlOptions = {
weekNumber: number
clonePathPreview: string
remoteWorkspace: boolean
moduleParameterScope: string
workspaceActive?: boolean
sourceSettings: StudioSourceSettings
rosNetwork?: RosNetworkConfig | null
simulatorWorldFile?: string | null
@@ -80,6 +82,8 @@ export function useWorkspaceModuleControl({
weekNumber,
clonePathPreview,
remoteWorkspace,
moduleParameterScope,
workspaceActive = true,
sourceSettings,
rosNetwork,
simulatorWorldFile,
@@ -99,7 +103,23 @@ export function useWorkspaceModuleControl({
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
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 => (
bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
), [bulkExcludedModuleKeys])
@@ -120,9 +140,18 @@ export function useWorkspaceModuleControl({
!parameter.startsWith('runtime_wheelbase:=')
&& !parameter.startsWith('runtime_max_speed:=')
&& !parameter.startsWith('runtime_max_steering_angle:=')
&& (remoteWorkspace || (
!parameter.startsWith('sim_mode:=')
&& !parameter.startsWith('simulator:=')
))
))
))
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_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
@@ -134,12 +163,15 @@ export function useWorkspaceModuleControl({
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
}
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '') && mapDatabasePath) {
parameters.push(`database_path:=${mapDatabasePath.replace(/^\/workspace\//, '/modules/')}`)
parameters.push('localization:=true')
const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
const databasePath = mapDatabasePath
?? (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(' ')
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
}, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
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])
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
// must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = (
@@ -435,14 +468,22 @@ export function useWorkspaceModuleControl({
)
if (
(!canControlExcludedModuleDuringBulkStop && installingModuleName)
|| queuedModuleNamesRef.current.has(module.name)
|| queuedModuleActionsRef.current.has(module.name)
) 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]))
setModuleInstallError(null)
const execute = async (): Promise<void> => {
const isCurrentControl = (): boolean => (
workspaceActiveRef.current
&& controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId
)
try {
if (!isCurrentControl()) return
const savedMapOverrides = action === 'start' || action === 'restart'
? await prepareSavedMap?.()
: undefined
@@ -462,6 +503,7 @@ export function useWorkspaceModuleControl({
? await remoteModuleStart(module, savedMapOverrides)
: 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 (!isCurrentControl()) return
if (action === 'start' || action === 'restart') {
markModuleState(module, 'running')
@@ -480,7 +522,9 @@ export function useWorkspaceModuleControl({
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
}
}
if (!isCurrentControl()) return
await loadAvailableModules({ silent: true })
if (!isCurrentControl()) return
const actionLabel = action === 'stop'
? '종료되었습니다'
: action === 'build'
@@ -491,6 +535,7 @@ export function useWorkspaceModuleControl({
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
} catch (error) {
console.error(`[modules] ${action} failed`, error)
if (!isCurrentControl()) return
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop'
? '종료 실패'
@@ -502,10 +547,12 @@ export function useWorkspaceModuleControl({
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
await loadAvailableModules({ silent: true })
} 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))
}
}
}
if (action === 'stop') {
await execute()
@@ -540,14 +587,17 @@ export function useWorkspaceModuleControl({
const normalizedParameters = parameters
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
.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)
setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item))
} catch (error) {
console.error('[modules] update parameters failed', error)
setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패')
}
}, [courseKey, setAvailableModules, weekNumber])
}, [courseKey, moduleParameterScope, setAvailableModules, weekNumber])
return {
installingModuleName,
@@ -23,6 +23,7 @@ type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
removeModule: (module: ModuleRow) => void
modulesLoading: boolean
moduleListError: string | null
clearModuleListError: () => void
@@ -130,6 +131,16 @@ export function useWorkspaceModuleList({
})
}, [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(() => {
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
@@ -219,6 +230,7 @@ export function useWorkspaceModuleList({
availableModules,
setAvailableModules,
markModuleState,
removeModule,
modulesLoading,
moduleListError,
clearModuleListError: () => setModuleListError(null),
@@ -73,6 +73,9 @@ type UseWorkspaceVisualizationOptions = {
rosNetwork?: RosNetworkConfig | null
mapKey?: string | null
platform?: string | null
threeDimensional?: boolean
savedMapDatabasePath?: string | null
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }>
visualizationRuntime?: 'real' | 'simulation'
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
@@ -114,6 +117,9 @@ export function useWorkspaceVisualization({
rosNetwork,
mapKey,
platform,
threeDimensional = false,
savedMapDatabasePath,
prepareSavedMap,
visualizationRuntime,
worldLayoutVersion,
onTitlebarNotice
@@ -165,7 +171,10 @@ export function useWorkspaceVisualization({
const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
const visualizationBackendUrl = visualizationBridgeHttpUrl
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 localBridgeProfile = useMemo(() => {
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
@@ -184,7 +193,7 @@ export function useWorkspaceVisualization({
}
}, [activeEnvironmentConfig, visualizationRuntimeStack])
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)}`
useEffect(() => {
@@ -244,11 +253,15 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중')
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
? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
: { 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 ?? '시작 실패')
setVisualizationRunning(Boolean(status.running))
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'