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