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 # Electron main process runtime overrides
LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge
LOCAL_NAV2_BRIDGE_IMAGE=nav2-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_BRIDGE_IMAGE=slam-ros-bridge
LOCAL_SLAM_RUNTIME_IMAGE=nav2-slam:humble LOCAL_SLAM_RUNTIME_IMAGE=future-nav2-gazebo:humble
# Remote Jetson runtime overrides # Remote Jetson runtime overrides
JETSON_AGENT_URL=http://100.64.0.2:8787 JETSON_AGENT_URL=http://100.64.0.2:8787
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.7", "version": "1.1.8",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
-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 COPY bridge.py /app/bridge.py
RUN apt-get update && \ RUN apt-get update && \
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs && \ apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs ros-humble-rtabmap-msgs ros-humble-rtabmap-util && \
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \ pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
+561 -31
View File
@@ -9,6 +9,8 @@ import os
import shutil import shutil
import sqlite3 import sqlite3
import struct import struct
import subprocess
import tempfile
import threading import threading
import time import time
import traceback import traceback
@@ -17,7 +19,7 @@ from pathlib import Path
from typing import Optional from typing import Optional
import rclpy import rclpy
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect from fastapi import FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
from action_msgs.msg import GoalStatus from action_msgs.msg import GoalStatus
@@ -25,19 +27,21 @@ from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
from nav2_msgs.action import NavigateToPose from nav2_msgs.action import NavigateToPose
from pydantic import BaseModel from pydantic import BaseModel
from rclpy.action import ActionClient from rclpy.action import ActionClient
from rclpy.duration import Duration
from rclpy.executors import MultiThreadedExecutor from rclpy.executors import MultiThreadedExecutor
from rclpy.node import Node from rclpy.node import Node
from rclpy.parameter import Parameter from rclpy.parameter import Parameter
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
from rclpy.time import Time from rclpy.time import Time
from sensor_msgs.msg import 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 from tf2_msgs.msg import TFMessage
import uvicorn import uvicorn
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000"))) _POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000")))
_CAMERA_FRAME_MAX_AGE_SECONDS = 3.0
def yaw_from_quaternion(q) -> float: def yaw_from_quaternion(q) -> float:
@@ -129,6 +133,10 @@ class VelocityPayload(BaseModel):
angular: float = 0.0 angular: float = 0.0
class SteeringPayload(BaseModel):
angle: float
class NavigationGoalPayload(BaseModel): class NavigationGoalPayload(BaseModel):
x: float x: float
y: float y: float
@@ -154,12 +162,19 @@ class SlamNode(Node):
self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose") self.amcl_pose_topic = os.environ.get("AMCL_POSE_TOPIC", "/amcl_pose")
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map") self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered") self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
self.color_point_cloud_topic = os.environ.get("COLOR_POINT_CLOUD_TOPIC", "/camera_test/colored_points")
self.camera_image_topic = os.environ.get("CAMERA_IMAGE_TOPIC", "/camera_test/image_jpeg")
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom") self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel") self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
self.map_topic = os.environ.get("MAP_TOPIC", "/map") self.map_topic = os.environ.get("MAP_TOPIC", "/map")
self.plan_topic = os.environ.get("PLAN_TOPIC", "/plan") 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.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes") self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes")
self.saved_map_database_path = Path(os.environ.get("SAVED_MAP_DATABASE_PATH", ""))
best_effort_qos = QoSProfile( best_effort_qos = QoSProfile(
depth=1, depth=1,
@@ -187,15 +202,22 @@ class SlamNode(Node):
self._amcl_pose: Optional[PoseWithCovarianceStamped] = None self._amcl_pose: Optional[PoseWithCovarianceStamped] = None
self._point_cloud: Optional[PointCloud2] = None self._point_cloud: Optional[PointCloud2] = None
self._fallback_point_cloud: Optional[PointCloud2] = None self._fallback_point_cloud: Optional[PointCloud2] = None
self._color_point_cloud: Optional[PointCloud2] = None
self._camera_image: Optional[CompressedImage] = None
self._icp_odom: Optional[Odometry] = None self._icp_odom: Optional[Odometry] = None
self._map: Optional[OccupancyGrid] = None self._map: Optional[OccupancyGrid] = None
self._plan: Optional[NavPath] = None self._plan: Optional[NavPath] = None
self._joint_positions = {}
self._steering_feedback = None
self._simulator_steering_angle = None
self._tf = {} self._tf = {}
self._odom_count = 0 self._odom_count = 0
self._scan_count = 0 self._scan_count = 0
self._amcl_pose_count = 0 self._amcl_pose_count = 0
self._point_cloud_count = 0 self._point_cloud_count = 0
self._fallback_point_cloud_count = 0 self._fallback_point_cloud_count = 0
self._color_point_cloud_count = 0
self._camera_image_count = 0
self._icp_odom_count = 0 self._icp_odom_count = 0
self._map_count = 0 self._map_count = 0
self._tf_count = 0 self._tf_count = 0
@@ -204,6 +226,8 @@ class SlamNode(Node):
self._last_amcl_pose_wall_time = None self._last_amcl_pose_wall_time = None
self._last_point_cloud_wall_time = None self._last_point_cloud_wall_time = None
self._last_fallback_point_cloud_wall_time = None self._last_fallback_point_cloud_wall_time = None
self._last_color_point_cloud_wall_time = None
self._last_camera_image_wall_time = None
self._last_icp_odom_wall_time = None self._last_icp_odom_wall_time = None
self._last_map_wall_time = None self._last_map_wall_time = None
self._plan_count = 0 self._plan_count = 0
@@ -212,52 +236,186 @@ class SlamNode(Node):
self._topic_status_last_refresh = 0.0 self._topic_status_last_refresh = 0.0
self._navigation = {"status": "idle", "goal_id": None} self._navigation = {"status": "idle", "goal_id": None}
self._active_goal_handle = None self._active_goal_handle = None
self._requested_initial_pose: Optional[dict] = None
self._initial_pose_state_path = Path(
os.environ.get("INITIAL_POSE_STATE_PATH", "/workspace/.nav2-initial-pose-state.json")
)
self._initial_pose_set, self._initial_pose_session_id = self._load_initial_pose_state()
self._localization_session_id: Optional[str] = None
self._lock = threading.Lock() self._lock = threading.Lock()
self._saved_map_point_cloud = self._load_saved_map_point_cloud()
self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos) self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos)
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos) self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data) self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos) self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, reliable_qos)
self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos) self.create_subscription(PoseWithCovarianceStamped, self.amcl_pose_topic, self._on_amcl_pose, best_effort_qos)
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos) self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
if self.fallback_point_cloud_topic != self.point_cloud_topic: if self.fallback_point_cloud_topic != self.point_cloud_topic:
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data) self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
if self.color_point_cloud_topic not in (self.point_cloud_topic, self.fallback_point_cloud_topic):
self.create_subscription(PointCloud2, self.color_point_cloud_topic, self._on_color_point_cloud, qos_profile_sensor_data)
self.create_subscription(CompressedImage, self.camera_image_topic, self._on_camera_image, qos_profile_sensor_data)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos) self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos) self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, best_effort_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(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", self._on_tf, qos_profile_sensor_data)
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos) self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10) self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
self._steering_pub = self.create_publisher(Float64, self.steering_topic, 10)
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10) self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action) self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
self._rtabmap_backup_client = self.create_client(Empty, "/rtabmap/backup")
self.get_logger().info( self.get_logger().info(
f"SLAM bridge started: odom={self.odom_topic}, icp_odom={self.icp_odom_topic}, scan={self.scan_topic}, point_cloud={self.point_cloud_topic}, fallback_point_cloud={self.fallback_point_cloud_topic}, map={self.map_topic}, read_only={self.read_only}" f"SLAM bridge started: odom={self.odom_topic}, icp_odom={self.icp_odom_topic}, scan={self.scan_topic}, point_cloud={self.point_cloud_topic}, fallback_point_cloud={self.fallback_point_cloud_topic}, color_point_cloud={self.color_point_cloud_topic}, camera_image={self.camera_image_topic}, map={self.map_topic}, read_only={self.read_only}"
) )
def _load_saved_map_point_cloud(self) -> Optional[dict]:
"""Create a stable navigation-map cloud from the selected RTAB-Map DB."""
if not self.saved_map_database_path.is_file():
return None
try:
with tempfile.TemporaryDirectory(prefix="slam-map-cloud-") as output_dir:
output_name = "saved-map"
subprocess.run(
[
"rtabmap-export",
"--cloud",
"--scan",
"--voxel",
"0.01",
"--output",
output_name,
"--output_dir",
output_dir,
str(self.saved_map_database_path),
],
check=True,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
text=True,
timeout=90,
)
points = self._read_exported_ply_points(
Path(output_dir) / f"{output_name}_cloud.ply",
_POINT_CLOUD_LIMIT,
)
except (OSError, subprocess.SubprocessError, ValueError) as error:
self.get_logger().warning(f"Saved 3D map export failed: {error}")
return None
if not points:
self.get_logger().warning("Saved 3D map export did not contain any points.")
return None
self.get_logger().info(f"Loaded saved 3D map point cloud: {len(points)} points")
return {
"frame_id": "map",
"points": points,
"transform": {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0},
"saved_map": True,
}
@staticmethod
def _read_exported_ply_points(path: Path, limit: int) -> list[list[float]]:
with path.open("rb") as source:
header = bytearray()
while not header.endswith(b"end_header\n"):
chunk = source.read(1)
if not chunk or len(header) > 16_384:
raise ValueError("Invalid exported PLY header")
header.extend(chunk)
lines = header.decode("ascii", errors="strict").splitlines()
if "format binary_little_endian 1.0" not in lines:
raise ValueError("Unsupported exported PLY format")
vertex_line = next((line for line in lines if line.startswith("element vertex ")), None)
if vertex_line is None:
raise ValueError("Exported PLY does not have vertices")
vertex_count = int(vertex_line.rsplit(" ", 1)[1])
if vertex_count <= 0:
return []
# rtabmap-export writes x/y/z followed by five float fields.
data = source.read(vertex_count * 32)
if len(data) < vertex_count * 32:
raise ValueError("Exported PLY was truncated")
stride = max(1, (vertex_count + max(1, limit) - 1) // max(1, limit))
return [
list(struct.unpack_from("<3f", data, index * 32))
for index in range(0, vertex_count, stride)
]
def _load_initial_pose_state(self) -> tuple[bool, Optional[str]]:
try:
payload = json.loads(self._initial_pose_state_path.read_text(encoding="utf-8"))
session_id = payload.get("localization_session_id")
return (
payload.get("initial_pose_set") is True,
session_id.strip() if isinstance(session_id, str) and session_id.strip() else None,
)
except (OSError, ValueError, TypeError):
return False, None
def _save_initial_pose_state(self) -> None:
try:
self._initial_pose_state_path.parent.mkdir(parents=True, exist_ok=True)
temporary_path = self._initial_pose_state_path.with_suffix(".tmp")
temporary_path.write_text(json.dumps({
"initial_pose_set": self._initial_pose_set,
"localization_session_id": self._initial_pose_session_id,
}), encoding="utf-8")
temporary_path.replace(self._initial_pose_state_path)
except OSError as error:
self.get_logger().warning(f"Failed to persist initial pose state: {error}")
def _on_localization_session(self, msg: String):
session_id = msg.data.strip()
if not session_id:
return
should_persist = False
with self._lock:
if self._localization_session_id == session_id:
return
self._localization_session_id = session_id
if self._initial_pose_set and self._initial_pose_session_id == "__pending__":
self._initial_pose_session_id = session_id
should_persist = True
elif self._initial_pose_session_id != session_id:
# A localization module restart starts a new estimation session.
# Never reuse the previous session's initial pose in it.
self._initial_pose_set = False
self._initial_pose_session_id = None
should_persist = True
if should_persist:
self._save_initial_pose_state()
self.get_logger().info("Localization session changed; initial pose state was reset.")
def _on_odom(self, msg: Odometry): def _on_odom(self, msg: Odometry):
with self._lock: with self._lock:
self._odom = msg self._odom = msg
self._odom_count += 1 self._odom_count += 1
self._last_odom_wall_time = time.time() self._last_odom_wall_time = time.time()
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None: def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
# AMCL transforms the initial pose at the message timestamp. On a real """Provide a map-frame seed without overwriting live ICP odometry."""
# platform the bridge clock can be a few milliseconds ahead of the
# latest odom/TF sample, which makes AMCL reject the pose as a future
# extrapolation. Anchor it to the latest received odometry instead.
with self._lock: with self._lock:
odom_stamp = self._odom.header.stamp if self._odom is not None else None has_3d_localization = self._icp_odom is not None
# A zero ROS timestamp means "use the latest available transform".
# The bridge and platform receive odometry through different DDS
# processes, so an odometry-derived timestamp can still be newer than
# AMCL's TF buffer.
message = PoseWithCovarianceStamped() message = PoseWithCovarianceStamped()
if odom_stamp: message.header.stamp = Time().to_msg()
# The odom callback can lead the TF buffer by a few milliseconds.
# Keep a small margin so AMCL can always transform map -> odom.
message.header.stamp = (Time.from_msg(odom_stamp) - Duration(seconds=0.1)).to_msg()
else:
message.header.stamp = self.get_clock().now().to_msg()
message.header.frame_id = frame_id message.header.frame_id = frame_id
message.pose.pose.position.x = x message.pose.pose.position.x = x
message.pose.pose.position.y = y message.pose.pose.position.y = y
@@ -269,8 +427,33 @@ class SlamNode(Node):
message.pose.covariance[0] = 0.25 message.pose.covariance[0] = 0.25
message.pose.covariance[7] = 0.25 message.pose.covariance[7] = 0.25
message.pose.covariance[35] = 0.0685 message.pose.covariance[35] = 0.0685
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) self._initial_pose_pub.publish(message)
with self._lock:
self._initial_pose_set = True
self._initial_pose_session_id = self._localization_session_id or "__pending__"
self._save_initial_pose_state()
return {
"mode": "rtabmap-3d" if has_3d_localization else "amcl-2d",
"refining": has_3d_localization,
}
def save_map(self, name: Optional[str] = None) -> dict: def save_map(self, name: Optional[str] = None) -> dict:
with self._lock: with self._lock:
grid = self._map grid = self._map
@@ -342,6 +525,10 @@ class SlamNode(Node):
database_path = None database_path = None
database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", "")) database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", ""))
if os.environ.get("RTABMAP_MAP_SAVE_ENABLED", "").lower() == "true" and database_source.is_file(): if os.environ.get("RTABMAP_MAP_SAVE_ENABLED", "").lower() == "true" and database_source.is_file():
# RTAB-Map keeps the optimized pose graph in memory until its
# backup service flushes it. Copying SQLite directly here leaves a
# usable PGM but a DB that cannot localize on the saved map.
self._backup_rtabmap_database()
database_snapshot = maps_dir / f"{safe_name}.db" database_snapshot = maps_dir / f"{safe_name}.db"
source_connection = None source_connection = None
destination_connection = None destination_connection = None
@@ -359,6 +546,11 @@ class SlamNode(Node):
destination_connection.close() destination_connection.close()
if source_connection is not None: if source_connection is not None:
source_connection.close() source_connection.close()
# A saved map must start unlocalized. RTAB-Map stores the last
# localization pose beside the optimized graph; retaining it
# makes a later localization session place the vehicle before the
# learner supplies the first pose.
self._clear_database_last_localization(database_snapshot)
database_path = str(database_snapshot) database_path = str(database_snapshot)
return { return {
@@ -372,6 +564,28 @@ class SlamNode(Node):
"previewPgm": base64.b64encode(preview_pgm).decode("ascii"), "previewPgm": base64.b64encode(preview_pgm).decode("ascii"),
} }
def _backup_rtabmap_database(self, timeout_seconds: float = 30.0) -> None:
if not self._rtabmap_backup_client.wait_for_service(timeout_sec=5.0):
raise RuntimeError("3D SLAM DB 저장 서비스를 찾지 못했습니다. RTAB-Map 모듈이 실행 중인지 확인하세요.")
future = self._rtabmap_backup_client.call_async(Empty.Request())
deadline = time.monotonic() + timeout_seconds
while not future.done() and time.monotonic() < deadline:
time.sleep(0.05)
if not future.done():
raise RuntimeError("3D SLAM DB 저장 시간이 초과되었습니다.")
if future.exception() is not None:
raise RuntimeError(f"3D SLAM DB 저장에 실패했습니다: {future.exception()}")
@staticmethod
def _clear_database_last_localization(database_path: Path) -> None:
try:
with sqlite3.connect(str(database_path)) as connection:
connection.execute("UPDATE Admin SET opt_last_localization = NULL")
except sqlite3.Error as error:
raise RuntimeError(f"저장한 3D SLAM DB를 정리하지 못했습니다: {error}") from error
def _on_scan(self, msg: LaserScan): def _on_scan(self, msg: LaserScan):
with self._lock: with self._lock:
self._scan = msg self._scan = msg
@@ -396,6 +610,18 @@ class SlamNode(Node):
self._fallback_point_cloud_count += 1 self._fallback_point_cloud_count += 1
self._last_fallback_point_cloud_wall_time = time.time() self._last_fallback_point_cloud_wall_time = time.time()
def _on_color_point_cloud(self, msg: PointCloud2):
with self._lock:
self._color_point_cloud = msg
self._color_point_cloud_count += 1
self._last_color_point_cloud_wall_time = time.time()
def _on_camera_image(self, msg: CompressedImage):
with self._lock:
self._camera_image = msg
self._camera_image_count += 1
self._last_camera_image_wall_time = time.time()
def _active_point_cloud(self) -> Optional[PointCloud2]: def _active_point_cloud(self) -> Optional[PointCloud2]:
with self._lock: with self._lock:
primary = self._point_cloud primary = self._point_cloud
@@ -425,6 +651,22 @@ class SlamNode(Node):
self._plan_count += 1 self._plan_count += 1
self._last_plan_wall_time = time.time() 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: def topic_status(self) -> dict:
now = time.time() now = time.time()
with self._lock: with self._lock:
@@ -435,6 +677,8 @@ class SlamNode(Node):
amcl_pose_last = self._last_amcl_pose_wall_time amcl_pose_last = self._last_amcl_pose_wall_time
point_cloud_last = self._last_point_cloud_wall_time point_cloud_last = self._last_point_cloud_wall_time
fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time fallback_point_cloud_last = self._last_fallback_point_cloud_wall_time
color_point_cloud_last = self._last_color_point_cloud_wall_time
camera_image_last = self._last_camera_image_wall_time
map_count = self._map_count map_count = self._map_count
plan_count = self._plan_count plan_count = self._plan_count
@@ -457,7 +701,24 @@ class SlamNode(Node):
"amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)}, "amcl_pose": {"name": self.amcl_pose_topic, "available": received_recently(amcl_pose_last)},
"point_cloud": { "point_cloud": {
"name": self.point_cloud_topic, "name": self.point_cloud_topic,
"available": received_recently(point_cloud_last) or received_recently(fallback_point_cloud_last), # RTAB-Map publishes the reconstructed global cloud with
# TRANSIENT_LOCAL durability. In localization it may publish
# only once after loading the database, so a received map is
# still valid even when it is older than the live-topic
# freshness window used for sensors.
"available": self._saved_map_point_cloud is not None or point_cloud_last is not None,
},
"sensor_point_cloud": {
"name": self.fallback_point_cloud_topic,
"available": received_recently(fallback_point_cloud_last),
},
"color_point_cloud": {
"name": self.color_point_cloud_topic,
"available": received_recently(color_point_cloud_last),
},
"camera_image": {
"name": self.camera_image_topic,
"available": received_recently(camera_image_last),
}, },
"plan": {"name": self.plan_topic, "available": plan_available}, "plan": {"name": self.plan_topic, "available": plan_available},
"cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available}, "cmd_vel": {"name": self.cmd_vel_topic, "available": cmd_vel_available},
@@ -563,6 +824,21 @@ class SlamNode(Node):
msg.angular.z = float(angular) msg.angular.z = float(angular)
self._cmd_vel_pub.publish(msg) 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: def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
if self.read_only: if self.read_only:
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.") raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.")
@@ -596,6 +872,7 @@ class SlamNode(Node):
goal_id = bytes(goal_handle.goal_id.uuid).hex() goal_id = bytes(goal_handle.goal_id.uuid).hex()
with self._lock: with self._lock:
self._clear_plan_locked()
self._navigation = {"status": "running", "goal_id": goal_id} self._navigation = {"status": "running", "goal_id": goal_id}
self._active_goal_handle = goal_handle self._active_goal_handle = goal_handle
goal_handle.get_result_async().add_done_callback( goal_handle.get_result_async().add_done_callback(
@@ -631,11 +908,13 @@ class SlamNode(Node):
return return
self._navigation = {**self._navigation, "status": result_status} self._navigation = {**self._navigation, "status": result_status}
self._active_goal_handle = None self._active_goal_handle = None
self._clear_plan_locked()
def cancel_navigation(self, wait: bool = False) -> bool: def cancel_navigation(self, wait: bool = False) -> bool:
with self._lock: with self._lock:
goal_handle = self._active_goal_handle goal_handle = self._active_goal_handle
self._navigation = {**self._navigation, "status": "canceled"} self._navigation = {**self._navigation, "status": "canceled"}
self._clear_plan_locked()
if goal_handle is None: if goal_handle is None:
return True return True
@@ -662,6 +941,16 @@ class SlamNode(Node):
scan = self._scan scan = self._scan
grid = self._map grid = self._map
plan = self._plan 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) tf_snapshot = dict(self._tf)
navigation = dict(self._navigation) navigation = dict(self._navigation)
effective_map = self._effective_map(grid, scan, odom) effective_map = self._effective_map(grid, scan, odom)
@@ -671,6 +960,7 @@ class SlamNode(Node):
pose_map = None pose_map = None
scan_pose = None scan_pose = None
scan_pose_map = None scan_pose_map = None
vehicle = {}
if odom is not None: if odom is not None:
pos = odom.pose.pose.position pos = odom.pose.pose.position
ori = odom.pose.pose.orientation ori = odom.pose.pose.orientation
@@ -703,6 +993,10 @@ class SlamNode(Node):
"source_frame_id": scan.header.frame_id, "source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9, "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) map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
if map_to_base is not None: if map_to_base is not None:
pose_map = { pose_map = {
@@ -712,10 +1006,79 @@ class SlamNode(Node):
"source_frame_id": odom.header.frame_id, "source_frame_id": odom.header.frame_id,
"t": pose["t"], "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: if scan is not None and pose_map is not None:
# Resolve map -> sensor directly through the published TF graph so # Prefer direct map -> sensor TF for the current scan. It carries
# the scan uses the same localization correction as the map. # 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) map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
if map_to_scan is not None: if map_to_scan is not None:
scan_pose_map = { scan_pose_map = {
@@ -726,6 +1089,21 @@ class SlamNode(Node):
"source_frame_id": scan.header.frame_id, "source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9, "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 pose_3d = None
if icp_odom is not None: if icp_odom is not None:
@@ -743,15 +1121,14 @@ class SlamNode(Node):
"yaw": euler["yaw"], "yaw": euler["yaw"],
"t": icp_odom.header.stamp.sec + icp_odom.header.stamp.nanosec * 1e-9, "t": icp_odom.header.stamp.sec + icp_odom.header.stamp.nanosec * 1e-9,
} }
cloud_frame = point_cloud.header.frame_id if point_cloud is not None else "" map_to_icp_odom = resolve_2d_transform(tf_snapshot, "map", icp_odom.header.frame_id)
map_to_icp_odom = tf_snapshot.get((cloud_frame, icp_odom.header.frame_id)) if cloud_frame else None
if map_to_icp_odom is not None: if map_to_icp_odom is not None:
# RTAB-Map publishes /cloud_map in map, while ICP odometry is # ICP is local odometry. RTAB-Map localization publishes the
# expressed in icp_odom. Render both in the cloud frame. # map -> icp_odom correction needed to render it on the saved map.
pose_3d = { pose_3d = {
**pose_3d, **pose_3d,
**compose_2d(map_to_icp_odom, pose_3d), **compose_2d(map_to_icp_odom, pose_3d),
"frame_id": cloud_frame, "frame_id": "map",
"source_frame_id": icp_odom.header.frame_id, "source_frame_id": icp_odom.header.frame_id,
} }
@@ -789,12 +1166,38 @@ class SlamNode(Node):
"t": point_cloud.header.stamp.sec + point_cloud.header.stamp.nanosec * 1e-9, "t": point_cloud.header.stamp.sec + point_cloud.header.stamp.nanosec * 1e-9,
} }
sensor_point_cloud_payload = None
if fallback_point_cloud is not None:
sensor_point_cloud_payload = {
"frame_id": fallback_point_cloud.header.frame_id,
"count": fallback_point_cloud.width * fallback_point_cloud.height,
"t": fallback_point_cloud.header.stamp.sec + fallback_point_cloud.header.stamp.nanosec * 1e-9,
}
color_point_cloud_payload = None
if color_point_cloud is not None:
color_point_cloud_payload = {
"frame_id": color_point_cloud.header.frame_id,
"count": color_point_cloud.width * color_point_cloud.height,
"t": color_point_cloud.header.stamp.sec + color_point_cloud.header.stamp.nanosec * 1e-9,
}
camera_image_payload = None
if camera_image is not None:
camera_image_payload = {
"frame_id": camera_image.header.frame_id,
"t": camera_image.header.stamp.sec + camera_image.header.stamp.nanosec * 1e-9,
}
return { return {
"ok": True, "ok": True,
"topics": { "topics": {
"odom": self.odom_topic, "odom": self.odom_topic,
"scan": self.scan_topic, "scan": self.scan_topic,
"point_cloud": self.point_cloud_topic, "point_cloud": self.point_cloud_topic,
"sensor_point_cloud": self.fallback_point_cloud_topic,
"color_point_cloud": self.color_point_cloud_topic,
"camera_image": self.camera_image_topic,
"icp_odom": self.icp_odom_topic, "icp_odom": self.icp_odom_topic,
"map": self.map_topic, "map": self.map_topic,
"cmd_vel": self.cmd_vel_topic, "cmd_vel": self.cmd_vel_topic,
@@ -805,13 +1208,20 @@ class SlamNode(Node):
"scan_pose": scan_pose, "scan_pose": scan_pose,
"scan_pose_map": scan_pose_map, "scan_pose_map": scan_pose_map,
"pose_3d": pose_3d, "pose_3d": pose_3d,
"vehicle": vehicle,
"scan": scan_payload, "scan": scan_payload,
"point_cloud": point_cloud_payload, "point_cloud": point_cloud_payload,
"sensor_point_cloud": sensor_point_cloud_payload,
"color_point_cloud": color_point_cloud_payload,
"camera_image": camera_image_payload,
"map": map_payload, "map": map_payload,
"tf": { "tf": {
"map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"), "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
}, },
"navigation": navigation, "navigation": navigation,
"initial_pose_set": initial_pose_set,
"localization_session_id": localization_session_id,
"initial_pose_session_id": initial_pose_session_id,
"topic_status": self.topic_status(), "topic_status": self.topic_status(),
"plan": { "plan": {
"frame_id": plan.header.frame_id, "frame_id": plan.header.frame_id,
@@ -828,7 +1238,57 @@ class SlamNode(Node):
} }
def point_cloud_payload(self, limit: int = _POINT_CLOUD_LIMIT) -> Optional[dict]: def point_cloud_payload(self, limit: int = _POINT_CLOUD_LIMIT) -> Optional[dict]:
cloud = self._active_point_cloud() with self._lock:
saved_map_point_cloud = self._saved_map_point_cloud
# A saved RTAB-Map map may be published in the local ICP frame
# during navigation, while a live SLAM map is normally in `map`.
# Preserve the source frame here so the viewer can place either
# cloud in the common map scene.
cloud = self._point_cloud or self._fallback_point_cloud
tf_snapshot = dict(self._tf)
if saved_map_point_cloud is not None:
return saved_map_point_cloud
payload = self._serialize_point_cloud(cloud, limit)
if payload is None:
return None
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
return payload
def sensor_point_cloud_payload(self, limit: int = 10_000) -> Optional[dict]:
with self._lock:
cloud = self._fallback_point_cloud
tf_snapshot = dict(self._tf)
received_at = self._last_fallback_point_cloud_wall_time
payload = self._serialize_point_cloud(cloud, limit)
if payload is None:
return None
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
payload["received_at"] = received_at
return payload
def color_point_cloud_payload(self, limit: int = 10_000) -> Optional[dict]:
with self._lock:
cloud = self._color_point_cloud
tf_snapshot = dict(self._tf)
received_at = self._last_color_point_cloud_wall_time
payload = self._serialize_point_cloud(cloud, limit, include_colors=True)
if payload is None:
return None
payload["transform"] = resolve_2d_transform(tf_snapshot, "map", payload["frame_id"])
payload["received_at"] = received_at
return payload
def camera_frame_jpeg(self) -> Optional[bytes]:
with self._lock:
image = self._camera_image
received_at = self._last_camera_image_wall_time
if image is None or received_at is None or time.time() - received_at > _CAMERA_FRAME_MAX_AGE_SECONDS:
return None
if not image.data or "jpeg" not in image.format.lower():
return None
return bytes(image.data)
def _serialize_point_cloud(self, cloud: Optional[PointCloud2], limit: int, include_colors: bool = False) -> Optional[dict]:
if cloud is None or cloud.point_step <= 0 or not cloud.data: if cloud is None or cloud.point_step <= 0 or not cloud.data:
return None return None
@@ -843,6 +1303,9 @@ class SlamNode(Node):
prefix = ">" if cloud.is_bigendian else "<" prefix = ">" if cloud.is_bigendian else "<"
data = cloud.data data = cloud.data
points = [] points = []
colors = []
color_offset = offsets.get("rgb", offsets.get("rgba"))
color_field = next((field for field in cloud.fields if field.name in ("rgb", "rgba")), None)
for index in range(0, total, stride): for index in range(0, total, stride):
offset = index * cloud.point_step offset = index * cloud.point_step
if offset + cloud.point_step > len(data): if offset + cloud.point_step > len(data):
@@ -855,12 +1318,27 @@ class SlamNode(Node):
break break
if math.isfinite(x) and math.isfinite(y) and math.isfinite(z): if math.isfinite(x) and math.isfinite(y) and math.isfinite(z):
points.append([x, y, z]) points.append([x, y, z])
if include_colors:
rgb = 0xFFFFFF
if color_offset is not None and color_field is not None:
try:
if color_field.datatype == 6: # PointField.UINT32
rgb = struct.unpack_from(f"{prefix}I", data, offset + color_offset)[0]
elif color_field.datatype == 7: # PointField.FLOAT32 packed RGB
packed = struct.unpack_from(f"{prefix}f", data, offset + color_offset)[0]
rgb = struct.unpack(f"{prefix}I", struct.pack(f"{prefix}f", packed))[0]
except struct.error:
pass
colors.append([(rgb >> 16) & 0xFF, (rgb >> 8) & 0xFF, rgb & 0xFF])
return { payload = {
"frame_id": cloud.header.frame_id, "frame_id": cloud.header.frame_id,
"stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9, "stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9,
"points": points, "points": points,
} }
if include_colors:
payload["colors"] = colors
return payload
def map_binary(self) -> Optional[bytes]: def map_binary(self) -> Optional[bytes]:
with self._lock: with self._lock:
@@ -894,7 +1372,7 @@ class SlamNode(Node):
def topic_debug(self) -> dict: def topic_debug(self) -> dict:
topics = {} topics = {}
for name, types in self.get_topic_names_and_types(): for name, types in self.get_topic_names_and_types():
if name in (self.odom_topic, self.icp_odom_topic, self.scan_topic, self.point_cloud_topic, self.fallback_point_cloud_topic, self.map_topic, self.cmd_vel_topic): if name in (self.odom_topic, self.icp_odom_topic, self.scan_topic, self.point_cloud_topic, self.fallback_point_cloud_topic, self.color_point_cloud_topic, self.camera_image_topic, self.map_topic, self.cmd_vel_topic):
topics[name] = types topics[name] = types
return { return {
@@ -903,6 +1381,8 @@ class SlamNode(Node):
"scan": self.scan_topic, "scan": self.scan_topic,
"point_cloud": self.point_cloud_topic, "point_cloud": self.point_cloud_topic,
"fallback_point_cloud": self.fallback_point_cloud_topic, "fallback_point_cloud": self.fallback_point_cloud_topic,
"color_point_cloud": self.color_point_cloud_topic,
"camera_image": self.camera_image_topic,
"icp_odom": self.icp_odom_topic, "icp_odom": self.icp_odom_topic,
"map": self.map_topic, "map": self.map_topic,
"cmd_vel": self.cmd_vel_topic, "cmd_vel": self.cmd_vel_topic,
@@ -912,6 +1392,8 @@ class SlamNode(Node):
"scan_publishers": self.count_publishers(self.scan_topic), "scan_publishers": self.count_publishers(self.scan_topic),
"point_cloud_publishers": self.count_publishers(self.point_cloud_topic), "point_cloud_publishers": self.count_publishers(self.point_cloud_topic),
"fallback_point_cloud_publishers": self.count_publishers(self.fallback_point_cloud_topic), "fallback_point_cloud_publishers": self.count_publishers(self.fallback_point_cloud_topic),
"color_point_cloud_publishers": self.count_publishers(self.color_point_cloud_topic),
"camera_image_publishers": self.count_publishers(self.camera_image_topic),
"icp_odom_publishers": self.count_publishers(self.icp_odom_topic), "icp_odom_publishers": self.count_publishers(self.icp_odom_topic),
"odom_publishers": self.count_publishers(self.odom_topic), "odom_publishers": self.count_publishers(self.odom_topic),
"map_publishers": self.count_publishers(self.map_topic), "map_publishers": self.count_publishers(self.map_topic),
@@ -920,6 +1402,8 @@ class SlamNode(Node):
"scan_count": self._scan_count, "scan_count": self._scan_count,
"point_cloud_count": self._point_cloud_count, "point_cloud_count": self._point_cloud_count,
"fallback_point_cloud_count": self._fallback_point_cloud_count, "fallback_point_cloud_count": self._fallback_point_cloud_count,
"color_point_cloud_count": self._color_point_cloud_count,
"camera_image_count": self._camera_image_count,
"icp_odom_count": self._icp_odom_count, "icp_odom_count": self._icp_odom_count,
"odom_count": self._odom_count, "odom_count": self._odom_count,
"map_count": self._map_count, "map_count": self._map_count,
@@ -927,6 +1411,8 @@ class SlamNode(Node):
"last_scan_wall_time": self._last_scan_wall_time, "last_scan_wall_time": self._last_scan_wall_time,
"last_point_cloud_wall_time": self._last_point_cloud_wall_time, "last_point_cloud_wall_time": self._last_point_cloud_wall_time,
"last_fallback_point_cloud_wall_time": self._last_fallback_point_cloud_wall_time, "last_fallback_point_cloud_wall_time": self._last_fallback_point_cloud_wall_time,
"last_color_point_cloud_wall_time": self._last_color_point_cloud_wall_time,
"last_camera_image_wall_time": self._last_camera_image_wall_time,
"last_icp_odom_wall_time": self._last_icp_odom_wall_time, "last_icp_odom_wall_time": self._last_icp_odom_wall_time,
"last_odom_wall_time": self._last_odom_wall_time, "last_odom_wall_time": self._last_odom_wall_time,
"last_map_wall_time": self._last_map_wall_time, "last_map_wall_time": self._last_map_wall_time,
@@ -1000,6 +1486,36 @@ def point_cloud():
return payload return payload
@app.get("/sensor-point-cloud")
def sensor_point_cloud():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
payload = slam_node.sensor_point_cloud_payload()
if payload is None:
raise HTTPException(status_code=404, detail="센서 포인트클라우드를 아직 수신하지 못했습니다.")
return payload
@app.get("/color-point-cloud")
def color_point_cloud():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
payload = slam_node.color_point_cloud_payload()
if payload is None:
raise HTTPException(status_code=404, detail="컬러 포인트클라우드를 아직 수신하지 못했습니다.")
return payload
@app.get("/camera-frame")
def camera_frame():
if not slam_node:
raise HTTPException(status_code=503, detail="ROS node is not ready")
frame = slam_node.camera_frame_jpeg()
if frame is None:
raise HTTPException(status_code=404, detail="카메라 프레임을 아직 수신하지 못했습니다.")
return Response(content=frame, media_type="image/jpeg", headers={"Cache-Control": "no-store"})
@app.get("/world-layout") @app.get("/world-layout")
def world_layout(): def world_layout():
path = Path(os.environ.get("WORLD_LAYOUT_PATH", "/workspace/world-layout.json")) path = Path(os.environ.get("WORLD_LAYOUT_PATH", "/workspace/world-layout.json"))
@@ -1017,7 +1533,7 @@ def save_map(payload: SaveMapPayload):
raise HTTPException(status_code=503, detail="ROS node is not ready") raise HTTPException(status_code=503, detail="ROS node is not ready")
try: try:
return {"ok": True, "map": slam_node.save_map(payload.name)} return {"ok": True, "map": slam_node.save_map(payload.name)}
except ValueError as error: except (ValueError, RuntimeError) as error:
raise HTTPException(status_code=409, detail=str(error)) from error raise HTTPException(status_code=409, detail=str(error)) from error
@@ -1036,6 +1552,17 @@ def cmd_vel(payload: VelocityPayload):
return {"ok": True} 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") @app.post("/stop")
def stop(): def stop():
if not slam_node: if not slam_node:
@@ -1073,8 +1600,11 @@ def initial_pose(payload: InitialPosePayload):
raise HTTPException(status_code=503, detail="ROS node is not ready") raise HTTPException(status_code=503, detail="ROS node is not ready")
if slam_node.read_only: if slam_node.read_only:
raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.") raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.")
slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id) try:
return {"ok": True} result = slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id)
except RuntimeError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
return {"ok": True, **result}
@app.websocket("/ws/state") @app.websocket("/ws/state")
+222 -78
View File
@@ -26,10 +26,10 @@ const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts') const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' 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 autowareRosBridgeImage = 'autoware-ros-bridge'
const slamRosBridgeImage = 'slam-ros-bridge' const slamRosBridgeImage = 'slam-ros-bridge'
const slamSimulationRuntimeImage = 'nav2-slam:humble' const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble'
const slamSimulationRuntimeContainerName = 'nav2-slam' const slamSimulationRuntimeContainerName = 'nav2-slam'
const rosBridgePort = 8765 const rosBridgePort = 8765
const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code') const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code')
@@ -199,6 +199,14 @@ type RuntimeImageArtifact = {
required?: boolean | null 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 = { type ModuleInstallOptions = {
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
runtimeImages?: RuntimeImageArtifact[] | null runtimeImages?: RuntimeImageArtifact[] | null
@@ -236,6 +244,10 @@ type WorldLayoutObject = {
type WorldLayout = { type WorldLayout = {
version: 1 version: 1
bounds: {
width: number
depth: number
}
objects: WorldLayoutObject[] objects: WorldLayoutObject[]
} }
@@ -255,6 +267,7 @@ type SavedMapArtifactDownload = {
function defaultWorldLayout(): WorldLayout { function defaultWorldLayout(): WorldLayout {
return { return {
version: 1, version: 1,
bounds: { width: 10, depth: 10 },
objects: [] objects: []
} }
} }
@@ -278,7 +291,14 @@ function normalizeWorldLayout(value: unknown): WorldLayout {
size: tuple(object.size, [1, 1, 1]) size: tuple(object.size, [1, 1, 1])
}] }]
}).filter((object) => !['wall-1', 'pillar-1'].includes(object.id)) }).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 { function worldLayoutPath(workspaceRoot?: string | null): string {
@@ -352,7 +372,11 @@ async function syncWorkspaceSavedMapArtifacts(
} }
await Promise.all(artifacts.map(async (artifact) => { await Promise.all(artifacts.map(async (artifact) => {
const normalized = validateSavedMapDownload(artifact) const normalized = validateSavedMapDownload(artifact)
await downloadArtifact(normalized.url, resolve(mapsDirectory, normalized.fileName)) const targetPath = resolve(mapsDirectory, normalized.fileName)
// SLAM containers can leave an artifact owned by root. Removing it first
// lets the download recreate the file with the desktop user's ownership.
await rm(targetPath, { force: true })
await downloadArtifact(normalized.url, targetPath)
})) }))
return { ok: true } return { ok: true }
} }
@@ -762,6 +786,17 @@ function resolveNav2RuntimeImage(
return nav2Image || nav2DockerImage 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> { async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise<string> {
try { try {
const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown } 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 targetPath = artifactCachePath(artifactUrl, fileName)
const expected = checksumSha256?.trim().toLowerCase() 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) { if (expected) {
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase() let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
if (actual !== expected) { 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( async function syncRemoteSavedMapArtifacts(
targetInput: RemoteWorkspaceTarget, targetInput: RemoteWorkspaceTarget,
artifacts: SavedMapArtifactDownload[], artifacts: SavedMapArtifactDownload[],
@@ -1347,7 +1425,13 @@ async function syncRemoteSavedMapArtifacts(
`ROOT=${quoteShellSingle(target.workspaceRoot)}`, `ROOT=${quoteShellSingle(target.workspaceRoot)}`,
`MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`, `MAPS_DIRECTORY=${quoteShellSingle(mapsDirectory)}`,
'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then', 'if [ ! -d "$MAPS_DIRECTORY" ] || [ ! -w "$MAPS_DIRECTORY" ]; then',
' IMAGE="$(docker image inspect future-nav2:humble-jetson_orin --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble-jetson_orin || docker image inspect future-nav2:humble --format {{.Id}} >/dev/null 2>&1 && printf future-nav2:humble || true)"', ' if docker image inspect future-nav2:humble-jetson_orin >/dev/null 2>&1; then',
' IMAGE="future-nav2:humble-jetson_orin"',
' elif docker image inspect future-nav2:humble >/dev/null 2>&1; then',
' IMAGE="future-nav2:humble"',
' else',
' IMAGE=""',
' fi',
' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi', ' if [ -z "$IMAGE" ]; then echo "저장된 맵 디렉터리에 쓸 권한이 없고 권한 복구용 NAV2 이미지를 찾을 수 없습니다." >&2; exit 1; fi',
' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"', ' docker run --rm --user 0:0 -v "$ROOT:/workspace" --entrypoint /bin/sh "$IMAGE" -lc "mkdir -p /workspace/maps && chown -R $(id -u):$(id -g) /workspace/maps"',
'fi', 'fi',
@@ -1460,7 +1544,7 @@ async function startRemoteVisualizationBridge(
`-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`, `-e ROBOT_STACK=${quoteShellSingle(stack === 'autoware' ? 'autoware' : 'nav2')}`,
'-e ODOM_TOPIC=/odom -e SCAN_TOPIC=/scan -e MAP_TOPIC=/map -e CMD_VEL_TOPIC=/cmd_vel -e NAVIGATE_TO_POSE_ACTION=/navigate_to_pose', '-e ODOM_TOPIC=/odom -e SCAN_TOPIC=/scan -e MAP_TOPIC=/map -e CMD_VEL_TOPIC=/cmd_vel -e NAVIGATE_TO_POSE_ACTION=/navigate_to_pose',
...(stack === 'slam3d' ...(stack === 'slam3d'
? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e READ_ONLY=true -e RTABMAP_MAP_SAVE_ENABLED=true'] ? ['-e POINT_CLOUD_TOPIC=/cloud_map -e FALLBACK_POINT_CLOUD_TOPIC=/lidar_filtered -e ICP_ODOM_TOPIC=/icp_odom -e POINT_CLOUD_LIMIT=50000 -e RTABMAP_MAP_SAVE_ENABLED=true']
: []), : []),
bridgeEnvArgs, bridgeEnvArgs,
'-e WORLD_LAYOUT_PATH=/workspace/world-layout.json -e SAVED_MAPS_PATH=/workspace/maps -e RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db', '-e WORLD_LAYOUT_PATH=/workspace/world-layout.json -e SAVED_MAPS_PATH=/workspace/maps -e RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
@@ -2450,7 +2534,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string {
return [ return [
'source /opt/ros/humble/setup.bash', 'source /opt/ros/humble/setup.bash',
setupWorkspace, setupWorkspace,
`python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath}`, `(python3 -c "import fastapi, uvicorn, websockets" || python3 -m pip install --no-cache-dir -r ${requirementPath})`,
`python3 ${bridgePath}` `python3 ${bridgePath}`
].filter(Boolean).join(' && ') ].filter(Boolean).join(' && ')
} }
@@ -2458,7 +2542,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string {
function normalizeVisualizationStack(value?: string | null): VisualizationStack { function normalizeVisualizationStack(value?: string | null): VisualizationStack {
if (value === 'slam') return 'slam' if (value === 'slam') return 'slam'
if (value === 'slam3d') return 'slam3d' 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> { async function readContainerEnvironmentValue(containerName: string, key: string): Promise<string | null> {
@@ -2673,10 +2757,7 @@ function withRuntimeProfileDefaults(
if (!profile) return profile if (!profile) return profile
const target = normalizeRuntimeTarget(profile.target) const target = normalizeRuntimeTarget(profile.target)
const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}` const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}`
const dedicatedReadOnlyBridge = profile.type?.toLowerCase() === 'real_slam_3d' const bridgeImage = envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image
const bridgeImage = dedicatedReadOnlyBridge
? profile.bridge?.image
: envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image
const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`) const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`)
const agentUrl = target === 'remote-jetson' const agentUrl = target === 'remote-jetson'
? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url ? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url
@@ -2739,8 +2820,9 @@ async function startVisualizationBridge(
rosDomainId?: string | null, rosDomainId?: string | null,
environmentProfile?: EnvironmentProfileConfig | null, environmentProfile?: EnvironmentProfileConfig | null,
rosNetwork?: RosNetworkConfig | null, rosNetwork?: RosNetworkConfig | null,
savedMapDatabasePath?: string | null,
): Promise<VisualizationBridgeStatus> { ): Promise<VisualizationBridgeStatus> {
const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput) const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
const target = normalizeRuntimeTarget(resolvedProfile?.target) const target = normalizeRuntimeTarget(resolvedProfile?.target)
const bridge = resolvedProfile?.bridge const bridge = resolvedProfile?.bridge
@@ -2819,7 +2901,7 @@ async function startVisualizationBridge(
'-e', '-e',
`ROS_DOMAIN_ID=${requestedRosDomainId}`, `ROS_DOMAIN_ID=${requestedRosDomainId}`,
'-e', '-e',
'ROS_LOCALHOST_ONLY=0', `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
'-e', '-e',
`AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`, `AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`,
'-e', '-e',
@@ -2840,6 +2922,10 @@ async function startVisualizationBridge(
'-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db', '-e', 'RTABMAP_DATABASE_PATH=/workspace/maps/rtabmap-active.db',
) )
if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true') if (stack === 'slam3d') args.push('-e', 'RTABMAP_MAP_SAVE_ENABLED=true')
const savedMapFileName = savedMapDatabasePath ? safeSavedMapFileName(basename(savedMapDatabasePath)) : null
if (savedMapFileName?.endsWith('.db') && existsSync(resolve(layoutWorkspaceRoot, 'maps', savedMapFileName))) {
args.push('-e', `SAVED_MAP_DATABASE_PATH=/workspace/maps/${savedMapFileName}`)
}
} }
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId) const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, requestedRosDomainId)
if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) { if (fastDdsConfigPath && existsSync(fastDdsConfigPath)) {
@@ -3096,13 +3182,21 @@ async function installRemoteWorkspaceModule(
' if not url: continue', ' if not url: continue',
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"', ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"',
' path = os.path.join(cache, name)', ' path = os.path.join(cache, name)',
' if not os.path.exists(path):', ' # Stable latest URLs must not reuse a same-named local artifact.',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', ' if os.path.exists(path): os.unlink(path)',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' expected = (item.get("checksumSha256") or "").strip().lower()', ' expected = (item.get("checksumSha256") or "").strip().lower()',
' if expected:', ' if expected:',
' import hashlib', ' import hashlib',
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', ' def checksum(file_path):',
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")', ' with open(file_path, "rb") as fp: return hashlib.sha256(fp.read()).hexdigest().lower()',
' actual = checksum(path)',
' if actual != expected:',
' # Stable artifact URLs may have a newer object than the local cache.',
' os.unlink(path)',
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
' actual = checksum(path)',
' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")',
' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)', ' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)',
' for entry in entries.splitlines():', ' for entry in entries.splitlines():',
' entry = entry.strip()', ' entry = entry.strip()',
@@ -3470,6 +3564,7 @@ function buildNav2DockerRunScript(): string {
'GPU_ENABLED=${6:-false}', 'GPU_ENABLED=${6:-false}',
'shift 6 || true', 'shift 6 || true',
'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}', 'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}',
'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}',
'LOCALIZATION_MODE=slam', 'LOCALIZATION_MODE=slam',
'filtered_args=()', 'filtered_args=()',
'for arg in "$@"; do', 'for arg in "$@"; do',
@@ -3512,13 +3607,13 @@ function buildNav2DockerRunScript(): string {
'esac', 'esac',
'fi', 'fi',
'', '',
'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* ]]; then', 'if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* || "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* ]]; then',
' mkdir -p "$MODULE_ROOT/maps"', ' mkdir -p "$MODULE_ROOT/maps"',
' has_database_path=false', ' has_database_path=false',
' for arg in "$@"; do', ' for arg in "$@"; do',
' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi', ' if [[ "$arg" == database_path:=* ]]; then has_database_path=true; break; fi',
' done', ' done',
' if [ "$has_database_path" = false ]; then', ' if [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-slam3d* && "$has_database_path" = false ]] || [[ "$MODULE_FOLDER" == *lms-nav2-gazebo-localization* && "$has_database_path" = false ]]; then',
' set -- "database_path:=/modules/maps/rtabmap-active.db" "$@"', ' set -- "database_path:=/modules/maps/rtabmap-active.db" "$@"',
' fi', ' fi',
'fi', 'fi',
@@ -3530,6 +3625,7 @@ function buildNav2DockerRunScript(): string {
' --rm -d --network host --ipc=host', ' --rm -d --network host --ipc=host',
' --name "$CONTAINER_NAME"', ' --name "$CONTAINER_NAME"',
' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"', ' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"',
' -e "ROS_LOCALHOST_ONLY=$ROS_LOCALHOST_ONLY_VALUE"',
' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"', ' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"',
' -e "MODULE_ROOT=/modules"', ' -e "MODULE_ROOT=/modules"',
' -e "MODULE_FOLDER=$MODULE_FOLDER"', ' -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)', ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
'fi', 'fi',
'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then', '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', 'fi',
'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi', 'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi',
'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then', 'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then',
@@ -3621,11 +3717,19 @@ function buildNav2DockerBuildScript(): string {
' set -e', ' set -e',
' source /opt/ros/humble/setup.bash', ' source /opt/ros/humble/setup.bash',
' mapfile -t paths < <(colcon list --base-paths src --paths-only)', ' mapfile -t paths < <(colcon list --base-paths src --paths-only)',
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \\", ' ROSDEP_SKIP_ARGS=()',
' if ! apt-cache show ros-humble-gazebo-ros >/dev/null 2>&1; then',
' ROSDEP_SKIP_ARGS=(--skip-keys gazebo_ros)',
' fi',
" rosdep install --from-paths \"${paths[@]}\" --ignore-src \"${ROSDEP_SKIP_ARGS[@]}\" \\",
" --dependency-types buildtool --dependency-types build \\", " --dependency-types buildtool --dependency-types build \\",
' --dependency-types build_export --dependency-types exec -r -y', ' --dependency-types build_export --dependency-types exec -r -y',
' BUILD_TARGETS=("$2")',
' if [[ "$1" == *lms-nav2-gazebo-slam* && "$1" != *lms-nav2-gazebo-slam3d* ]]; then',
' BUILD_TARGETS=(slam_pkg slam2d_pkg slam3d_pkg)',
' fi',
" colcon --log-base log build --base-paths src --build-base build --install-base install \\", " colcon --log-base log build --base-paths src --build-base build --install-base install \\",
' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "$2"', ' --executor sequential --cmake-args -DBUILD_TESTING=OFF --packages-up-to "${BUILD_TARGETS[@]}"',
"' bash \"$MODULE_FOLDER\" \"$TARGET\"", "' bash \"$MODULE_FOLDER\" \"$TARGET\"",
'' ''
].join('\n') ].join('\n')
@@ -3785,6 +3889,22 @@ async function findRunningNav2SimulatorContainer(): Promise<string | null> {
} }
async function findNav2SimulatorRobotModel(containerName: string): Promise<string> { async function findNav2SimulatorRobotModel(containerName: string): Promise<string> {
// Query Gazebo directly first. The ROS GetModelList service can be slow or
// unavailable while DDS participants are still discovering each other, even
// though the simulator itself is already able to accept a pose update.
const knownModels = ['mkmini_gazebo', 'turtlebot3_burger_3d_lidar', 'turtlebot3_burger']
for (const modelName of knownModels) {
const pose = await runCommandOutput(
'docker',
['exec', containerName, 'gz', 'model', '-m', modelName, '-p'],
undefined,
undefined,
5_000,
).catch(() => '')
const values = pose.trim().split(/\s+/).map(Number)
if (values.length === 6 && values.every(Number.isFinite)) return modelName
}
const output = await runCommandOutput( const output = await runCommandOutput(
'docker', 'docker',
[ [
@@ -3798,7 +3918,7 @@ async function findNav2SimulatorRobotModel(containerName: string): Promise<strin
const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? '' const modelList = output.match(/model_names=\[([^\]]*)\]/)?.[1] ?? ''
const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1]) const modelNames = Array.from(modelList.matchAll(/'([^']+)'/g), (match) => match[1])
const robotModel = modelNames.find((modelName) => ( const robotModel = modelNames.find((modelName) => (
/turtlebot|burger|robot/i.test(modelName) && !/world/i.test(modelName) /turtlebot|burger|mkmini|mk-mini|robot/i.test(modelName) && !/world/i.test(modelName)
)) ))
if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.') if (!robotModel) throw new Error('Gazebo 로봇 모델을 찾을 수 없습니다.')
return robotModel return robotModel
@@ -3815,6 +3935,10 @@ async function setNav2SimulatorPose(
const containerName = await findRunningNav2SimulatorContainer() const containerName = await findRunningNav2SimulatorContainer()
if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.') if (!containerName) throw new Error('실행 중인 NAV2 Simulator를 찾을 수 없습니다.')
const robotModel = await findNav2SimulatorRobotModel(containerName) const robotModel = await findNav2SimulatorRobotModel(containerName)
// MK-mini's model origin is higher than TurtleBot's. Teleporting it at the
// TurtleBot ground height puts its chassis and wheels into the floor, which
// makes Gazebo eject or tip the vehicle while resolving the collision.
const spawnHeight = /^mkmini/i.test(robotModel) ? '0.16' : '0.01'
await runCommand( await runCommand(
'docker', 'docker',
@@ -3823,7 +3947,7 @@ async function setNav2SimulatorPose(
'gz', 'model', '-m', robotModel, 'gz', 'model', '-m', robotModel,
'-x', String(x), '-x', String(x),
'-y', String(y), '-y', String(y),
'-z', '0.01', '-z', spawnHeight,
'-R', '0', '-R', '0',
'-P', '0', '-P', '0',
'-Y', String(yaw), '-Y', String(yaw),
@@ -3910,7 +4034,6 @@ async function startWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName) const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
await prepareRuntimeImages(runtimeImages)
if (isNav2ModuleFolder(targetFolderName)) { if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets() await ensureNav2RuntimeAssets()
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId) const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId)
@@ -3920,6 +4043,7 @@ async function startWorkspaceModule(
moduleRoot, moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
) )
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
await runCommand( await runCommand(
'bash', 'bash',
[ [
@@ -3933,12 +4057,16 @@ async function startWorkspaceModule(
...splitRosParams(rosParams) ...splitRosParams(rosParams)
], ],
undefined, undefined,
fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : undefined, {
ROS_LOCALHOST_ONLY: rosNetworkEnabled(rosNetwork) ? '0' : '1',
...(fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : {}),
},
30000 30000
) )
return { ok: true, message: '실행 요청을 보냈습니다.' } return { ok: true, message: '실행 요청을 보냈습니다.' }
} }
await prepareRuntimeImages(runtimeImages)
await ensureStudioRuntimeAssets() await ensureStudioRuntimeAssets()
const scriptPath = resolveAutowareDockerRunScript() const scriptPath = resolveAutowareDockerRunScript()
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
@@ -3993,7 +4121,6 @@ async function buildWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName) const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
await prepareRuntimeImages(runtimeImages)
if (isNav2ModuleFolder(targetFolderName)) { if (isNav2ModuleFolder(targetFolderName)) {
await ensureNav2RuntimeAssets() await ensureNav2RuntimeAssets()
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
@@ -4001,6 +4128,7 @@ async function buildWorkspaceModule(
moduleRoot, moduleRoot,
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
) )
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
await runCommand( await runCommand(
'bash', 'bash',
[ [
@@ -4017,6 +4145,7 @@ async function buildWorkspaceModule(
return { ok: true, message: '빌드가 완료되었습니다.' } return { ok: true, message: '빌드가 완료되었습니다.' }
} }
await prepareRuntimeImages(runtimeImages)
await runCommand( await runCommand(
'docker', 'docker',
[ [
@@ -4035,6 +4164,62 @@ async function buildWorkspaceModule(
return { ok: true, message: '빌드가 완료되었습니다.' } 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( async function startRemoteWorkspaceModule(
targetInput: RemoteWorkspaceTarget, targetInput: RemoteWorkspaceTarget,
moduleName: string, moduleName: string,
@@ -4051,17 +4236,6 @@ async function startRemoteWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName) const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') 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, [ const existsOutput = await runSshOutput(target, [
`ROOT=${quoteShellSingle(target.workspaceRoot)}`, `ROOT=${quoteShellSingle(target.workspaceRoot)}`,
...expandRemotePathVar('ROOT'), ...expandRemotePathVar('ROOT'),
@@ -4083,43 +4257,7 @@ async function startRemoteWorkspaceModule(
? await ensureRemoteNav2RuntimeAssets(target) ? await ensureRemoteNav2RuntimeAssets(target)
: await ensureRemoteStudioRuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target)
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
await runSsh( await prepareRemoteRuntimeImages(target, runtimeImages)
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
)
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) 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 (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
if (currentStatus.trim()) { if (currentStatus.trim()) {
@@ -4168,9 +4306,11 @@ async function startRemoteWorkspaceModule(
...expandRemotePathVar('ROOT'), ...expandRemotePathVar('ROOT'),
`PARAMS=${quoteShellSingle(remoteRosParams)}`, `PARAMS=${quoteShellSingle(remoteRosParams)}`,
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
...expandRemotePathVar('FASTDDS_CONFIG_PATH'), ...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
'read -r -a EXTRA_ARGS <<< "$PARAMS"', 'read -r -a EXTRA_ARGS <<< "$PARAMS"',
'export FASTDDS_CONFIG_PATH', '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[@]}"` `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') ].join('\n')
: [ : [
@@ -4209,6 +4349,7 @@ async function buildRemoteWorkspaceModule(
targetFolderName, targetFolderName,
await resolveRemoteNav2RuntimeImage(runtimeImages), await resolveRemoteNav2RuntimeImage(runtimeImages),
) )
await prepareRemoteRuntimeImages(target, runtimeImages)
await runSsh(target, [ await runSsh(target, [
'set -e', 'set -e',
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
@@ -5141,6 +5282,9 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ( ipcMain.handle('remote-workspace:maps:read-artifacts', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => (
readRemoteSavedMapArtifacts(target, artifacts) readRemoteSavedMapArtifacts(target, artifacts)
)) ))
ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => (
copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath)
))
ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ( ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => (
syncRemoteSavedMapArtifacts(target, artifacts) syncRemoteSavedMapArtifacts(target, artifacts)
)) ))
@@ -5204,7 +5348,7 @@ function registerLocalWorkspaceHandlers(): void {
return { ok: true } return { ok: true }
}) })
ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('local-workspace:visualization:status', () => readVisualizationBridgeStatus())
ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork)) ipcMain.handle('local-workspace:visualization:start', (_event, stack?: string | null, workspaceRoot?: string | null, rosDomainId?: string | null, environmentProfile?: EnvironmentProfileConfig | null, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => startVisualizationBridge(stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath))
ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile)) ipcMain.handle('local-workspace:visualization:stop', (_event, environmentProfile?: EnvironmentProfileConfig | null) => stopVisualizationBridge(environmentProfile))
ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath)) ipcMain.handle('local-workspace:git:status', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, selectedFilePath?: string | null) => readWorkspaceGitStatus(moduleName, workspaceRoot, folderName, selectedFilePath))
ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl)) ipcMain.handle('local-workspace:git:submit', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null, repositoryUrl?: string | null) => submitWorkspaceGitChanges(moduleName, workspaceRoot, folderName, message, authorName, authorEmail, submissionId, repositoryUrl))
+2 -1
View File
@@ -220,7 +220,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
}, },
visualization: { visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'), status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork), start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath),
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile) stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
}, },
git: { git: {
@@ -266,6 +266,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
}, },
maps: { maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts), readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts) sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
}, },
modules: { modules: {
Binary file not shown.
File diff suppressed because it is too large Load Diff
+173 -97
View File
@@ -43,7 +43,7 @@ import WorkspaceLogView from './features/workspace/WorkspaceLogView'
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu' import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
import ModuleListView from './features/workspace/ModuleListView' import ModuleListView from './features/workspace/ModuleListView'
import WorldEditorModal from './features/workspace/WorldEditorModal' 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 WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -85,6 +85,11 @@ type IdeActivity = WorkspaceActivity
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide' 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( function resolveModuleRuntimeImages(
profiles: EnvironmentProfile[], profiles: EnvironmentProfile[],
remoteWorkspace: boolean, remoteWorkspace: boolean,
@@ -93,7 +98,7 @@ function resolveModuleRuntimeImages(
if (!remoteWorkspace) return activeRuntimeImages if (!remoteWorkspace) return activeRuntimeImages
const images = profiles 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 : []) .flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
.filter((image): image is RuntimeImageArtifact => ( .filter((image): image is RuntimeImageArtifact => (
typeof image === 'object' typeof image === 'object'
@@ -428,6 +433,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentProfiles, platformEnvironmentProfiles,
platformEnvironmentConfigOverride, platformEnvironmentConfigOverride,
platformOptions, platformOptions,
workspaceVisible,
skipInitialLoading, skipInitialLoading,
visualizationOpen, visualizationOpen,
onVisualizationOpenChange, onVisualizationOpenChange,
@@ -476,6 +482,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentProfiles?: EnvironmentProfile[] platformEnvironmentProfiles?: EnvironmentProfile[]
platformEnvironmentConfigOverride?: Record<string, unknown> | null platformEnvironmentConfigOverride?: Record<string, unknown> | null
platformOptions: PlatformOption[] platformOptions: PlatformOption[]
workspaceVisible: boolean
skipInitialLoading?: boolean skipInitialLoading?: boolean
visualizationOpen: boolean visualizationOpen: boolean
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean> onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
@@ -527,6 +534,7 @@ function LocalPcWorkspaceMockup({
clearQaConversation clearQaConversation
} = useQaChat({ } = useQaChat({
accessToken, accessToken,
courseId: courseKey,
weekNumber, weekNumber,
apiBaseUrl: LMS_API_BASE_URL, apiBaseUrl: LMS_API_BASE_URL,
socketBaseUrl: LMS_SOCKET_ORIGIN, socketBaseUrl: LMS_SOCKET_ORIGIN,
@@ -803,18 +811,33 @@ function LocalPcWorkspaceMockup({
const sessionWorldMaps = useMemo(() => { const sessionWorldMaps = useMemo(() => {
const configuredMaps = (curriculumWeek.maps ?? []) const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2') .filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
const baseMaps = [...configuredMaps] const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
const platformSlamMap: WorkspaceMapOption = { const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap
key: 'nav2-platform-slam', ? [...configuredMaps]
platformKey: 'NAV2', : [...configuredMaps, customMap]
name: '새 SLAM', const platformSlamMaps: WorkspaceMapOption[] = [
description: '플랫폼 LiDAR로 새 지도를 생성합니다.', {
worldFile: '', key: 'nav2-platform-slam-2d',
viewerModel: 'turtlebot3_world', platformKey: 'NAV2',
remoteSlam: true, name: '새 2D SLAM',
runtimeKind: 'PLATFORM_2D', description: '2D LiDAR로 새 지도를 생성합니다.',
} worldFile: '',
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps] 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>() const keys = new Set<string>()
return maps.filter((map) => { return maps.filter((map) => {
if (keys.has(map.key)) return false if (keys.has(map.key)) return false
@@ -826,19 +849,27 @@ function LocalPcWorkspaceMockup({
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey) const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey) ?? window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey) ?? 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 if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return
setSelectedWorldMapKey((current) => { setSelectedWorldMapKey((current) => {
const next = sessionWorldMaps.some((map) => map.key === current) const next = sessionWorldMaps.some((map) => map.key === current)
? current ? current
: sessionWorldMaps.some((map) => map.key === storedMapKey) : sessionWorldMaps.some((map) => map.key === normalizedStoredMapKey)
? storedMapKey! ? normalizedStoredMapKey!
: sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world' : sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world'
window.localStorage.setItem(sessionMapStorageKey, next) window.localStorage.setItem(sessionMapStorageKey, next)
return next return next
}) })
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey]) }, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey])
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0] const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0]
const prepareVisualizationSavedMapRef = useRef<(
() => Promise<{ savedMapDatabasePath?: string }>
) | null>(null)
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D' const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D' || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap?.remoteSlam === true || selectedWorldMap?.remoteSlam === true
@@ -1004,6 +1035,11 @@ function LocalPcWorkspaceMockup({
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`, sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key, mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
platform: visualizationPlatform, platform: visualizationPlatform,
threeDimensional: Boolean(
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
),
savedMapDatabasePath: selectedWorldMap.databasePath,
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D' visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D' || selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap.remoteSlam || selectedWorldMap.remoteSlam
@@ -1023,15 +1059,18 @@ function LocalPcWorkspaceMockup({
if (initializedVisualizationMapRef.current === mapIdentity) return if (initializedVisualizationMapRef.current === mapIdentity) return
initializedVisualizationMapRef.current = mapIdentity initializedVisualizationMapRef.current = mapIdentity
const navigationMap = Boolean(selectedWorldMap?.saved) const navigationMap = Boolean(selectedWorldMap?.saved)
const threeDimensionalMap = selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
// Saved RTAB-Map databases are navigable maps. Keep the 3D viewer while
// enabling the same pose and goal controls used by NAV2 navigation.
const nextMode = navigationMap ? 'navigation' : 'slam' const nextMode = navigationMap ? 'navigation' : 'slam'
const nextStack = navigationMap const nextStack = threeDimensionalMap
? 'nav2' ? 'slam3d'
: selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D' : navigationMap
? 'slam3d' ? 'nav2'
: 'slam' : 'slam'
if (visualizationStack !== nextStack) setVisualizationStack(nextStack) if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode) if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
}, [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']>>(() => ( const [customModules, setCustomModules] = useState<NonNullable<CurriculumWeek['modules']>>(() => (
(curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM') (curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM')
)) ))
@@ -1064,7 +1103,7 @@ function LocalPcWorkspaceMockup({
const resolvedModules = resolveRuntimeModules( const resolvedModules = resolveRuntimeModules(
curriculumModulesWithCustom, curriculumModulesWithCustom,
remoteWorkspace remoteWorkspace
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase())) ? visualizationProfiles.filter(isRemoteEnvironmentProfile)
: activeEnvironmentProfile, : activeEnvironmentProfile,
remoteWorkspace ? 'platform' : 'local' remoteWorkspace ? 'platform' : 'local'
) ?? [] ) ?? []
@@ -1108,7 +1147,12 @@ function LocalPcWorkspaceMockup({
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps?courseId=${encodeURIComponent(courseKey)}&week=${weekNumber}`) const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/maps?courseId=${encodeURIComponent(courseKey)}&week=${weekNumber}`)
if (!response.ok) throw new Error(`saved_maps_failed_${response.status}`) if (!response.ok) throw new Error(`saved_maps_failed_${response.status}`)
const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }> const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }>
setSavedSlamMaps(maps.map((map) => ({ setSavedSlamMaps(maps.map((map) => {
const isThreeDimensional = map.runtimeKind === 'PLATFORM_3D'
const databasePath = isThreeDimensional
? (map.databasePath ?? map.yamlPath.replace(/\.yaml$/i, '.db'))
: undefined
return {
key: `saved-slam-${map.id}`, key: `saved-slam-${map.id}`,
platformKey: 'NAV2', platformKey: 'NAV2',
name: map.name, name: map.name,
@@ -1124,11 +1168,12 @@ function LocalPcWorkspaceMockup({
yamlUrl: map.yamlUrl ?? undefined, yamlUrl: map.yamlUrl ?? undefined,
pgmUrl: map.pgmUrl ?? undefined, pgmUrl: map.pgmUrl ?? undefined,
previewPgm: map.previewPgm ?? undefined, previewPgm: map.previewPgm ?? undefined,
databasePath: map.databasePath ?? undefined, databasePath,
databaseUrl: map.databaseUrl ?? undefined, databaseUrl: map.databaseUrl ?? undefined,
runtimeKind: map.runtimeKind runtimeKind: map.runtimeKind
?? (map.databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'), ?? (map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
}))) }
}))
} catch (error) { } catch (error) {
console.warn('[studio] saved SLAM maps load failed', error) console.warn('[studio] saved SLAM maps load failed', error)
} finally { } finally {
@@ -1150,20 +1195,28 @@ function LocalPcWorkspaceMockup({
message?: string message?: string
} }
if (payload?.source !== 'autonomy-slam-viewer') return if (payload?.source !== 'autonomy-slam-viewer') return
if (payload.type === 'navigation-init') { if (payload.type === 'simulator-pose-request' && payload.requestId && payload.pose) {
const isPlatformMap = selectedWorldMap.runtimeKind === 'PLATFORM_2D' const reply = (ok: boolean, message?: string) => {
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D' const target = event.source as Window | null
|| selectedWorldMap.remoteSlam target?.postMessage({
// Only the Gazebo simulation has a model pose to reset. A platform source: 'autonomy-studio',
// map can also be opened from the local workspace, where this IPC type: 'simulator-pose-result',
// would incorrectly fail because no Simulator container is running. requestId: payload.requestId,
if (remoteWorkspace || isPlatformMap) return ok,
void Promise.all([ message,
window.localWorkspace.nav2.setSimulatorPose({ x: -2, y: -0.5, yaw: 0 }), }, '*')
window.localWorkspace.nav2.clearRuntimeObstacles(), }
]).catch((error) => { if (remoteWorkspace) {
console.warn('[studio] nav2 initialization failed', error) reply(false, '원격 플랫폼의 로봇 위치는 시각화에서 변경할 수 없습니다.')
onTitlebarNotice('NAV2 초기화에 실패했습니다.', 'error') return
}
void window.localWorkspace.nav2.setSimulatorPose({
x: Number(payload.pose.x),
y: Number(payload.pose.y),
yaw: Number(payload.pose.yaw ?? 0),
}).then(() => reply(true)).catch((error) => {
console.warn('[studio] nav2 simulator pose update failed', error)
reply(false, error instanceof Error ? error.message : '가제보 로봇 위치 적용에 실패했습니다.')
}) })
return return
} }
@@ -1191,42 +1244,6 @@ function LocalPcWorkspaceMockup({
}) })
return return
} }
if (payload.type === 'initial-pose-selected') {
const x = Number(payload.pose?.x)
const y = Number(payload.pose?.y)
const yaw = Number(payload.pose?.yaw ?? 0)
if (![x, y, yaw].every(Number.isFinite)) return
if (remoteWorkspace) {
;(event.source as WindowProxy | null)?.postMessage({
source: 'autonomy-lms-shell',
type: 'initial-pose-applied',
requestId: payload.requestId,
ok: true,
}, '*')
return
}
void window.localWorkspace.nav2.setSimulatorPose({ x, y, yaw })
.then(() => {
(event.source as WindowProxy | null)?.postMessage({
source: 'autonomy-lms-shell',
type: 'initial-pose-applied',
requestId: payload.requestId,
ok: true,
}, '*')
})
.catch((error) => {
console.warn('[studio] nav2 simulator pose update failed', error)
onTitlebarNotice('Gazebo 로봇 위치 적용에 실패했습니다.', 'error')
;(event.source as WindowProxy | null)?.postMessage({
source: 'autonomy-lms-shell',
type: 'initial-pose-applied',
requestId: payload.requestId,
ok: false,
message: error instanceof Error ? error.message : 'Gazebo 로봇 위치 적용에 실패했습니다.',
}, '*')
})
return
}
if (payload.type === 'map-save-failed') { if (payload.type === 'map-save-failed') {
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error') onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
return return
@@ -1234,27 +1251,45 @@ function LocalPcWorkspaceMockup({
if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return
void (async () => { void (async () => {
try { try {
let databasePath = payload.map.databasePath ?? null
const savesThreeDimensionalMap = remoteWorkspace
&& visualizationBridgeLocation === 'local'
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
if (savesThreeDimensionalMap && !databasePath) {
const databaseFileName = payload.map.yamlPath.replace(/\.yaml$/i, '.db')
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
getRemoteWorkspaceTarget(),
localVisualizationWorkspaceRoot,
databaseFileName,
)
databasePath = copied.databasePath
}
const sourceArtifacts = [ const sourceArtifacts = [
{ name: 'yaml' as const, path: payload.map.yamlPath }, { name: 'yaml' as const, path: payload.map.yamlPath },
{ name: 'pgm' as const, path: payload.map.pgmPath }, { name: 'pgm' as const, path: payload.map.pgmPath },
...(payload.map.databasePath ? [{ name: 'database' as const, path: payload.map.databasePath }] : []), ...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
] ]
const artifacts = remoteWorkspace // A remote ROS session can still use a bridge running on this PC.
// Read saved artifacts from the bridge host, not merely from the
// currently selected workspace.
const artifacts = remoteWorkspace && visualizationBridgeLocation === 'platform'
? await window.remoteWorkspace.maps.readArtifacts(getRemoteWorkspaceTarget(), sourceArtifacts) ? await window.remoteWorkspace.maps.readArtifacts(getRemoteWorkspaceTarget(), sourceArtifacts)
: await window.localWorkspace.maps.readArtifacts(clonePathPreview, sourceArtifacts) : await window.localWorkspace.maps.readArtifacts(
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
sourceArtifacts,
)
const form = new FormData() const form = new FormData()
form.set('courseId', courseKey) form.set('courseId', courseKey)
form.set('week', String(weekNumber)) form.set('week', String(weekNumber))
form.set('sourceMapKey', selectedWorldMap.key) form.set('sourceMapKey', selectedWorldMap.key)
form.set('worldFile', selectedWorldMap.worldFile) form.set('worldFile', selectedWorldMap.worldFile)
form.set('viewerModel', selectedWorldMap.viewerModel) form.set('viewerModel', selectedWorldMap.viewerModel)
form.set('runtimeKind', remoteWorkspace form.set('runtimeKind', selectedWorldMap.runtimeKind
? (payload.map.databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D') ?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? 'PLATFORM_2D' : 'SIMULATION'))
: 'SIMULATION')
form.set('yamlPath', payload.map.yamlPath) form.set('yamlPath', payload.map.yamlPath)
form.set('pgmPath', payload.map.pgmPath) form.set('pgmPath', payload.map.pgmPath)
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm) if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
if (payload.map.databasePath) form.set('databasePath', payload.map.databasePath) if (databasePath) form.set('databasePath', databasePath)
artifacts.forEach((artifact) => { artifacts.forEach((artifact) => {
const binary = atob(artifact.base64) const binary = atob(artifact.base64)
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)) const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
@@ -1281,7 +1316,7 @@ function LocalPcWorkspaceMockup({
} }
window.addEventListener('message', handleViewerMessage) window.addEventListener('message', handleViewerMessage)
return () => window.removeEventListener('message', handleViewerMessage) return () => window.removeEventListener('message', handleViewerMessage)
}, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, weekNumber]) }, [clonePathPreview, courseKey, fetchWithFreshAuth, getFreshAccessToken, getRemoteWorkspaceTarget, loadSavedSlamMaps, localVisualizationWorkspaceRoot, onTitlebarNotice, remoteWorkspace, selectedWorldMap.key, selectedWorldMap.remoteSlam, selectedWorldMap.runtimeKind, selectedWorldMap.viewerModel, selectedWorldMap.worldFile, visualizationBridgeLocation, weekNumber])
const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget()) const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget())
const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId) const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId)
const hasRestorableWorkspaceUi = Boolean( const hasRestorableWorkspaceUi = Boolean(
@@ -1290,7 +1325,10 @@ function LocalPcWorkspaceMockup({
) )
const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi) const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(!skipInitialLoading && hasRestorableWorkspaceUi)
const workspaceRestoreStartedAtRef = useRef(Date.now()) const workspaceRestoreStartedAtRef = useRef(Date.now())
const getModuleParameterStorageKey = (module: ModuleRow): string => makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name) const moduleParameterScope = remoteWorkspace ? `platform:${platformId}` : 'local'
const getModuleParameterStorageKey = (module: ModuleRow): string => (
makeModuleParameterKey(courseKey, weekNumber, module.id ?? module.folderName ?? module.name, moduleParameterScope)
)
const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => { const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => {
const storedParameters = readStoredModuleParameters() const storedParameters = readStoredModuleParameters()
return modules.map((module) => { return modules.map((module) => {
@@ -1304,7 +1342,7 @@ function LocalPcWorkspaceMockup({
: defaultParameters.length ? defaultParameters : module.parameters : defaultParameters.length ? defaultParameters : module.parameters
} }
}) })
}, [courseKey, runtimeModules, weekNumber]) }, [courseKey, moduleParameterScope, runtimeModules, weekNumber])
const curriculumModuleKey = runtimeModules const curriculumModuleKey = runtimeModules
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`) .map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}:${JSON.stringify(module.defaultParameters ?? [])}`)
.join('|') .join('|')
@@ -1312,6 +1350,7 @@ function LocalPcWorkspaceMockup({
availableModules, availableModules,
setAvailableModules, setAvailableModules,
markModuleState, markModuleState,
removeModule,
modulesLoading, modulesLoading,
moduleListError, moduleListError,
clearModuleListError, clearModuleListError,
@@ -2346,6 +2385,24 @@ function LocalPcWorkspaceMockup({
void applySourceSettings(normalizedSettings, false) 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 saveDirtyFilesForModule = async (module: ModuleRow): Promise<void> => {
const moduleRoot = getModuleFilePrefix(module.name) const moduleRoot = getModuleFilePrefix(module.name)
const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => ( const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => (
@@ -2408,16 +2465,21 @@ function LocalPcWorkspaceMockup({
artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl }) artifacts.push({ name: 'database', fileName: databaseFileName, url: selectedWorldMap.databaseUrl })
databasePath = `/workspace/maps/${databaseFileName}` databasePath = `/workspace/maps/${databaseFileName}`
} }
if (remoteWorkspace) { const syncToPlatformWorkspace = remoteWorkspace && visualizationBridgeLocation === 'platform'
if (syncToPlatformWorkspace) {
await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts) await window.remoteWorkspace.maps.sync(getRemoteWorkspaceTarget(), artifacts)
} else { } else {
await window.localWorkspace.maps.sync(clonePathPreview, artifacts) await window.localWorkspace.maps.sync(
remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
artifacts,
)
} }
return { return {
savedMapYamlPath: `/workspace/maps/${yamlFileName}`, savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
savedMapDatabasePath: databasePath, savedMapDatabasePath: databasePath,
} }
}, [clonePathPreview, getRemoteWorkspaceTarget, remoteWorkspace, selectedWorldMap]) }, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
prepareVisualizationSavedMapRef.current = prepareSavedMap
const vehicleRuntimeProfiles = useMemo(() => { const vehicleRuntimeProfiles = useMemo(() => {
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>() const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
platformOptions.forEach((platform) => { platformOptions.forEach((platform) => {
@@ -2447,6 +2509,8 @@ function LocalPcWorkspaceMockup({
weekNumber, weekNumber,
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
moduleParameterScope,
workspaceActive: workspaceVisible,
sourceSettings: runtimeSourceSettings, sourceSettings: runtimeSourceSettings,
rosNetwork: moduleRosNetworkConfig, rosNetwork: moduleRosNetworkConfig,
simulatorWorldFile: selectedWorldMap.worldFile, simulatorWorldFile: selectedWorldMap.worldFile,
@@ -2483,11 +2547,10 @@ function LocalPcWorkspaceMockup({
} }
if (map.saved) { if (map.saved) {
setNav2VisualizationMode('navigation') setNav2VisualizationMode('navigation')
setVisualizationStack('nav2') setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
} else { } else {
setNav2VisualizationMode('slam') setNav2VisualizationMode('slam')
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d') setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'slam')
else if (visualizationStacks.includes('slam')) setVisualizationStack('slam')
} }
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info') onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
} }
@@ -2575,7 +2638,11 @@ function LocalPcWorkspaceMockup({
delete nextRoots[module.name] delete nextRoots[module.name]
return nextRoots return nextRoots
}) })
await loadAvailableModules() if (module.sourceType === 'CUSTOM') {
removeModule(module)
} else {
markModuleState(module, 'uninstalled')
}
onTitlebarNotice(options.deleteRepository ? `[${module.name}] 모듈과 저장소가 삭제되었습니다` : `[${module.name}] 삭제되었습니다`, 'info') onTitlebarNotice(options.deleteRepository ? `[${module.name}] 모듈과 저장소가 삭제되었습니다` : `[${module.name}] 삭제되었습니다`, 'info')
} catch (error) { } catch (error) {
console.error('[modules] delete failed', error) console.error('[modules] delete failed', error)
@@ -3509,6 +3576,7 @@ function LocalPcWorkspaceMockup({
platformName={platformName} platformName={platformName}
configuredDraftWorkspaceRoot={configuredDraftWorkspaceRoot} configuredDraftWorkspaceRoot={configuredDraftWorkspaceRoot}
sourceSettingsDraft={sourceSettingsDraft} sourceSettingsDraft={sourceSettingsDraft}
saveDisabled={!sourceSettingsDirty}
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')} remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
onUpdateSourceSetting={updateSourceSetting} onUpdateSourceSetting={updateSourceSetting}
onSaveSourceSettings={saveSourceSettings} onSaveSourceSettings={saveSourceSettings}
@@ -3753,13 +3821,12 @@ function LocalPcWorkspaceMockup({
onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning') onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning')
return return
} }
setWorldMapSelectorOpen(false)
setWorldEditorOpen(true) setWorldEditorOpen(true)
}} }}
onDelete={(map) => void deleteSavedSlamMap(map)} onDelete={(map) => void deleteSavedSlamMap(map)}
onSelect={(map) => { onSelect={(map) => {
if (map.key === selectedWorldMapKey) return
setWorldMapSelectorOpen(false) setWorldMapSelectorOpen(false)
if (map.key === selectedWorldMapKey) return
if (availableModules.some((module) => module.state === 'running')) { if (availableModules.some((module) => module.state === 'running')) {
void applyWorldMapSelection(map) void applyWorldMapSelection(map)
setPendingWorldMapRestart(map) setPendingWorldMapRestart(map)
@@ -3779,7 +3846,14 @@ function LocalPcWorkspaceMockup({
{worldEditorOpen && ( {worldEditorOpen && (
<WorldEditorModal <WorldEditorModal
workspaceRoot={clonePathPreview} 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)} onClose={() => setWorldEditorOpen(false)}
/> />
)} )}
@@ -4178,6 +4252,7 @@ export default function App(): React.JSX.Element {
deleteAiConversation: deleteGuideAiConversation deleteAiConversation: deleteGuideAiConversation
} = useQaChat({ } = useQaChat({
accessToken: authSession?.accessToken ?? '', accessToken: authSession?.accessToken ?? '',
courseId: selectedCourseId,
weekNumber: 0, weekNumber: 0,
globalScope: true, globalScope: true,
apiBaseUrl: LMS_API_BASE_URL, apiBaseUrl: LMS_API_BASE_URL,
@@ -5605,6 +5680,7 @@ export default function App(): React.JSX.Element {
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles} platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride} platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
platformOptions={platformOptions} platformOptions={platformOptions}
workspaceVisible={localPcLayoutOpen}
skipInitialLoading={skipWorkspaceInitialLoading} skipInitialLoading={skipWorkspaceInitialLoading}
visualizationOpen={workspaceVisualizationOpen} visualizationOpen={workspaceVisualizationOpen}
onVisualizationOpenChange={setWorkspaceVisualizationOpen} onVisualizationOpenChange={setWorkspaceVisualizationOpen}
+6 -1
View File
@@ -235,7 +235,7 @@ interface Window {
stack: 'autoware' | 'nav2' | 'slam' | null stack: 'autoware' | 'nav2' | 'slam' | null
message?: string message?: string
}> }>
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{
ok: boolean ok: boolean
running: boolean running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null stack: 'autoware' | 'nav2' | 'slam' | null
@@ -280,6 +280,7 @@ interface Window {
} }
maps: { maps: {
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>> readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }> sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
} }
modules: { modules: {
@@ -347,6 +348,10 @@ type WorldLayoutObject = {
type WorldLayout = { type WorldLayout = {
version: 1 version: 1
bounds: {
width: number
depth: number
}
objects: WorldLayoutObject[] objects: WorldLayoutObject[]
} }
@@ -17,6 +17,8 @@ export type ModuleListRowItem = {
type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral' type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
let dismissActiveTooltip: (() => void) | null = null
type ModuleListRowProps<Module extends ModuleListRowItem> = { type ModuleListRowProps<Module extends ModuleListRowItem> = {
dark: boolean dark: boolean
module: Module 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 { function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
const anchorRef = useRef<HTMLSpanElement>(null) const anchorRef = useRef<HTMLSpanElement>(null)
const dismissTimerRef = useRef<number | null>(null)
const [position, setPosition] = useState<{ left: number; top: number } | null>(null) const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
useEffect(() => { useEffect(() => {
const anchor = anchorRef.current?.parentElement const anchor = anchorRef.current?.parentElement
if (!(anchor instanceof HTMLElement)) return 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 => { const showTooltip = (): void => {
dismissActiveTooltip?.()
clearDismissTimer()
const bounds = anchor.getBoundingClientRect() const bounds = anchor.getBoundingClientRect()
setPosition({ setPosition({
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))), left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
top: bounds.top - 8 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('mouseenter', showTooltip)
anchor.addEventListener('mouseleave', hideTooltip) anchor.addEventListener('mouseleave', hideTooltip)
anchor.addEventListener('focus', showTooltip) anchor.addEventListener('focus', showTooltip)
anchor.addEventListener('blur', hideTooltip) 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 () => { return () => {
anchor.removeEventListener('mouseenter', showTooltip) anchor.removeEventListener('mouseenter', showTooltip)
anchor.removeEventListener('mouseleave', hideTooltip) anchor.removeEventListener('mouseleave', hideTooltip)
anchor.removeEventListener('focus', showTooltip) anchor.removeEventListener('focus', showTooltip)
anchor.removeEventListener('blur', hideTooltip) 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 ( return (
<> <>
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" /> <span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
@@ -10,6 +10,7 @@ type Props = {
platformName: string platformName: string
configuredDraftWorkspaceRoot: string configuredDraftWorkspaceRoot: string
sourceSettingsDraft: StudioSourceSettings sourceSettingsDraft: StudioSourceSettings
saveDisabled: boolean
remotePlatforms: PlatformOption[] remotePlatforms: PlatformOption[]
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
onSaveSourceSettings: () => void onSaveSourceSettings: () => void
@@ -23,6 +24,7 @@ function WorkspaceSettingsView({
platformName, platformName,
configuredDraftWorkspaceRoot, configuredDraftWorkspaceRoot,
sourceSettingsDraft, sourceSettingsDraft,
saveDisabled,
remotePlatforms, remotePlatforms,
onUpdateSourceSetting, onUpdateSourceSetting,
onSaveSourceSettings onSaveSourceSettings
@@ -173,8 +175,9 @@ function WorkspaceSettingsView({
<div className="mt-8 flex justify-end"> <div className="mt-8 flex justify-end">
<button <button
type="button" type="button"
disabled={saveDisabled}
onClick={onSaveSourceSettings} 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> </button>
@@ -338,7 +338,7 @@ function WorkspaceSourceControlView({
onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)} onClick={() => onOpenGitChangedFile(selectedGitTarget.moduleName, selectedGitTarget.filePath)}
className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} className={['h-7 cursor-pointer rounded-md border px-3 text-[11px] font-bold transition', dark ? 'border-white/12 text-white/58 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
> >
파일 열기 열기
</button> </button>
)} )}
</div> </div>
@@ -11,7 +11,7 @@ type WorldObjectType = WorldLayoutObject['type']
type WorldEditorModalProps = { type WorldEditorModalProps = {
workspaceRoot: string workspaceRoot: string
onClose: () => void onClose: () => void
onSaved?: () => void onSaved?: (layout: WorldLayout) => void
} }
type TransformMode = 'translate' | 'rotate' | 'scale' 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 { export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
const canvasHostRef = useRef<HTMLDivElement>(null) const canvasHostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<HTMLIFrameElement>(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 selectedIdRef = useRef<string | null>(null)
const renderSceneRef = useRef<(() => void) | 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 [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
@@ -403,8 +403,16 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
setSelectedId(remaining[0]?.id ?? null) setSelectedId(remaining[0]?.id ?? null)
window.requestAnimationFrame(() => renderSceneRef.current?.()) 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 resetLayout = (): void => {
const next: WorldLayout = { version: 1, objects: [] } const next: WorldLayout = { version: 1, bounds: layout.bounds, objects: [] }
worldRef.current = next worldRef.current = next
setLayout(next) setLayout(next)
setSelectedId(null) setSelectedId(null)
@@ -416,7 +424,7 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
try { try {
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout) const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
setLayout(saved) setLayout(saved)
onSaved?.() onSaved?.(saved)
onClose() onClose()
} catch (cause) { } catch (cause) {
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.') setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
@@ -426,7 +434,7 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
} }
return ( 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)]"> <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"> <header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
<div> <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="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p> <p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
</div> </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"> <div className="min-h-0 flex-1 overflow-y-auto p-2">
{layout.objects.length ? layout.objects.map((item) => { {layout.objects.length ? layout.objects.map((item) => {
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box 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 && <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 && <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 left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] 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('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> <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>
<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) => { {maps.map((map) => {
const pending = map.key === pendingMap?.key const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey const selected = map.key === selectedMapKey
// "새 SLAM" is a launch target, not a saved map yet. Its 2D/3D
// runtime is chosen by the active SLAM stack, so only persisted
// map results receive a dimensional badge.
const mapDimension = map.saved
? (map.runtimeKind === 'PLATFORM_3D' ? '3D' : '2D')
: null
return ( return (
<button <button
key={map.key} key={map.key}
@@ -224,7 +253,15 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
> >
<WorkspaceMapPreview map={map} selected={pending} /> <WorkspaceMapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</span> <span className="flex min-w-0 items-center gap-2">
<span className="truncate text-[14px] font-bold">{map.name}</span>
{mapDimension && (
<span className={[
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-bold',
mapDimension === '3D' ? 'bg-cyan-400/15 text-cyan-200' : 'bg-white/10 text-white/55',
].join(' ')}>{mapDimension}</span>
)}
</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span> <span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
</span> </span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>} {map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
@@ -243,10 +280,10 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
{scope === 'simulation' && onCreate && ( {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"> <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" /> <PencilRuler className="size-3.5" />
맵 편집 커스텀 맵
</button> </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> </button>
</footer> </footer>
@@ -54,6 +54,11 @@ export function storeModuleParameters(key: string, parameters: ModuleParameterPa
} }
} }
export function makeModuleParameterKey(courseKey: string, week: number, folderName?: string): string { export function makeModuleParameterKey(
return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}` courseKey: string,
week: number,
folderName?: string,
workspaceScope = 'local'
): string {
return `${courseKey || 'default-course'}:week-${week}:${workspaceScope}:${folderName || 'module'}`
} }
@@ -52,6 +52,7 @@ type ChatNotificationPayload = {
type UseQaChatOptions = { type UseQaChatOptions = {
accessToken: string accessToken: string
courseId: string
weekNumber: number weekNumber: number
globalScope?: boolean globalScope?: boolean
apiBaseUrl: string apiBaseUrl: string
@@ -73,6 +74,7 @@ const qaVisibleMessageLimit = 20
export function useQaChat({ export function useQaChat({
accessToken, accessToken,
courseId,
weekNumber, weekNumber,
globalScope = false, globalScope = false,
apiBaseUrl, apiBaseUrl,
@@ -100,9 +102,12 @@ export function useQaChat({
const aiRevealCancelRef = useRef(false) const aiRevealCancelRef = useRef(false)
const expandedAiMessageIdsRef = useRef<Set<string>>(new Set()) const expandedAiMessageIdsRef = useRef<Set<string>>(new Set())
const normalizedCourseId = courseId.trim()
const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null const validChatWeek = globalScope ? null : Number.isInteger(weekNumber) && weekNumber >= 1 ? weekNumber : null
const chatAvailable = globalScope || validChatWeek !== null const chatAvailable = Boolean(normalizedCourseId) && (globalScope || validChatWeek !== null)
const chatWeekQuery = validChatWeek ? `&week=${validChatWeek}` : '' const chatQuery = new URLSearchParams({ courseId: normalizedCourseId })
if (validChatWeek) chatQuery.set('week', String(validChatWeek))
const chatQueryString = chatQuery.toString()
const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => { const requestChatApi = async <T,>(path: string, options?: RequestInit): Promise<T> => {
const requestAccessToken = accessToken ? await getFreshAccessToken() : null const requestAccessToken = accessToken ? await getFreshAccessToken() : null
@@ -166,7 +171,7 @@ export function useQaChat({
let cancelled = false let cancelled = false
setAiMessagesLoading(true) setAiMessagesLoading(true)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=ai${chatWeekQuery}&includeArchived=true`) requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=ai&includeArchived=true`)
.then((payload) => { .then((payload) => {
if (cancelled) return if (cancelled) return
const conversations = payload.threads.map((thread) => ({ const conversations = payload.threads.map((thread) => ({
@@ -191,7 +196,7 @@ export function useQaChat({
}) })
setTeacherMessagesLoading(true) setTeacherMessagesLoading(true)
requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?mode=teacher${chatWeekQuery}`) requestChatApi<{ threads: ChatThreadResponse[] }>(`/chat/threads?${chatQueryString}&mode=teacher`)
.then((payload) => { .then((payload) => {
if (cancelled) return if (cancelled) return
const thread = payload.threads[0] const thread = payload.threads[0]
@@ -211,7 +216,7 @@ export function useQaChat({
return () => { return () => {
cancelled = true cancelled = true
} }
}, [accessToken, chatAvailable, chatRefreshSignal, chatWeekQuery]) }, [accessToken, chatAvailable, chatQueryString, chatRefreshSignal])
useEffect(() => { useEffect(() => {
const target = workspaceChatScrollRef.current const target = workspaceChatScrollRef.current
@@ -264,7 +269,7 @@ export function useQaChat({
setAiPending(false) setAiPending(false)
void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', { void requestChatApi<{ message: ChatThreadResponse['messages'][number] | null }>('/chat/ai/cancel-active', {
method: 'POST', method: 'POST',
body: JSON.stringify(validChatWeek ? { week: validChatWeek } : {}) body: JSON.stringify({ courseId: normalizedCourseId, ...(validChatWeek ? { week: validChatWeek } : {}) })
}).catch(() => {}) }).catch(() => {})
setAiMessages((messages) => [ setAiMessages((messages) => [
...messages, ...messages,
@@ -294,7 +299,7 @@ export function useQaChat({
try { try {
const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', { const response = await requestChatApi<{ message: { id: string; content: string; createdAt: string } }>('/chat/messages', {
method: 'POST', method: 'POST',
body: JSON.stringify({ mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) }) body: JSON.stringify({ courseId: normalizedCourseId, mode: 'teacher', content, ...(validChatWeek ? { week: validChatWeek } : {}) })
}) })
setTeacherMessages((messages) => messages.map((message) => ( setTeacherMessages((messages) => messages.map((message) => (
message.id === userMessage.id message.id === userMessage.id
@@ -333,6 +338,7 @@ export function useQaChat({
const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', { const response = await requestChatApi<{ threadId: string; threadTitle?: string | null; message: { id: string; content: string; createdAt: string }; content: string; canceled?: boolean }>('/chat/ai/respond', {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
courseId: normalizedCourseId,
content, content,
...(validChatWeek ? { week: validChatWeek } : {}), ...(validChatWeek ? { week: validChatWeek } : {}),
...(options?.contextWeek ? { contextWeek: options.contextWeek } : {}) ...(options?.contextWeek ? { contextWeek: options.contextWeek } : {})
@@ -429,7 +435,7 @@ export function useQaChat({
setAiTypingMessageId(null) setAiTypingMessageId(null)
} }
await requestChatApi<{ deletedCount: number }>(`/chat/threads?mode=${qaMode}${chatWeekQuery}`, { await requestChatApi<{ deletedCount: number }>(`/chat/threads?${chatQueryString}&mode=${qaMode}`, {
method: 'DELETE' method: 'DELETE'
}) })
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ModuleRow } from './types' import type { ModuleRow } from './types'
import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils' import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
@@ -36,6 +36,7 @@ export function useSourceControl({
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('') const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false) const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
const [gitSubmitting, setGitSubmitting] = useState(false) const [gitSubmitting, setGitSubmitting] = useState(false)
const gitFileSelectionRequestRef = useRef(0)
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|') const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|')
const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|') const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|')
@@ -87,8 +88,30 @@ export function useSourceControl({
const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => { const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => {
const target = { moduleName, filePath } const target = { moduleName, filePath }
setSelectedGitTarget(target) setSelectedGitTarget(target)
await refreshGitStatuses(target) const cachedStatus = gitModuleStatuses[moduleName]
}, [refreshGitStatuses]) if (cachedStatus?.selectedFilePath === filePath) return
const module = installedGitModules.find((item) => item.name === moduleName)
if (!module) return
const requestId = ++gitFileSelectionRequestRef.current
setGitLoading(true)
setGitError(null)
try {
// Selecting a file used to refresh every module's status and history.
// Only the selected module needs its diff refreshed here.
const status = await readGitStatus(module, filePath)
if (requestId !== gitFileSelectionRequestRef.current) return
setGitModuleStatuses((statuses) => ({ ...statuses, [moduleName]: status }))
if (!status.ok) setGitError(status.message ?? '파일 변경 내용을 불러오지 못했습니다.')
} catch (error) {
if (requestId !== gitFileSelectionRequestRef.current) return
console.error('[git] selected file status failed', error)
setGitError(error instanceof Error ? error.message : '파일 변경 내용을 불러오지 못했습니다.')
} finally {
if (requestId === gitFileSelectionRequestRef.current) setGitLoading(false)
}
}, [gitModuleStatuses, installedGitModules, readGitStatus])
const refreshGitSubmissions = useCallback(async (): Promise<void> => { const refreshGitSubmissions = useCallback(async (): Promise<void> => {
if (!installedGitModules.length) { if (!installedGitModules.length) {
@@ -1,4 +1,4 @@
import { useCallback, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { LMS_API_BASE_URL } from '../../config/appConfig' import { LMS_API_BASE_URL } from '../../config/appConfig'
import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types' import type { StudioSourceSettings, VehicleRuntimeProfile } from '../platforms/types'
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters' import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
@@ -31,6 +31,24 @@ function waitForSimulatorStartup(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 4000)) 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 = { type UseWorkspaceModuleControlOptions = {
availableModules: ModuleRow[] availableModules: ModuleRow[]
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>> setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
@@ -40,6 +58,8 @@ type UseWorkspaceModuleControlOptions = {
weekNumber: number weekNumber: number
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean remoteWorkspace: boolean
moduleParameterScope: string
workspaceActive?: boolean
sourceSettings: StudioSourceSettings sourceSettings: StudioSourceSettings
rosNetwork?: RosNetworkConfig | null rosNetwork?: RosNetworkConfig | null
simulatorWorldFile?: string | null simulatorWorldFile?: string | null
@@ -80,6 +100,8 @@ export function useWorkspaceModuleControl({
weekNumber, weekNumber,
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
moduleParameterScope,
workspaceActive = true,
sourceSettings, sourceSettings,
rosNetwork, rosNetwork,
simulatorWorldFile, simulatorWorldFile,
@@ -99,7 +121,23 @@ export function useWorkspaceModuleControl({
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([]) const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null) const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve()) const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
const queuedModuleNamesRef = useRef(new Set<string>()) const queuedModuleActionsRef = useRef(new Map<string, number>())
const moduleActionIdRef = useRef(0)
const controlGenerationRef = useRef(0)
const workspaceActiveRef = useRef(workspaceActive)
useEffect(() => {
workspaceActiveRef.current = workspaceActive
if (workspaceActive) return
// Workspace pages remain mounted while another LMS page is shown. Do not
// leave their control buttons locked behind a detached IPC request.
controlGenerationRef.current += 1
queuedModuleActionsRef.current.clear()
moduleActionQueueRef.current = Promise.resolve()
setControllingModuleNames([])
setInstallingModuleName(null)
}, [workspaceActive])
const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => ( const isExcludedFromBulkControl = useCallback((module: ModuleRow): boolean => (
bulkExcludedModuleKeys.includes(module.folderName ?? module.name) bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
), [bulkExcludedModuleKeys]) ), [bulkExcludedModuleKeys])
@@ -120,9 +158,18 @@ export function useWorkspaceModuleControl({
!parameter.startsWith('runtime_wheelbase:=') !parameter.startsWith('runtime_wheelbase:=')
&& !parameter.startsWith('runtime_max_speed:=') && !parameter.startsWith('runtime_max_speed:=')
&& !parameter.startsWith('runtime_max_steering_angle:=') && !parameter.startsWith('runtime_max_steering_angle:=')
&& (remoteWorkspace || (
!parameter.startsWith('sim_mode:=')
&& !parameter.startsWith('simulator:=')
))
)) ))
)) ))
if (isMkMiniModule && mkMiniProfile) { if (isMkMiniModule && mkMiniProfile) {
if (!remoteWorkspace) {
// The local workspace pairs MK-mini with NAV2-Gazebo Simulator. Do not
// let the real-platform module default start hardware CAN/LiDAR here.
parameters.push('sim_mode:=true', 'simulator:=gazebo')
}
parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`) parameters.push(`runtime_wheelbase:=${mkMiniProfile.wheelbaseMeters}`)
parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`) parameters.push(`runtime_max_speed:=${mkMiniProfile.maxSpeedMetersPerSecond}`)
parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`) parameters.push(`runtime_max_steering_angle:=${mkMiniProfile.maxSteeringAngleRadians}`)
@@ -134,12 +181,15 @@ export function useWorkspaceModuleControl({
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`) parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)
if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`) if (mapYamlPath) parameters.push(`map:=${mapYamlPath.replace(/^\/workspace\//, '/modules/')}`)
} }
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '') && mapDatabasePath) { const isLocalizationModule = /lms-nav2-gazebo-localization/i.test(module.folderName ?? '')
parameters.push(`database_path:=${mapDatabasePath.replace(/^\/workspace\//, '/modules/')}`) const databasePath = mapDatabasePath
parameters.push('localization:=true') ?? (isLocalizationModule ? '/workspace/maps/rtabmap-active.db' : null)
if (/(lms-nav2-gazebo-slam3d|lms-nav2-gazebo-localization)/i.test(module.folderName ?? '') && databasePath) {
parameters.push(`database_path:=${databasePath.replace(/^\/workspace\//, '/modules/')}`)
if (/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')) parameters.push('localization:=true')
} }
return parameters.join(' ') return parameters.join(' ')
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles]) }, [remoteWorkspace, savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile, vehicleRuntimeProfiles])
const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => { const createCustomModuleSource = useCallback(async (module: ModuleRow): Promise<void> => {
if (!module.folderName || !module.packageName || !module.launchFile) { if (!module.folderName || !module.packageName || !module.launchFile) {
@@ -252,14 +302,17 @@ export function useWorkspaceModuleControl({
try { try {
const requestAccessToken = await getFreshAccessToken() const requestAccessToken = await getFreshAccessToken()
await installModuleOnce(module, requestAccessToken) 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) { } catch (error) {
console.error('[modules] install failed', error) console.error('[modules] install failed', error)
setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패') setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패')
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules]) }, [getFreshAccessToken, installModuleOnce, installingModuleName, markModuleState])
const installAllModules = useCallback(async (): Promise<void> => { const installAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return if (installingModuleName) return
@@ -272,16 +325,15 @@ export function useWorkspaceModuleControl({
const requestAccessToken = await getFreshAccessToken() const requestAccessToken = await getFreshAccessToken()
for (const module of targets) { for (const module of targets) {
await installModuleOnce(module, requestAccessToken) await installModuleOnce(module, requestAccessToken)
markModuleState(module, 'stopped')
} }
await loadAvailableModules({ silent: true })
} catch (error) { } catch (error) {
console.error('[modules] install all failed', error) console.error('[modules] install all failed', error)
setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패') setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패')
await loadAvailableModules({ silent: true })
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, loadAvailableModules]) }, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, markModuleState])
const startAllModules = useCallback(async (): Promise<void> => { const startAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return if (installingModuleName) return
@@ -291,6 +343,18 @@ export function useWorkspaceModuleControl({
return 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__') setInstallingModuleName('__start_all__')
setModuleInstallError(null) setModuleInstallError(null)
try { try {
@@ -354,6 +418,7 @@ export function useWorkspaceModuleControl({
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })), targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
) )
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
targets.forEach((module) => markModuleState(module, 'stopped'))
} else { } else {
if (targets.length) { if (targets.length) {
const result = await window.localWorkspace.modules.stopMany( const result = await window.localWorkspace.modules.stopMany(
@@ -361,6 +426,7 @@ export function useWorkspaceModuleControl({
clonePathPreview clonePathPreview
) )
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
targets.forEach((module) => markModuleState(module, 'stopped'))
} }
await stopLocalVisualization() await stopLocalVisualization()
} }
@@ -374,7 +440,7 @@ export function useWorkspaceModuleControl({
} finally { } finally {
setInstallingModuleName(null) 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> => { const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
if (installingModuleName) return false 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]) }, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, prepareSavedMap, remoteModuleStart, remoteWorkspace, rosNetwork, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (!workspaceActiveRef.current) return
// The parking area only opts modules out of bulk actions; those modules // The parking area only opts modules out of bulk actions; those modules
// must remain individually controllable while the rest are stopping. // must remain individually controllable while the rest are stopping.
const canControlExcludedModuleDuringBulkStop = ( const canControlExcludedModuleDuringBulkStop = (
@@ -435,14 +502,22 @@ export function useWorkspaceModuleControl({
) )
if ( if (
(!canControlExcludedModuleDuringBulkStop && installingModuleName) (!canControlExcludedModuleDuringBulkStop && installingModuleName)
|| queuedModuleNamesRef.current.has(module.name) || queuedModuleActionsRef.current.has(module.name)
) return ) return
queuedModuleNamesRef.current.add(module.name) const actionId = ++moduleActionIdRef.current
const controlGeneration = controlGenerationRef.current
queuedModuleActionsRef.current.set(module.name, actionId)
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null) setModuleInstallError(null)
const execute = async (): Promise<void> => { const execute = async (): Promise<void> => {
const isCurrentControl = (): boolean => (
workspaceActiveRef.current
&& controlGenerationRef.current === controlGeneration
&& queuedModuleActionsRef.current.get(module.name) === actionId
)
try { try {
if (!isCurrentControl()) return
const savedMapOverrides = action === 'start' || action === 'restart' const savedMapOverrides = action === 'start' || action === 'restart'
? await prepareSavedMap?.() ? await prepareSavedMap?.()
: undefined : undefined
@@ -462,6 +537,7 @@ export function useWorkspaceModuleControl({
? await remoteModuleStart(module, savedMapOverrides) ? await remoteModuleStart(module, savedMapOverrides)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, savedMapOverrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages, rosNetwork)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
if (!isCurrentControl()) return
if (action === 'start' || action === 'restart') { if (action === 'start' || action === 'restart') {
markModuleState(module, 'running') markModuleState(module, 'running')
@@ -480,7 +556,9 @@ export function useWorkspaceModuleControl({
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패') if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
} }
} }
if (!isCurrentControl()) return
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
if (!isCurrentControl()) return
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료되었습니다' ? '종료되었습니다'
: action === 'build' : action === 'build'
@@ -491,6 +569,7 @@ export function useWorkspaceModuleControl({
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
} catch (error) { } catch (error) {
console.error(`[modules] ${action} failed`, error) console.error(`[modules] ${action} failed`, error)
if (!isCurrentControl()) return
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패') setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료 실패' ? '종료 실패'
@@ -502,8 +581,10 @@ export function useWorkspaceModuleControl({
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
} finally { } finally {
queuedModuleNamesRef.current.delete(module.name) if (queuedModuleActionsRef.current.get(module.name) === actionId) {
setControllingModuleNames((names) => names.filter((name) => name !== module.name)) queuedModuleActionsRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
}
} }
} }
@@ -540,14 +621,17 @@ export function useWorkspaceModuleControl({
const normalizedParameters = parameters const normalizedParameters = parameters
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() })) .map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
.filter((parameter) => parameter.name) .filter((parameter) => parameter.name)
storeModuleParameters(makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name), normalizedParameters) storeModuleParameters(
makeModuleParameterKey(courseKey, weekNumber, module.id ?? module.folderName ?? module.name, moduleParameterScope),
normalizedParameters
)
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean) const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item)) setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item))
} catch (error) { } catch (error) {
console.error('[modules] update parameters failed', error) console.error('[modules] update parameters failed', error)
setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패') setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패')
} }
}, [courseKey, setAvailableModules, weekNumber]) }, [courseKey, moduleParameterScope, setAvailableModules, weekNumber])
return { return {
installingModuleName, installingModuleName,
@@ -23,6 +23,7 @@ type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[] availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>> setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
removeModule: (module: ModuleRow) => void
modulesLoading: boolean modulesLoading: boolean
moduleListError: string | null moduleListError: string | null
clearModuleListError: () => void clearModuleListError: () => void
@@ -130,6 +131,16 @@ export function useWorkspaceModuleList({
}) })
}, [moduleCacheKey]) }, [moduleCacheKey])
const removeModule = useCallback((module: ModuleRow): void => {
const key = moduleStateKey(module)
pendingModuleStatesRef.current.delete(key)
setAvailableModules((current) => {
const next = current.filter((item) => moduleStateKey(item) !== key)
storeRemoteModuleCache(moduleCacheKey, next)
return next
})
}, [moduleCacheKey])
useEffect(() => { useEffect(() => {
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides } moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules]) }, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
@@ -219,6 +230,7 @@ export function useWorkspaceModuleList({
availableModules, availableModules,
setAvailableModules, setAvailableModules,
markModuleState, markModuleState,
removeModule,
modulesLoading, modulesLoading,
moduleListError, moduleListError,
clearModuleListError: () => setModuleListError(null), clearModuleListError: () => setModuleListError(null),
@@ -28,20 +28,10 @@ const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
function resolveVisualizationStack(profileType: string): VisualizationStack | null { function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase() const type = profileType.toLowerCase()
if (type === 'local' || type === 'platform') return 'nav2' 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 return type === 'autoware' || type === 'nav2' || type === 'slam' || type === 'slam3d' ? type : null
} }
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode { function isRemoteEnvironment(profile: EnvironmentProfile | null): boolean {
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
const config = profile?.config const config = profile?.config
return Boolean( return Boolean(
config config
@@ -53,7 +43,7 @@ function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] { function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => ( const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile) remoteWorkspace ? isRemoteEnvironment(profile) : !isRemoteEnvironment(profile)
)) ))
return matchingProfiles.length ? matchingProfiles : profiles return matchingProfiles.length ? matchingProfiles : profiles
} }
@@ -73,6 +63,9 @@ type UseWorkspaceVisualizationOptions = {
rosNetwork?: RosNetworkConfig | null rosNetwork?: RosNetworkConfig | null
mapKey?: string | null mapKey?: string | null
platform?: string | null platform?: string | null
threeDimensional?: boolean
savedMapDatabasePath?: string | null
prepareSavedMap?: () => Promise<{ savedMapDatabasePath?: string }>
visualizationRuntime?: 'real' | 'simulation' visualizationRuntime?: 'real' | 'simulation'
worldLayoutVersion?: number worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
@@ -114,6 +107,9 @@ export function useWorkspaceVisualization({
rosNetwork, rosNetwork,
mapKey, mapKey,
platform, platform,
threeDimensional = false,
savedMapDatabasePath,
prepareSavedMap,
visualizationRuntime, visualizationRuntime,
worldLayoutVersion, worldLayoutVersion,
onTitlebarNotice onTitlebarNotice
@@ -165,8 +161,11 @@ export function useWorkspaceVisualization({
const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') const visualizationBridgeUrl = visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
const visualizationBackendUrl = visualizationBridgeHttpUrl const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : '' // A saved RTAB-Map database can be selected while the profile itself still
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation') // 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 localBridgeProfile = useMemo(() => {
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
? activeEnvironmentConfig.bridge as Record<string, unknown> ? activeEnvironmentConfig.bridge as Record<string, unknown>
@@ -184,7 +183,7 @@ export function useWorkspaceVisualization({
} }
}, [activeEnvironmentConfig, visualizationRuntimeStack]) }, [activeEnvironmentConfig, visualizationRuntimeStack])
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2' const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
? `./slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}` ? `./slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${viewerRuntime}&platform=${encodeURIComponent(platform?.trim() || '')}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
: `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}` : `./autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => { useEffect(() => {
@@ -196,7 +195,6 @@ export function useWorkspaceVisualization({
? savedStack as VisualizationStack ? savedStack as VisualizationStack
: fallback ?? fallbackStack : fallback ?? fallbackStack
setVisualizationStack(stack) setVisualizationStack(stack)
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks]) }, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
const refreshVisualizationStatus = useCallback(async (): Promise<void> => { const refreshVisualizationStatus = useCallback(async (): Promise<void> => {
@@ -218,9 +216,13 @@ export function useWorkspaceVisualization({
const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam' const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam'
? status.stack ? status.stack
: null : null
const statusStack = rawStatusStack === 'nav2' && visualizationStack === 'slam3d' ? 'slam3d' : rawStatusStack // The runtime bridge reports Nav2 for both SLAM modes. Preserve the
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack && visualizationStacks.includes(statusStack))) // map-selected SLAM stack instead of treating it as an unavailable profile.
if (statusStack && visualizationStacks.includes(statusStack)) setVisualizationStack(statusStack) const statusStack = rawStatusStack === 'nav2' && (visualizationStack === 'slam' || visualizationStack === 'slam3d')
? visualizationStack
: rawStatusStack
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack))
if (statusStack) setVisualizationStack(statusStack)
setVisualizationStatusMessage(status.running setVisualizationStatusMessage(status.running
? formatVisualizationStackLabel(status.stack) ? formatVisualizationStackLabel(status.stack)
: status.message : status.message
@@ -244,11 +246,15 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중') setVisualizationStatusMessage('시작 중')
try { try {
// Download the selected map artifacts before starting the bridge. The
// bridge only receives workspace-local paths, never an R2 URL.
const preparedMap = await prepareSavedMap?.()
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
const status = bridgeRunsOnPlatform const status = bridgeRunsOnPlatform
? bridgeTarget?.host ? bridgeTarget?.host
? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork) ? await window.remoteWorkspace.visualization.start(bridgeTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config, rosNetwork)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' } : { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork) : await window.localWorkspace.visualization.start(visualizationRuntimeStack, bridgeWorkspaceRoot ?? clonePathPreview, rosDomainId, localBridgeProfile, rosNetwork, bridgeSavedMapDatabasePath)
if (!status.ok) throw new Error(status.message ?? '시작 실패') if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running)) setVisualizationRunning(Boolean(status.running))
const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d' const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'