Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c010d0c913 | |||
| f6be625a8f | |||
| a88f0920cf |
+2
-2
@@ -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
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.7",
|
"version": "1.1.8",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
@@ -1,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"]
|
|
||||||
@@ -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.
|
|
||||||
@@ -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 "$@"
|
|
||||||
@@ -6,7 +6,7 @@ COPY requirements.txt /app/requirements.txt
|
|||||||
COPY bridge.py /app/bridge.py
|
COPY bridge.py /app/bridge.py
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs && \
|
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs ros-humble-rtabmap-msgs ros-humble-rtabmap-util && \
|
||||||
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
|
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
@@ -9,6 +9,8 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import struct
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
@@ -17,7 +19,7 @@ from pathlib import Path
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, Response, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
||||||
from action_msgs.msg import GoalStatus
|
from action_msgs.msg import GoalStatus
|
||||||
@@ -25,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
@@ -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))
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
Vendored
+6
-1
@@ -235,7 +235,7 @@ interface Window {
|
|||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | null
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{
|
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | null
|
||||||
@@ -280,6 +280,7 @@ interface Window {
|
|||||||
}
|
}
|
||||||
maps: {
|
maps: {
|
||||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||||
|
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
||||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
||||||
}
|
}
|
||||||
modules: {
|
modules: {
|
||||||
@@ -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'
|
||||||
|
|||||||
Reference in New Issue
Block a user