Compare commits

..

3 Commits

Author SHA1 Message Date
chyoo c010d0c913 add commits.. 2026-08-12 14:46:39 +09:00
chyoo f6be625a8f synchronize source.. 2026-08-11 14:23:29 +09:00
chyoo a88f0920cf feat(visualization): render vehicle steering feedback 2026-08-10 09:11:16 +09:00
26 changed files with 2019 additions and 521 deletions
+2 -2
View File
@@ -4,9 +4,9 @@ VITE_LAUNCHER_API_BASE_URL=http://localhost:4200/api
# Electron main process runtime overrides
LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge
LOCAL_NAV2_BRIDGE_IMAGE=nav2-ros-bridge
LOCAL_NAV2_RUNTIME_IMAGE=nav2-slam:humble
LOCAL_NAV2_RUNTIME_IMAGE=future-nav2-gazebo:humble
LOCAL_SLAM_BRIDGE_IMAGE=slam-ros-bridge
LOCAL_SLAM_RUNTIME_IMAGE=nav2-slam:humble
LOCAL_SLAM_RUNTIME_IMAGE=future-nav2-gazebo:humble
# Remote Jetson runtime overrides
JETSON_AGENT_URL=http://100.64.0.2:8787
+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": {
-41
View File
@@ -1,41 +0,0 @@
FROM future-nav2:humble
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive \
ROS_DISTRO=humble \
TURTLEBOT3_MODEL=burger \
NAV2_WORKSPACE=/workspace/nav2-slam_ws
# Nav2 and SLAM Toolbox are provided by the base image. Module workspaces may
# override those packages with local sources, while Gazebo and TurtleBot3 are
# added here for the source-based simulator modules.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
git \
python3-argcomplete \
python3-colcon-common-extensions \
python3-rosdep \
python3-vcstool \
python3-pip \
ros-humble-gazebo-plugins \
ros-humble-gazebo-ros-pkgs \
ros-humble-turtlebot3-description \
ros-humble-turtlebot3-gazebo \
ros-humble-turtlebot3-msgs \
ros-humble-xacro \
ros-humble-tf-transformations \
&& rm -rf /var/lib/apt/lists/* \
&& rosdep init 2>/dev/null || true
RUN rosdep update
COPY docker-entrypoint.sh /usr/local/bin/studio-nav2-entrypoint
RUN chmod +x /usr/local/bin/studio-nav2-entrypoint \
&& mkdir -p "${NAV2_WORKSPACE}/src"
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/studio-nav2-entrypoint"]
CMD ["bash"]
-34
View File
@@ -1,34 +0,0 @@
# Nav2 Development Image
`nav2-dev:humble` is the source-build runtime for Nav2 learning modules. It
contains ROS 2 Humble, Gazebo, colcon, and rosdep, but intentionally does not
contain Nav2, TurtleBot3, or SLAM Toolbox source code.
Build it from this directory:
```bash
./build.sh
```
Mount a composed module workspace and build its sources:
```bash
docker run --rm -it --network host \
-e DISPLAY="$DISPLAY" \
-e TURTLEBOT3_MODEL=burger \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v "$HOME/autonomy-studio/workspaces/nav2-slam_ws:/workspace/nav2-slam_ws" \
nav2-dev:humble bash
cd "$NAV2_WORKSPACE"
rosdep install --from-paths src --ignore-src -r -y
colcon build
```
The composed workspace must contain all direct source dependencies. For Nav2,
include its shared packages such as `nav2_common`, `nav2_util`, and `nav2_msgs`
alongside the module packages being built.
On later container starts, the entrypoint automatically sources
`/workspace/nav2-slam_ws/install/setup.bash`, so source-built packages override
the base ROS installation.
-4
View File
@@ -1,4 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
docker build -t nav2-dev:humble "$(cd "$(dirname "$0")" && pwd)"
@@ -1,12 +0,0 @@
#!/usr/bin/env bash
set -e
source "/opt/ros/${ROS_DISTRO}/setup.bash"
# A prior colcon build takes precedence over apt packages when the workspace
# is mounted. This makes source edits immediately available to ROS commands.
if [[ -f "${NAV2_WORKSPACE}/install/setup.bash" ]]; then
source "${NAV2_WORKSPACE}/install/setup.bash"
fi
exec "$@"
+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/*
+561 -31
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,19 +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 LaserScan, PointCloud2
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:
@@ -129,6 +133,10 @@ class VelocityPayload(BaseModel):
angular: float = 0.0
class SteeringPayload(BaseModel):
angle: float
class NavigationGoalPayload(BaseModel):
x: float
y: float
@@ -154,12 +162,19 @@ 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")
self.plan_topic = os.environ.get("PLAN_TOPIC", "/plan")
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,
@@ -187,15 +202,22 @@ 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
self._joint_positions = {}
self._steering_feedback = None
self._simulator_steering_angle = None
self._tf = {}
self._odom_count = 0
self._scan_count = 0
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
@@ -204,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
@@ -212,52 +236,186 @@ class SlamNode(Node):
self._topic_status_last_refresh = 0.0
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)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, best_effort_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(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
@@ -269,8 +427,33 @@ 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 TF.
self._requested_initial_pose = {
"x": float(x),
"y": float(y),
"z": 0.0,
"yaw": float(yaw),
"frame_id": frame_id or "map",
"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
@@ -342,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
@@ -359,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 {
@@ -372,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
@@ -396,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
@@ -425,6 +651,22 @@ class SlamNode(Node):
self._plan_count += 1
self._last_plan_wall_time = time.time()
def _clear_plan_locked(self):
"""Discard a path that belongs to a completed or replaced navigation goal."""
self._plan = None
self._last_plan_wall_time = None
def _on_joint_states(self, msg: JointState):
with self._lock:
self._joint_positions = {
name: position
for name, position in zip(msg.name, msg.position)
}
def _on_steering_feedback(self, msg: Float64):
with self._lock:
self._steering_feedback = float(msg.data)
def topic_status(self) -> dict:
now = time.time()
with self._lock:
@@ -435,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
@@ -457,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},
@@ -563,6 +824,21 @@ class SlamNode(Node):
msg.angular.z = float(angular)
self._cmd_vel_pub.publish(msg)
def set_simulator_steering(self, angle: float) -> None:
if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
if not math.isfinite(angle):
raise RuntimeError("조향각이 올바르지 않습니다.")
steering_angle = max(-0.5, min(0.5, float(angle)))
command = Float64()
command.data = steering_angle
with self._lock:
self._simulator_steering_angle = steering_angle
self._steering_pub.publish(command)
drive_command = Twist()
drive_command.angular.z = steering_angle
self._cmd_vel_pub.publish(drive_command)
def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.")
@@ -596,6 +872,7 @@ class SlamNode(Node):
goal_id = bytes(goal_handle.goal_id.uuid).hex()
with self._lock:
self._clear_plan_locked()
self._navigation = {"status": "running", "goal_id": goal_id}
self._active_goal_handle = goal_handle
goal_handle.get_result_async().add_done_callback(
@@ -631,11 +908,13 @@ class SlamNode(Node):
return
self._navigation = {**self._navigation, "status": result_status}
self._active_goal_handle = None
self._clear_plan_locked()
def cancel_navigation(self, wait: bool = False) -> bool:
with self._lock:
goal_handle = self._active_goal_handle
self._navigation = {**self._navigation, "status": "canceled"}
self._clear_plan_locked()
if goal_handle is None:
return True
@@ -662,6 +941,16 @@ class SlamNode(Node):
scan = self._scan
grid = self._map
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)
@@ -671,6 +960,7 @@ class SlamNode(Node):
pose_map = None
scan_pose = None
scan_pose_map = None
vehicle = {}
if odom is not None:
pos = odom.pose.pose.position
ori = odom.pose.pose.orientation
@@ -703,6 +993,10 @@ class SlamNode(Node):
"source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
}
# map -> base is the live localization transform and must win over
# /amcl_pose. AMCL may publish a pose only when it changes, while
# TF continues updating with odometry; preferring the message made
# the viewer appear to stop between AMCL updates.
map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
if map_to_base is not None:
pose_map = {
@@ -712,10 +1006,79 @@ class SlamNode(Node):
"source_frame_id": odom.header.frame_id,
"t": pose["t"],
}
elif amcl_pose is not None and amcl_pose.header.frame_id.lstrip("/") == "map":
amcl_position = amcl_pose.pose.pose.position
pose_map = {
"x": amcl_position.x,
"y": amcl_position.y,
"z": amcl_position.z,
"yaw": yaw_from_quaternion(amcl_pose.pose.pose.orientation),
"frame_id": "map",
"child_frame_id": odom.child_frame_id,
"source_frame_id": odom.header.frame_id,
"t": amcl_pose.header.stamp.sec + amcl_pose.header.stamp.nanosec * 1e-9,
}
elif (
requested_initial_pose
and requested_initial_pose["frame_id"].lstrip("/") == "map"
and time.monotonic() <= requested_initial_pose.get("expires_at", 0.0)
):
pose_map = {
**requested_initial_pose,
"child_frame_id": odom.child_frame_id,
"source_frame_id": odom.header.frame_id,
}
# The Gazebo MK-mini uses independent front steering joints. Keep
# both values so the viewer renders the Ackermann inner/outer turn.
steering_left_angle = next(
(
position
for name, position in joint_positions.items()
if name == "front_left_visual_steering_joint" or name.endswith("/front_left_visual_steering_joint")
),
None,
)
steering_right_angle = next(
(
position
for name, position in joint_positions.items()
if name == "front_right_visual_steering_joint" or name.endswith("/front_right_visual_steering_joint")
),
None,
)
steering_angle = None
if steering_left_angle is not None and steering_right_angle is not None:
vehicle["steering_left_angle"] = steering_left_angle
vehicle["steering_right_angle"] = steering_right_angle
steering_angle = (steering_left_angle + steering_right_angle) * 0.5
else:
steering_angle = next(
(
position
for name, position in joint_positions.items()
if name == "front_steering_joint" or name.endswith("/front_steering_joint")
),
None,
)
if steering_angle is None and steering_feedback is not None:
steering_angle = steering_feedback
if steering_angle is None:
front_fork = resolve_2d_transform(
tf_snapshot,
odom.child_frame_id or "base_link",
"front_fork",
)
steering_angle = front_fork["yaw"] if front_fork is not None else None
if steering_angle is None:
steering_angle = self._simulator_steering_angle
if steering_angle is not None:
vehicle["steering_angle"] = steering_angle
if scan is not None and pose_map is not None:
# Resolve map -> sensor directly through the published TF graph so
# the scan uses the same localization correction as the map.
# Prefer direct map -> sensor TF for the current scan. It carries
# the current odometry timestamp and keeps the laser synchronized
# with the moving robot.
map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
if map_to_scan is not None:
scan_pose_map = {
@@ -726,6 +1089,21 @@ class SlamNode(Node):
"source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
}
else:
base_to_scan = resolve_2d_transform(
tf_snapshot,
odom.child_frame_id if odom is not None else "base_link",
scan.header.frame_id,
)
if base_to_scan is not None:
scan_pose_map = {
**compose_2d(pose_map, base_to_scan),
"roll": 0.0,
"pitch": 0.0,
"frame_id": "map",
"source_frame_id": scan.header.frame_id,
"t": pose_map["t"],
}
pose_3d = None
if icp_odom is not None:
@@ -743,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,
}
@@ -789,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,
@@ -805,13 +1208,20 @@ class SlamNode(Node):
"scan_pose": scan_pose,
"scan_pose_map": scan_pose_map,
"pose_3d": pose_3d,
"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,
@@ -828,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
@@ -843,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):
@@ -855,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:
@@ -894,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 {
@@ -903,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,
@@ -912,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),
@@ -920,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,
@@ -927,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,
@@ -1000,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"))
@@ -1017,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
@@ -1036,6 +1552,17 @@ def cmd_vel(payload: VelocityPayload):
return {"ok": True}
@app.post("/steering")
def steering(payload: SteeringPayload):
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
try:
slam_node.set_simulator_steering(payload.angle)
except RuntimeError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
return {"ok": True}
@app.post("/stop")
def stop():
if not slam_node:
@@ -1073,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")
+222 -78
View File
@@ -26,10 +26,10 @@ const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
const nav2DockerImage = 'nav2-dev:humble'
const nav2DockerImage = 'future-nav2-gazebo:humble'
const autowareRosBridgeImage = 'autoware-ros-bridge'
const slamRosBridgeImage = 'slam-ros-bridge'
const slamSimulationRuntimeImage = 'nav2-slam:humble'
const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble'
const slamSimulationRuntimeContainerName = 'nav2-slam'
const rosBridgePort = 8765
const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code')
@@ -199,6 +199,14 @@ type RuntimeImageArtifact = {
required?: boolean | null
}
const defaultNav2GazeboRuntimeImage: RuntimeImageArtifact = {
image: 'future-nav2-gazebo:humble',
url: 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/gazebo/latest/future-nav2-gazebo-humble.tar.zst',
fileName: 'future-nav2-gazebo-humble.tar.zst',
checksumSha256: '54230e039c1fa5dc82615cef3828c5acae7b8bea8e921b70cfdc5351d8301644',
required: true,
}
type ModuleInstallOptions = {
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
runtimeImages?: RuntimeImageArtifact[] | null
@@ -236,6 +244,10 @@ type WorldLayoutObject = {
type WorldLayout = {
version: 1
bounds: {
width: number
depth: number
}
objects: WorldLayoutObject[]
}
@@ -255,6 +267,7 @@ type SavedMapArtifactDownload = {
function defaultWorldLayout(): WorldLayout {
return {
version: 1,
bounds: { width: 10, depth: 10 },
objects: []
}
}
@@ -278,7 +291,14 @@ function normalizeWorldLayout(value: unknown): WorldLayout {
size: tuple(object.size, [1, 1, 1])
}]
}).filter((object) => !['wall-1', 'pillar-1'].includes(object.id))
return { version: 1, objects }
const boundsInput = (value as { bounds?: unknown }).bounds
const bounds = boundsInput && typeof boundsInput === 'object'
? boundsInput as { width?: unknown; depth?: unknown }
: {}
const size = (input: unknown, fallback: number): number => (
typeof input === 'number' && Number.isFinite(input) ? Math.min(40, Math.max(2, input)) : fallback
)
return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects }
}
function worldLayoutPath(workspaceRoot?: string | null): string {
@@ -352,7 +372,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 }
}
@@ -762,6 +786,17 @@ function resolveNav2RuntimeImage(
return nav2Image || nav2DockerImage
}
function requiredRuntimeImages(
runtimeImages: RuntimeImageArtifact[] | null | undefined,
runtimeImage: string,
): RuntimeImageArtifact[] {
const images = (runtimeImages ?? []).filter((image) => image?.image?.trim())
if (runtimeImage !== nav2DockerImage || images.some((image) => image.image?.trim() === runtimeImage)) {
return images
}
return [...images, defaultNav2GazeboRuntimeImage]
}
async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise<string> {
try {
const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown }
@@ -1149,7 +1184,11 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null,
const targetPath = artifactCachePath(artifactUrl, fileName)
const expected = checksumSha256?.trim().toLowerCase()
if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath)
// Module artifact URLs are intentionally stable (for example, `latest`).
// Do not reuse a same-named local file, or a source update can run with an
// older install tree even though R2 already contains the current artifact.
await rm(targetPath, { force: true })
await downloadArtifact(artifactUrl, targetPath)
if (expected) {
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
if (actual !== expected) {
@@ -1335,6 +1374,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 +1425,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 +1544,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 +2534,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(' && ')
}
@@ -2458,7 +2542,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string {
function normalizeVisualizationStack(value?: string | null): VisualizationStack {
if (value === 'slam') return 'slam'
if (value === 'slam3d') return 'slam3d'
return value === 'nav2' ? 'nav2' : 'autoware'
return value === 'nav2' || value === 'local' || value === 'platform' ? 'nav2' : 'autoware'
}
async function readContainerEnvironmentValue(containerName: string, key: string): Promise<string | null> {
@@ -2673,10 +2757,7 @@ function withRuntimeProfileDefaults(
if (!profile) return profile
const target = normalizeRuntimeTarget(profile.target)
const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}`
const dedicatedReadOnlyBridge = profile.type?.toLowerCase() === 'real_slam_3d'
const bridgeImage = dedicatedReadOnlyBridge
? profile.bridge?.image
: envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image
const bridgeImage = envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image
const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`)
const agentUrl = target === 'remote-jetson'
? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url
@@ -2739,8 +2820,9 @@ 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 stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
const target = normalizeRuntimeTarget(resolvedProfile?.target)
const bridge = resolvedProfile?.bridge
@@ -2819,7 +2901,7 @@ async function startVisualizationBridge(
'-e',
`ROS_DOMAIN_ID=${requestedRosDomainId}`,
'-e',
'ROS_LOCALHOST_ONLY=0',
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
'-e',
`AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`,
'-e',
@@ -2840,6 +2922,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)) {
@@ -3096,13 +3182,21 @@ async function installRemoteWorkspaceModule(
' if not url: continue',
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"',
' path = os.path.join(cache, name)',
' if not os.path.exists(path):',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' # Stable latest URLs must not reuse a same-named local artifact.',
' if os.path.exists(path): os.unlink(path)',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
' 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():',
' entry = entry.strip()',
@@ -3470,6 +3564,7 @@ function buildNav2DockerRunScript(): string {
'GPU_ENABLED=${6:-false}',
'shift 6 || true',
'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}',
'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}',
'LOCALIZATION_MODE=slam',
'filtered_args=()',
'for arg in "$@"; do',
@@ -3512,13 +3607,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',
@@ -3530,6 +3625,7 @@ function buildNav2DockerRunScript(): string {
' --rm -d --network host --ipc=host',
' --name "$CONTAINER_NAME"',
' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"',
' -e "ROS_LOCALHOST_ONLY=$ROS_LOCALHOST_ONLY_VALUE"',
' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"',
' -e "MODULE_ROOT=/modules"',
' -e "MODULE_FOLDER=$MODULE_FOLDER"',
@@ -3542,7 +3638,7 @@ function buildNav2DockerRunScript(): string {
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
'fi',
'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then',
' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e ROS_LOCALHOST_ONLY=0)',
' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml")',
'fi',
'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi',
'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then',
@@ -3621,11 +3717,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 +3889,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 +3918,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 +3935,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 +3947,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),
@@ -3910,7 +4034,6 @@ async function startWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
await prepareRuntimeImages(runtimeImages)
if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets()
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId)
@@ -3920,6 +4043,7 @@ async function startWorkspaceModule(
moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
)
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
await runCommand(
'bash',
[
@@ -3933,12 +4057,16 @@ async function startWorkspaceModule(
...splitRosParams(rosParams)
],
undefined,
fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : undefined,
{
ROS_LOCALHOST_ONLY: rosNetworkEnabled(rosNetwork) ? '0' : '1',
...(fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : {}),
},
30000
)
return { ok: true, message: '실행 요청을 보냈습니다.' }
}
await prepareRuntimeImages(runtimeImages)
await ensureStudioRuntimeAssets()
const scriptPath = resolveAutowareDockerRunScript()
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
@@ -3993,7 +4121,6 @@ async function buildWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
await prepareRuntimeImages(runtimeImages)
if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets()
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
@@ -4001,6 +4128,7 @@ async function buildWorkspaceModule(
moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
)
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
await runCommand(
'bash',
[
@@ -4017,6 +4145,7 @@ async function buildWorkspaceModule(
return { ok: true, message: '빌드가 완료되었습니다.' }
}
await prepareRuntimeImages(runtimeImages)
await runCommand(
'docker',
[
@@ -4035,6 +4164,62 @@ async function buildWorkspaceModule(
return { ok: true, message: '빌드가 완료되었습니다.' }
}
async function prepareRemoteRuntimeImages(
target: RemoteWorkspaceTarget,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<void> {
const artifacts = (runtimeImages ?? [])
.filter((image) => image?.image?.trim())
.map((image) => ({
image: image.image?.trim() ?? '',
url: image.url?.trim() ?? '',
fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '',
checksumSha256: image.checksumSha256?.trim() ?? '',
required: Boolean(image.required),
}))
const artifactsJson = JSON.stringify(artifacts)
await runSsh(
target,
[
'set -e',
`RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(artifactsJson))} | base64 -d)"`,
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
'mkdir -p "$ARTIFACT_CACHE"',
'LOCK_FILE="$ARTIFACT_CACHE/runtime-images.lock"',
'flock "$LOCK_FILE" python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'',
'import json, os, subprocess, sys',
'images = json.loads(sys.argv[1] or "[]")',
'cache = sys.argv[2]',
'for item in images:',
' image = (item.get("image") or "").strip()',
' if not image: continue',
' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0',
' if exists: continue',
' url = (item.get("url") or "").strip()',
' if not url:',
' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")',
' continue',
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
' path = os.path.join(cache, name)',
' if os.path.exists(path): os.unlink(path)',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
' import shlex',
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
' else:',
' subprocess.run(["docker", "load", "-i", path], check=True)',
'PY'
].join('\n'),
900000,
)
}
async function startRemoteWorkspaceModule(
targetInput: RemoteWorkspaceTarget,
moduleName: string,
@@ -4051,17 +4236,6 @@ async function startRemoteWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
const runtimeImageArtifacts = (runtimeImages ?? [])
.filter((image) => image?.image?.trim())
.map((image) => ({
image: image.image?.trim() ?? '',
url: image.url?.trim() ?? '',
fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '',
checksumSha256: image.checksumSha256?.trim() ?? '',
required: Boolean(image.required),
}))
const runtimeImageJson = JSON.stringify(runtimeImageArtifacts)
const existsOutput = await runSshOutput(target, [
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
@@ -4083,43 +4257,7 @@ async function startRemoteWorkspaceModule(
? await ensureRemoteNav2RuntimeAssets(target)
: await ensureRemoteStudioRuntimeAssets(target)
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
await runSsh(
target,
[
'set -e',
`RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(runtimeImageJson))} | base64 -d)"`,
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
'mkdir -p "$ARTIFACT_CACHE"',
'python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'',
'import json, os, subprocess, sys',
'images = json.loads(sys.argv[1] or "[]")',
'cache = sys.argv[2]',
'for item in images:',
' image = (item.get("image") or "").strip()',
' if not image: continue',
' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0',
' if exists: continue',
' url = (item.get("url") or "").strip()',
' if not url:',
' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")',
' continue',
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
' path = os.path.join(cache, name)',
' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:',
' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
' import shlex',
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
' else:',
' subprocess.run(["docker", "load", "-i", path], check=True)',
'PY'
].join('\n'),
900000
)
await prepareRemoteRuntimeImages(target, runtimeImages)
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
if (currentStatus.trim()) {
@@ -4168,9 +4306,11 @@ async function startRemoteWorkspaceModule(
...expandRemotePathVar('ROOT'),
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
'export FASTDDS_CONFIG_PATH',
'export ROS_LOCALHOST_ONLY',
`bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} "\${EXTRA_ARGS[@]}"`
].join('\n')
: [
@@ -4209,6 +4349,7 @@ async function buildRemoteWorkspaceModule(
targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages),
)
await prepareRemoteRuntimeImages(target, runtimeImages)
await runSsh(target, [
'set -e',
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
@@ -5141,6 +5282,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 +5348,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
+173 -97
View File
@@ -43,7 +43,7 @@ import WorkspaceLogView from './features/workspace/WorkspaceLogView'
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
import ModuleListView from './features/workspace/ModuleListView'
import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -85,6 +85,11 @@ type IdeActivity = WorkspaceActivity
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
function isRemoteEnvironmentProfile(profile: EnvironmentProfile): boolean {
const target = profile.config?.target
return target === 'remote-jetson' || target === 'platform'
}
function resolveModuleRuntimeImages(
profiles: EnvironmentProfile[],
remoteWorkspace: boolean,
@@ -93,7 +98,7 @@ function resolveModuleRuntimeImages(
if (!remoteWorkspace) return activeRuntimeImages
const images = profiles
.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
.filter(isRemoteEnvironmentProfile)
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
.filter((image): image is RuntimeImageArtifact => (
typeof image === 'object'
@@ -428,6 +433,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentProfiles,
platformEnvironmentConfigOverride,
platformOptions,
workspaceVisible,
skipInitialLoading,
visualizationOpen,
onVisualizationOpenChange,
@@ -476,6 +482,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentProfiles?: EnvironmentProfile[]
platformEnvironmentConfigOverride?: Record<string, unknown> | null
platformOptions: PlatformOption[]
workspaceVisible: boolean
skipInitialLoading?: boolean
visualizationOpen: boolean
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
@@ -527,6 +534,7 @@ function LocalPcWorkspaceMockup({
clearQaConversation
} = useQaChat({
accessToken,
courseId: courseKey,
weekNumber,
apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN,
@@ -803,18 +811,33 @@ function LocalPcWorkspaceMockup({
const sessionWorldMaps = useMemo(() => {
const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
const baseMaps = [...configuredMaps]
const platformSlamMap: WorkspaceMapOption = {
key: 'nav2-platform-slam',
platformKey: 'NAV2',
name: '새 SLAM',
description: '플랫폼 LiDAR로 새 지도를 생성합니다.',
worldFile: '',
viewerModel: 'turtlebot3_world',
remoteSlam: true,
runtimeKind: 'PLATFORM_2D',
}
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps]
const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap
? [...configuredMaps]
: [...configuredMaps, customMap]
const platformSlamMaps: WorkspaceMapOption[] = [
{
key: 'nav2-platform-slam-2d',
platformKey: 'NAV2',
name: '새 2D SLAM',
description: '2D LiDAR로 새 지도를 생성합니다.',
worldFile: '',
viewerModel: 'turtlebot3_world',
remoteSlam: true,
runtimeKind: 'PLATFORM_2D',
},
{
key: 'nav2-platform-slam-3d',
platformKey: 'NAV2',
name: '새 3D SLAM',
description: '3D LiDAR 포인트 클라우드로 새 지도를 생성합니다.',
worldFile: '',
viewerModel: 'turtlebot3_world',
remoteSlam: true,
runtimeKind: 'PLATFORM_3D',
},
]
const maps = [...baseMaps, ...platformSlamMaps, ...savedSlamMaps]
const keys = new Set<string>()
return maps.filter((map) => {
if (keys.has(map.key)) return false
@@ -826,19 +849,27 @@ function LocalPcWorkspaceMockup({
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey)
const requestedMapKey = selectedWorldMapKey || storedMapKey
const normalizedStoredMapKey = storedMapKey === 'nav2-platform-slam'
? 'nav2-platform-slam-2d'
: storedMapKey
const requestedMapKey = selectedWorldMapKey === 'nav2-platform-slam'
? 'nav2-platform-slam-2d'
: selectedWorldMapKey || normalizedStoredMapKey
if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return
setSelectedWorldMapKey((current) => {
const next = sessionWorldMaps.some((map) => map.key === current)
? current
: sessionWorldMaps.some((map) => map.key === storedMapKey)
? storedMapKey!
: sessionWorldMaps.some((map) => map.key === normalizedStoredMapKey)
? normalizedStoredMapKey!
: sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world'
window.localStorage.setItem(sessionMapStorageKey, next)
return next
})
}, [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 +1035,11 @@ function LocalPcWorkspaceMockup({
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
platform: visualizationPlatform,
threeDimensional: Boolean(
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
),
savedMapDatabasePath: selectedWorldMap.databasePath,
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap.remoteSlam
@@ -1023,15 +1059,18 @@ function LocalPcWorkspaceMockup({
if (initializedVisualizationMapRef.current === mapIdentity) return
initializedVisualizationMapRef.current = mapIdentity
const navigationMap = Boolean(selectedWorldMap?.saved)
const threeDimensionalMap = 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'
? 'slam3d'
const nextStack = threeDimensionalMap
? 'slam3d'
: navigationMap
? 'nav2'
: 'slam'
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
}, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack])
}, [nav2VisualizationMode, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack])
const [customModules, setCustomModules] = useState<NonNullable<CurriculumWeek['modules']>>(() => (
(curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM')
))
@@ -1064,7 +1103,7 @@ function LocalPcWorkspaceMockup({
const resolvedModules = resolveRuntimeModules(
curriculumModulesWithCustom,
remoteWorkspace
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
? visualizationProfiles.filter(isRemoteEnvironmentProfile)
: activeEnvironmentProfile,
remoteWorkspace ? 'platform' : 'local'
) ?? []
@@ -1108,7 +1147,12 @@ 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'
const databasePath = isThreeDimensional
? (map.databasePath ?? map.yamlPath.replace(/\.yaml$/i, '.db'))
: undefined
return {
key: `saved-slam-${map.id}`,
platformKey: 'NAV2',
name: map.name,
@@ -1124,11 +1168,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'),
})))
?? (map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
}
}))
} catch (error) {
console.warn('[studio] saved SLAM maps load failed', error)
} finally {
@@ -1150,20 +1195,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 +1244,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,27 +1251,45 @@ 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))
form.set('sourceMapKey', selectedWorldMap.key)
form.set('worldFile', selectedWorldMap.worldFile)
form.set('viewerModel', selectedWorldMap.viewerModel)
form.set('runtimeKind', remoteWorkspace
? (payload.map.databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
: 'SIMULATION')
form.set('runtimeKind', selectedWorldMap.runtimeKind
?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? '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 +1316,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 +1325,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.id ?? module.folderName ?? module.name, moduleParameterScope)
)
const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => {
const storedParameters = readStoredModuleParameters()
return modules.map((module) => {
@@ -1304,7 +1342,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 +1350,7 @@ function LocalPcWorkspaceMockup({
availableModules,
setAvailableModules,
markModuleState,
removeModule,
modulesLoading,
moduleListError,
clearModuleListError,
@@ -2346,6 +2385,24 @@ function LocalPcWorkspaceMockup({
void applySourceSettings(normalizedSettings, false)
}
const sourceSettingsDirty = (() => {
const normalizeForSave = (settings: StudioSourceSettings): StudioSourceSettings => normalizeStudioSourceSettings({
workspaceRoot: remoteWorkspace
? platformSourceSettingsDefaults.workspaceRoot
: settings.workspaceRoot.trim() || defaultStudioSourceSettings.workspaceRoot,
giteaBaseUrl: sourceSettings.giteaBaseUrl,
giteaOwner: sourceSettings.giteaOwner,
gpuEnabled: settings.gpuEnabled,
editorWordWrap: settings.editorWordWrap,
editorFontSize: settings.editorFontSize,
editorTabSize: settings.editorTabSize,
editorMinimap: settings.editorMinimap,
rosDomainId: settings.rosDomainId.trim() || defaultStudioSourceSettings.rosDomainId,
rosLinkedPlatformId: settings.rosLinkedPlatformId ?? null,
visualizationBridgeLocation: settings.visualizationBridgeLocation ?? 'local',
})
return JSON.stringify(normalizeForSave(sourceSettingsDraft)) !== JSON.stringify(normalizeForSave(sourceSettings))
})()
const saveDirtyFilesForModule = async (module: ModuleRow): Promise<void> => {
const moduleRoot = getModuleFilePrefix(module.name)
const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => (
@@ -2408,16 +2465,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 +2509,8 @@ function LocalPcWorkspaceMockup({
weekNumber,
clonePathPreview,
remoteWorkspace,
moduleParameterScope,
workspaceActive: workspaceVisible,
sourceSettings: runtimeSourceSettings,
rosNetwork: moduleRosNetworkConfig,
simulatorWorldFile: selectedWorldMap.worldFile,
@@ -2483,11 +2547,10 @@ function LocalPcWorkspaceMockup({
}
if (map.saved) {
setNav2VisualizationMode('navigation')
setVisualizationStack('nav2')
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
} else {
setNav2VisualizationMode('slam')
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d')
else if (visualizationStacks.includes('slam')) setVisualizationStack('slam')
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'slam')
}
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
}
@@ -2575,7 +2638,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)
@@ -3509,6 +3576,7 @@ function LocalPcWorkspaceMockup({
platformName={platformName}
configuredDraftWorkspaceRoot={configuredDraftWorkspaceRoot}
sourceSettingsDraft={sourceSettingsDraft}
saveDisabled={!sourceSettingsDirty}
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
onUpdateSourceSetting={updateSourceSetting}
onSaveSourceSettings={saveSourceSettings}
@@ -3753,13 +3821,12 @@ function LocalPcWorkspaceMockup({
onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning')
return
}
setWorldMapSelectorOpen(false)
setWorldEditorOpen(true)
}}
onDelete={(map) => void deleteSavedSlamMap(map)}
onSelect={(map) => {
if (map.key === selectedWorldMapKey) return
setWorldMapSelectorOpen(false)
if (map.key === selectedWorldMapKey) return
if (availableModules.some((module) => module.state === 'running')) {
void applyWorldMapSelection(map)
setPendingWorldMapRestart(map)
@@ -3779,7 +3846,14 @@ function LocalPcWorkspaceMockup({
{worldEditorOpen && (
<WorldEditorModal
workspaceRoot={clonePathPreview}
onSaved={() => setWorldLayoutVersion((version) => version + 1)}
onSaved={() => {
setWorldLayoutVersion((version) => version + 1)
const customMap = sessionWorldMaps.find((map) => map.key === 'nav2-custom')
if (customMap) void applyWorldMapSelection(customMap)
if (availableModules.some((module) => module.state === 'running')) {
onTitlebarNotice('커스텀 맵은 시뮬레이터를 다시 실행하면 적용됩니다.', 'warning')
}
}}
onClose={() => setWorldEditorOpen(false)}
/>
)}
@@ -4178,6 +4252,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 +5680,7 @@ export default function App(): React.JSX.Element {
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
platformOptions={platformOptions}
workspaceVisible={localPcLayoutOpen}
skipInitialLoading={skipWorkspaceInitialLoading}
visualizationOpen={workspaceVisualizationOpen}
onVisualizationOpenChange={setWorkspaceVisualizationOpen}
+6 -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: {
@@ -347,6 +348,10 @@ type WorldLayoutObject = {
type WorldLayout = {
version: 1
bounds: {
width: number
depth: number
}
objects: WorldLayoutObject[]
}
@@ -17,6 +17,8 @@ export type ModuleListRowItem = {
type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
let dismissActiveTooltip: (() => void) | null = null
type ModuleListRowProps<Module extends ModuleListRowItem> = {
dark: boolean
module: Module
@@ -75,55 +77,65 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
const anchorRef = useRef<HTMLSpanElement>(null)
const dismissTimerRef = useRef<number | null>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
useEffect(() => {
const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return
const clearDismissTimer = (): void => {
if (dismissTimerRef.current === null) return
window.clearTimeout(dismissTimerRef.current)
dismissTimerRef.current = null
}
const hideTooltip = (): void => {
clearDismissTimer()
setPosition(null)
if (dismissActiveTooltip === hideTooltip) dismissActiveTooltip = null
}
const showTooltip = (): void => {
dismissActiveTooltip?.()
clearDismissTimer()
const bounds = anchor.getBoundingClientRect()
setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8
})
dismissActiveTooltip = hideTooltip
dismissTimerRef.current = window.setTimeout(() => {
hideTooltip()
}, 3000)
}
const hideWhenPointerLeaves = (event: PointerEvent): void => {
if (event.target instanceof Node && anchor.contains(event.target)) return
hideTooltip()
}
const hideTooltip = (): void => setPosition(null)
anchor.addEventListener('mouseenter', showTooltip)
anchor.addEventListener('mouseleave', hideTooltip)
anchor.addEventListener('focus', showTooltip)
anchor.addEventListener('blur', hideTooltip)
document.addEventListener('pointermove', hideWhenPointerLeaves, true)
document.addEventListener('pointerdown', hideTooltip, true)
document.addEventListener('scroll', hideTooltip, true)
window.addEventListener('blur', hideTooltip)
window.addEventListener('resize', hideTooltip)
return () => {
anchor.removeEventListener('mouseenter', showTooltip)
anchor.removeEventListener('mouseleave', hideTooltip)
anchor.removeEventListener('focus', showTooltip)
anchor.removeEventListener('blur', hideTooltip)
document.removeEventListener('pointermove', hideWhenPointerLeaves, true)
document.removeEventListener('pointerdown', hideTooltip, true)
document.removeEventListener('scroll', hideTooltip, true)
window.removeEventListener('blur', hideTooltip)
window.removeEventListener('resize', hideTooltip)
hideTooltip()
}
}, [])
useEffect(() => {
if (!position) return
const updateTooltipPosition = (): void => {
const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return
const bounds = anchor.getBoundingClientRect()
setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8
})
}
window.addEventListener('resize', updateTooltipPosition)
window.addEventListener('scroll', updateTooltipPosition, true)
return () => {
window.removeEventListener('resize', updateTooltipPosition)
window.removeEventListener('scroll', updateTooltipPosition, true)
}
}, [position])
return (
<>
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
@@ -10,6 +10,7 @@ type Props = {
platformName: string
configuredDraftWorkspaceRoot: string
sourceSettingsDraft: StudioSourceSettings
saveDisabled: boolean
remotePlatforms: PlatformOption[]
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
onSaveSourceSettings: () => void
@@ -23,6 +24,7 @@ function WorkspaceSettingsView({
platformName,
configuredDraftWorkspaceRoot,
sourceSettingsDraft,
saveDisabled,
remotePlatforms,
onUpdateSourceSetting,
onSaveSourceSettings
@@ -173,8 +175,9 @@ function WorkspaceSettingsView({
<div className="mt-8 flex justify-end">
<button
type="button"
disabled={saveDisabled}
onClick={onSaveSourceSettings}
className="h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
className="h-9 rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]"
>
저장
</button>
@@ -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>
@@ -11,7 +11,7 @@ type WorldObjectType = WorldLayoutObject['type']
type WorldEditorModalProps = {
workspaceRoot: string
onClose: () => void
onSaved?: () => void
onSaved?: (layout: WorldLayout) => void
}
type TransformMode = 'translate' | 'rotate' | 'scale'
@@ -175,10 +175,10 @@ async function loadWorldSdf(parent: THREE.Group): Promise<void> {
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
const canvasHostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<HTMLIFrameElement>(null)
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
const worldRef = useRef<WorldLayout>({ version: 1, bounds: { width: 10, depth: 10 }, objects: [] })
const selectedIdRef = useRef<string | null>(null)
const renderSceneRef = useRef<(() => void) | null>(null)
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
const [layout, setLayout] = useState<WorldLayout>({ version: 1, bounds: { width: 10, depth: 10 }, objects: [] })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
@@ -403,8 +403,16 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
setSelectedId(remaining[0]?.id ?? null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const updateBounds = (key: 'width' | 'depth', value: string): void => {
const numeric = Number(value)
if (!Number.isFinite(numeric)) return
setLayout((current) => ({
...current,
bounds: { ...current.bounds, [key]: Math.min(40, Math.max(2, numeric)) },
}))
}
const resetLayout = (): void => {
const next: WorldLayout = { version: 1, objects: [] }
const next: WorldLayout = { version: 1, bounds: layout.bounds, objects: [] }
worldRef.current = next
setLayout(next)
setSelectedId(null)
@@ -416,7 +424,7 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
try {
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
setLayout(saved)
onSaved?.()
onSaved?.(saved)
onClose()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
@@ -426,7 +434,7 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
}
return (
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
<div className="absolute inset-0 z-[170] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
<div>
@@ -442,6 +450,19 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
</div>
<div className="border-b border-white/10 px-3 py-3">
<p className="mb-2 text-[10px] font-bold tracking-[0.08em] text-white/38">맵 크기</p>
<div className="grid grid-cols-2 gap-1.5">
<label className="min-w-0">
<span className="mb-1 block text-[10px] font-bold text-white/38">가로 (m)</span>
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.width} onChange={(event) => updateBounds('width', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
</label>
<label className="min-w-0">
<span className="mb-1 block text-[10px] font-bold text-white/38">세로 (m)</span>
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.depth} onChange={(event) => updateBounds('depth', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
</label>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{layout.objects.length ? layout.objects.map((item) => {
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
@@ -137,7 +137,30 @@ export function WorkspaceMapPreview({
>
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
{!map.previewPgm && (map.custom ? (
{!map.previewPgm && (map.remoteSlam && map.runtimeKind === 'PLATFORM_3D' ? (
<>
<span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
{[
['left-[27%]', 'top-[31%]'], ['left-[31%]', 'top-[36%]'], ['left-[35%]', 'top-[28%]'],
['left-[38%]', 'top-[40%]'], ['left-[44%]', 'top-[33%]'], ['left-[48%]', 'top-[43%]'],
['left-[62%]', 'top-[27%]'], ['left-[68%]', 'top-[33%]'], ['left-[72%]', 'top-[40%]'],
['left-[58%]', 'top-[57%]'], ['left-[64%]', 'top-[63%]'], ['left-[70%]', 'top-[55%]'],
].map(([left, top], index) => (
<span
key={`${left}-${top}`}
className={[
'absolute rounded-full bg-[#536273]',
index % 4 === 0 ? 'size-1.5 opacity-90' : index % 3 === 0 ? 'size-1 opacity-75' : 'size-[3px] opacity-60',
left,
top,
].join(' ')}
/>
))}
<span className="absolute left-[28%] top-[51%] h-px w-[25%] rotate-[-20deg] bg-[#536273]/70" />
<span className="absolute left-[55%] top-[48%] h-px w-[23%] rotate-[28deg] bg-[#536273]/70" />
</>
) : map.custom ? (
<>
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
@@ -208,10 +231,16 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button>
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
</div>
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
<div className="thin-y-scrollbar min-h-[320px] max-h-[320px] space-y-2 overflow-y-auto p-5">
{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' ? '3D' : '2D')
: null
return (
<button
key={map.key}
@@ -224,7 +253,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>}
@@ -243,10 +280,10 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
{scope === 'simulation' && onCreate && (
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
<PencilRuler className="size-3.5" />
맵 편집
커스텀 맵
</button>
)}
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
<button type="button" disabled={!pendingMap} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
확인
</button>
</footer>
@@ -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'
@@ -31,6 +31,24 @@ function waitForSimulatorStartup(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 4000))
}
function duplicateCategorySummary(modules: ModuleRow[]): string | null {
const categories = new Map<string, { label: string; modules: ModuleRow[] }>()
modules.forEach((module) => {
const label = module.category.trim()
if (!label) return
const key = label.toLowerCase()
const entry = categories.get(key) ?? { label, modules: [] }
entry.modules.push(module)
categories.set(key, entry)
})
const conflicts = Array.from(categories.values()).filter((entry) => entry.modules.length > 1)
if (!conflicts.length) return null
return conflicts
.map((entry) => `${entry.label}: ${entry.modules.map((module) => module.name).join(', ')}`)
.join(' / ')
}
type UseWorkspaceModuleControlOptions = {
availableModules: ModuleRow[]
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
@@ -40,6 +58,8 @@ type UseWorkspaceModuleControlOptions = {
weekNumber: number
clonePathPreview: string
remoteWorkspace: boolean
moduleParameterScope: string
workspaceActive?: boolean
sourceSettings: StudioSourceSettings
rosNetwork?: RosNetworkConfig | null
simulatorWorldFile?: string | null
@@ -80,6 +100,8 @@ export function useWorkspaceModuleControl({
weekNumber,
clonePathPreview,
remoteWorkspace,
moduleParameterScope,
workspaceActive = true,
sourceSettings,
rosNetwork,
simulatorWorldFile,
@@ -99,7 +121,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 +158,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 +181,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) {
@@ -252,14 +302,17 @@ export function useWorkspaceModuleControl({
try {
const requestAccessToken = await getFreshAccessToken()
await installModuleOnce(module, requestAccessToken)
await loadAvailableModules({ silent: true })
// The install request completed successfully, so update only this row.
// A full module probe can overlap the ongoing background probe and leave
// the install action visible until the user refreshes the workspace.
markModuleState(module, 'stopped')
} catch (error) {
console.error('[modules] install failed', error)
setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패')
} finally {
setInstallingModuleName(null)
}
}, [getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
}, [getFreshAccessToken, installModuleOnce, installingModuleName, markModuleState])
const installAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return
@@ -272,16 +325,15 @@ export function useWorkspaceModuleControl({
const requestAccessToken = await getFreshAccessToken()
for (const module of targets) {
await installModuleOnce(module, requestAccessToken)
markModuleState(module, 'stopped')
}
await loadAvailableModules({ silent: true })
} catch (error) {
console.error('[modules] install all failed', error)
setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패')
await loadAvailableModules({ silent: true })
} finally {
setInstallingModuleName(null)
}
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, loadAvailableModules])
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, markModuleState])
const startAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return
@@ -291,6 +343,18 @@ export function useWorkspaceModuleControl({
return
}
const activeModules = availableModules.filter((module) => (
!isExcludedFromBulkControl(module)
&& (module.state === 'stopped' || module.state === 'running')
))
const duplicateCategories = duplicateCategorySummary(activeModules)
if (duplicateCategories) {
const message = `동일 카테고리 모듈이 중복되어 전체 실행을 중단했습니다. ${duplicateCategories}`
setModuleInstallError(message)
onTitlebarNotice('중복 카테고리 모듈을 확인하세요', 'warning')
return
}
setInstallingModuleName('__start_all__')
setModuleInstallError(null)
try {
@@ -354,6 +418,7 @@ export function useWorkspaceModuleControl({
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
if (!result.ok) throw new Error(result.message)
targets.forEach((module) => markModuleState(module, 'stopped'))
} else {
if (targets.length) {
const result = await window.localWorkspace.modules.stopMany(
@@ -361,6 +426,7 @@ export function useWorkspaceModuleControl({
clonePathPreview
)
if (!result.ok) throw new Error(result.message)
targets.forEach((module) => markModuleState(module, 'stopped'))
}
await stopLocalVisualization()
}
@@ -374,7 +440,7 @@ export function useWorkspaceModuleControl({
} finally {
setInstallingModuleName(null)
}
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, isExcludedFromBulkControl, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace])
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, isExcludedFromBulkControl, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, remoteWorkspace])
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
if (installingModuleName) return false
@@ -427,6 +493,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 +502,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 +537,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 +556,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 +569,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,8 +581,10 @@ export function useWorkspaceModuleControl({
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
await loadAvailableModules({ silent: true })
} finally {
queuedModuleNamesRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
if (queuedModuleActionsRef.current.get(module.name) === actionId) {
queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
}
}
}
@@ -540,14 +621,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.id ?? 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),
@@ -28,20 +28,10 @@ const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
if (type === 'local' || type === 'platform') return 'nav2'
if (type === 'real_slam') return 'slam'
if (type === 'real_slam_3d') return 'slam3d'
if (type === 'real_navigation') return 'nav2'
return type === 'autoware' || type === 'nav2' || type === 'slam' || type === 'slam3d' ? type : null
}
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
const type = profile?.profileType.toLowerCase()
if (type === 'real_navigation') return 'navigation'
return 'slam'
}
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
if (['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '')) return true
function isRemoteEnvironment(profile: EnvironmentProfile | null): boolean {
const config = profile?.config
return Boolean(
config
@@ -53,7 +43,7 @@ function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
remoteWorkspace ? isRemoteEnvironment(profile) : !isRemoteEnvironment(profile)
))
return matchingProfiles.length ? matchingProfiles : profiles
}
@@ -73,6 +63,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 +107,9 @@ export function useWorkspaceVisualization({
rosNetwork,
mapKey,
platform,
threeDimensional = false,
savedMapDatabasePath,
prepareSavedMap,
visualizationRuntime,
worldLayoutVersion,
onTitlebarNotice
@@ -165,8 +161,11 @@ 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' : ''
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
// 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 ?? (isRemoteEnvironment(activeEnvironmentProfile) ? 'real' : 'simulation')
const localBridgeProfile = useMemo(() => {
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
? activeEnvironmentConfig.bridge as Record<string, unknown>
@@ -184,7 +183,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(() => {
@@ -196,7 +195,6 @@ export function useWorkspaceVisualization({
? savedStack as VisualizationStack
: fallback ?? fallbackStack
setVisualizationStack(stack)
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
const refreshVisualizationStatus = useCallback(async (): Promise<void> => {
@@ -218,9 +216,13 @@ export function useWorkspaceVisualization({
const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam'
? status.stack
: null
const statusStack = rawStatusStack === 'nav2' && visualizationStack === 'slam3d' ? 'slam3d' : rawStatusStack
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack && visualizationStacks.includes(statusStack)))
if (statusStack && visualizationStacks.includes(statusStack)) setVisualizationStack(statusStack)
// The runtime bridge reports Nav2 for both SLAM modes. Preserve the
// map-selected SLAM stack instead of treating it as an unavailable profile.
const statusStack = rawStatusStack === 'nav2' && (visualizationStack === 'slam' || visualizationStack === 'slam3d')
? visualizationStack
: rawStatusStack
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack))
if (statusStack) setVisualizationStack(statusStack)
setVisualizationStatusMessage(status.running
? formatVisualizationStackLabel(status.stack)
: status.message
@@ -244,11 +246,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'