This commit is contained in:
2026-08-03 14:04:19 +09:00
parent 4b181e5ad3
commit d560ce6f82
48 changed files with 19866 additions and 555 deletions
+78 -3
View File
@@ -1,16 +1,18 @@
{ {
"name": "lms", "name": "lms",
"version": "1.0.19", "version": "1.0.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "lms", "name": "lms",
"version": "1.0.19", "version": "1.0.23",
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/three": "^0.181.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
@@ -19,7 +21,8 @@
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3",
"three": "^0.181.2"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
@@ -352,6 +355,12 @@
"url": "https://opencollective.com/webpack" "url": "https://opencollective.com/webpack"
} }
}, },
"node_modules/@dimforge/rapier3d-compat": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
"integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
"license": "Apache-2.0"
},
"node_modules/@electron-toolkit/preload": { "node_modules/@electron-toolkit/preload": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-3.0.2.tgz", "resolved": "https://registry.npmjs.org/@electron-toolkit/preload/-/preload-3.0.2.tgz",
@@ -1427,6 +1436,15 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/@mkkellogg/gaussian-splats-3d": {
"version": "0.4.7",
"resolved": "https://registry.npmjs.org/@mkkellogg/gaussian-splats-3d/-/gaussian-splats-3d-0.4.7.tgz",
"integrity": "sha512-0vy9/i9sJLFH/v3WJZ4axCsqjkToe8UsV3xY7bvK5EUC0akiRsWZODoCiSzpxhTLNyzSKTsyQKozIFeNA5RWRA==",
"license": "MIT",
"peerDependencies": {
"three": ">=0.160.0"
}
},
"node_modules/@monaco-editor/loader": { "node_modules/@monaco-editor/loader": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz", "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.7.0.tgz",
@@ -2248,6 +2266,12 @@
"node": ">= 10" "node": ">= 10"
} }
}, },
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"license": "MIT"
},
"node_modules/@types/babel__core": { "node_modules/@types/babel__core": {
"version": "7.20.5", "version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2427,6 +2451,27 @@
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/stats.js": {
"version": "0.17.4",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
"integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.181.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.181.0.tgz",
"integrity": "sha512-MLF1ks8yRM2k71D7RprFpDb9DOX0p22DbdPqT/uAkc6AtQXjxWCVDjCy23G9t1o8HcQPk7woD2NIyiaWcWPYmA==",
"license": "MIT",
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.22.0"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2448,6 +2493,12 @@
"license": "MIT", "license": "MIT",
"optional": true "optional": true
}, },
"node_modules/@types/webxr": {
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"license": "MIT"
},
"node_modules/@types/yauzl": { "node_modules/@types/yauzl": {
"version": "2.10.3", "version": "2.10.3",
"resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz", "resolved": "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz",
@@ -2485,6 +2536,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/@webgpu/types": {
"version": "0.1.71",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.71.tgz",
"integrity": "sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A==",
"license": "BSD-3-Clause"
},
"node_modules/@xmldom/xmldom": { "node_modules/@xmldom/xmldom": {
"version": "0.9.10", "version": "0.9.10",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz",
@@ -4666,6 +4723,12 @@
} }
} }
}, },
"node_modules/fflate": {
"version": "0.8.3",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
"license": "MIT"
},
"node_modules/filelist": { "node_modules/filelist": {
"version": "1.0.6", "version": "1.0.6",
"resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz",
@@ -6501,6 +6564,12 @@
"url": "https://opencollective.com/unified" "url": "https://opencollective.com/unified"
} }
}, },
"node_modules/meshoptimizer": {
"version": "0.22.0",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
"integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==",
"license": "MIT"
},
"node_modules/micromark": { "node_modules/micromark": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
@@ -8763,6 +8832,12 @@
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
}, },
"node_modules/three": {
"version": "0.181.2",
"resolved": "https://registry.npmjs.org/three/-/three-0.181.2.tgz",
"integrity": "sha512-k/CjiZ80bYss6Qs7/ex1TBlPD11whT9oKfT8oTGiHa34W4JRd1NiH/Tr1DbHWQ2/vMUypxksLnF2CfmlmM5XFQ==",
"license": "MIT"
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+4 -1
View File
@@ -45,7 +45,9 @@
"dependencies": { "dependencies": {
"@electron-toolkit/preload": "^3.0.1", "@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0", "@electron-toolkit/utils": "^4.0.0",
"@mkkellogg/gaussian-splats-3d": "^0.4.7",
"@monaco-editor/react": "^4.7.0", "@monaco-editor/react": "^4.7.0",
"@types/three": "^0.181.0",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"driver.js": "^1.4.0", "driver.js": "^1.4.0",
@@ -54,7 +56,8 @@
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.3" "socket.io-client": "^4.8.3",
"three": "^0.181.2"
}, },
"devDependencies": { "devDependencies": {
"@electron-toolkit/tsconfig": "^1.0.1", "@electron-toolkit/tsconfig": "^1.0.1",
+41
View File
@@ -0,0 +1,41 @@
FROM future-nav2:humble
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
ENV DEBIAN_FRONTEND=noninteractive \
ROS_DISTRO=humble \
TURTLEBOT3_MODEL=burger \
NAV2_WORKSPACE=/workspace/nav2-slam_ws
# Nav2 and SLAM Toolbox are provided by the base image. Module workspaces may
# override those packages with local sources, while Gazebo and TurtleBot3 are
# added here for the source-based simulator modules.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
build-essential \
cmake \
git \
python3-argcomplete \
python3-colcon-common-extensions \
python3-rosdep \
python3-vcstool \
python3-pip \
ros-humble-gazebo-plugins \
ros-humble-gazebo-ros-pkgs \
ros-humble-turtlebot3-description \
ros-humble-turtlebot3-gazebo \
ros-humble-turtlebot3-msgs \
ros-humble-xacro \
ros-humble-tf-transformations \
&& rm -rf /var/lib/apt/lists/* \
&& rosdep init 2>/dev/null || true
RUN rosdep update
COPY docker-entrypoint.sh /usr/local/bin/studio-nav2-entrypoint
RUN chmod +x /usr/local/bin/studio-nav2-entrypoint \
&& mkdir -p "${NAV2_WORKSPACE}/src"
WORKDIR /workspace
ENTRYPOINT ["/usr/local/bin/studio-nav2-entrypoint"]
CMD ["bash"]
+34
View File
@@ -0,0 +1,34 @@
# Nav2 Development Image
`nav2-dev:humble` is the source-build runtime for Nav2 learning modules. It
contains ROS 2 Humble, Gazebo, colcon, and rosdep, but intentionally does not
contain Nav2, TurtleBot3, or SLAM Toolbox source code.
Build it from this directory:
```bash
./build.sh
```
Mount a composed module workspace and build its sources:
```bash
docker run --rm -it --network host \
-e DISPLAY="$DISPLAY" \
-e TURTLEBOT3_MODEL=burger \
-v /tmp/.X11-unix:/tmp/.X11-unix \
-v "$HOME/autonomy-studio/workspaces/nav2-slam_ws:/workspace/nav2-slam_ws" \
nav2-dev:humble bash
cd "$NAV2_WORKSPACE"
rosdep install --from-paths src --ignore-src -r -y
colcon build
```
The composed workspace must contain all direct source dependencies. For Nav2,
include its shared packages such as `nav2_common`, `nav2_util`, and `nav2_msgs`
alongside the module packages being built.
On later container starts, the entrypoint automatically sources
`/workspace/nav2-slam_ws/install/setup.bash`, so source-built packages override
the base ROS installation.
+4
View File
@@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
docker build -t nav2-dev:humble "$(cd "$(dirname "$0")" && pwd)"
+12
View File
@@ -0,0 +1,12 @@
#!/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 "$@"
+42
View File
@@ -209,6 +209,9 @@ class SubscriberNode(Node):
self._trajectory_event = threading.Event() self._trajectory_event = threading.Event()
self._behavior_path = None self._behavior_path = None
self._behavior_path_event = threading.Event() self._behavior_path_event = threading.Event()
self._nav2_plan = None
self._nav2_plan_revision = 0
self._nav2_plan_lock = threading.Lock()
if _ROBOT_STACK == "autoware" and HAS_PLANNING: if _ROBOT_STACK == "autoware" and HAS_PLANNING:
self.create_subscription( self.create_subscription(
Trajectory, Trajectory,
@@ -350,11 +353,20 @@ class SubscriberNode(Node):
def _on_nav2_plan(self, msg: NavPath): def _on_nav2_plan(self, msg: NavPath):
if len(msg.poses) > 0: if len(msg.poses) > 0:
with self._nav2_plan_lock:
self._nav2_plan = msg
self._nav2_plan_revision += 1
self._behavior_path = msg self._behavior_path = msg
self._trajectory = msg self._trajectory = msg
self._behavior_path_event.set() self._behavior_path_event.set()
self._trajectory_event.set() self._trajectory_event.set()
def get_nav2_plan_snapshot(self):
with self._nav2_plan_lock:
if self._nav2_plan is None:
return self._nav2_plan_revision, []
return self._nav2_plan_revision, self._nav_path_to_points(self._nav2_plan)
def _on_kinematic_state(self, msg: Odometry): def _on_kinematic_state(self, msg: Odometry):
with self._kinematic_lock: with self._kinematic_lock:
self._kinematic_state = msg self._kinematic_state = msg
@@ -1104,6 +1116,36 @@ async def ws_map(websocket: WebSocket, stack: Optional[str] = None):
pass pass
@app.websocket("/ws/nav2")
async def ws_nav2(websocket: WebSocket):
await websocket.accept()
if _ROBOT_STACK != "nav2":
await websocket.send_json({
"type": "stack_mismatch",
"requested": "nav2",
"active": _ROBOT_STACK,
"message": "Restart bridge with ROBOT_STACK=nav2 to stream Nav2 paths",
})
await websocket.close()
return
last_revision = -1
try:
while True:
if sub_node:
revision, poses = sub_node.get_nav2_plan_snapshot()
if poses and revision != last_revision:
last_revision = revision
await websocket.send_json({
"type": "nav2_path",
"revision": revision,
"poses": poses,
})
await asyncio.sleep(0.25)
except (WebSocketDisconnect, Exception):
pass
@app.post("/set_initial_pose") @app.post("/set_initial_pose")
def set_initial_pose(payload: PosePayload): def set_initial_pose(payload: PosePayload):
try: try:
+75 -16
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
import asyncio import asyncio
import array import array
import base64
import json import json
import logging import logging
import math import math
@@ -83,6 +84,44 @@ def compose_2d(a: dict, b: dict) -> dict:
} }
def invert_2d(transform: dict) -> dict:
"""Return the inverse of a planar TF transform."""
yaw = -transform["yaw"]
c = math.cos(yaw)
s = math.sin(yaw)
return {
"x": c * -transform["x"] - s * -transform["y"],
"y": s * -transform["x"] + c * -transform["y"],
"z": -transform.get("z", 0.0),
"yaw": yaw,
}
def resolve_2d_transform(transforms: dict, target_frame: str, source_frame: str) -> Optional[dict]:
"""Resolve target <- source through the currently published TF graph."""
if not target_frame or not source_frame:
return None
if target_frame == source_frame:
return {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0}
pending = [(target_frame, {"x": 0.0, "y": 0.0, "z": 0.0, "yaw": 0.0})]
visited = set()
while pending:
frame, accumulated = pending.pop(0)
if frame in visited:
continue
visited.add(frame)
if frame == source_frame:
return accumulated
for (parent, child), transform in transforms.items():
if parent == frame and child not in visited:
pending.append((child, compose_2d(accumulated, transform)))
elif child == frame and parent not in visited:
pending.append((parent, compose_2d(accumulated, invert_2d(transform))))
return None
class VelocityPayload(BaseModel): class VelocityPayload(BaseModel):
linear: float = 0.0 linear: float = 0.0
angular: float = 0.0 angular: float = 0.0
@@ -238,6 +277,28 @@ class SlamNode(Node):
value = data[row * width + column] value = data[row * width + column]
pixels.append(0 if value >= 50 else 254 if value == 0 else 205) pixels.append(0 if value >= 50 else 254 if value == 0 else 205)
pgm_path.write_bytes(f"P5\n{width} {height}\n255\n".encode("ascii") + pixels) pgm_path.write_bytes(f"P5\n{width} {height}\n255\n".encode("ascii") + pixels)
# Keep a compact copy with the map record so the LMS can render an
# actual saved-map preview without accessing the student's workspace.
preview_limit = 192
preview_scale = max(1, math.ceil(width / preview_limit), math.ceil(height / preview_limit))
preview_width = math.ceil(width / preview_scale)
preview_height = math.ceil(height / preview_scale)
preview_pixels = bytearray()
for preview_row in range(preview_height):
source_top = preview_row * preview_scale
source_bottom = min(height, source_top + preview_scale)
for preview_column in range(preview_width):
source_left = preview_column * preview_scale
source_right = min(width, source_left + preview_scale)
# Preserve occupied cells while reducing the grid: black is
# lower than unknown/free in the generated PGM palette.
preview_pixels.append(min(
pixels[row * width + column]
for row in range(source_top, source_bottom)
for column in range(source_left, source_right)
))
preview_pgm = f"P5\n{preview_width} {preview_height}\n255\n".encode("ascii") + preview_pixels
yaml_path.write_text( yaml_path.write_text(
"\n".join([ "\n".join([
f"image: {pgm_path.name}", f"image: {pgm_path.name}",
@@ -285,6 +346,7 @@ class SlamNode(Node):
"resolution": resolution, "resolution": resolution,
"width": width, "width": width,
"height": height, "height": height,
"previewPgm": base64.b64encode(preview_pgm).decode("ascii"),
} }
def _on_scan(self, msg: LaserScan): def _on_scan(self, msg: LaserScan):
@@ -553,25 +615,24 @@ class SlamNode(Node):
"angular": vel.angular.z, "angular": vel.angular.z,
"t": odom.header.stamp.sec + odom.header.stamp.nanosec * 1e-9, "t": odom.header.stamp.sec + odom.header.stamp.nanosec * 1e-9,
} }
base_to_scan = ( odom_to_scan = (
tf_snapshot.get((odom.child_frame_id, scan.header.frame_id)) resolve_2d_transform(tf_snapshot, odom.header.frame_id, scan.header.frame_id)
if scan is not None if scan is not None
else None else None
) )
if base_to_scan is not None: if odom_to_scan is not None:
scan_pose = { scan_pose = {
**compose_2d(pose, base_to_scan), **odom_to_scan,
"z": pose.get("z", 0.0) + base_to_scan.get("z", 0.0),
"roll": 0.0, "roll": 0.0,
"pitch": 0.0, "pitch": 0.0,
"frame_id": pose["frame_id"], "frame_id": pose["frame_id"],
"source_frame_id": scan.header.frame_id, "source_frame_id": scan.header.frame_id,
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9, "t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
} }
map_to_odom = tf_snapshot.get(("map", odom.header.frame_id)) map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
if map_to_odom is not None: if map_to_base is not None:
pose_map = { pose_map = {
**compose_2d(map_to_odom, pose), **map_to_base,
"frame_id": "map", "frame_id": "map",
"child_frame_id": odom.child_frame_id, "child_frame_id": odom.child_frame_id,
"source_frame_id": odom.header.frame_id, "source_frame_id": odom.header.frame_id,
@@ -579,14 +640,12 @@ class SlamNode(Node):
} }
if scan is not None and pose_map is not None: if scan is not None and pose_map is not None:
# A scan is measured from its sensor frame, not base_link. Use the # Resolve map -> sensor directly through the published TF graph so
# published base_link -> scan-frame TF so its endpoints overlay the # the scan uses the same localization correction as the map.
# occupancy grid in the same map coordinate system. map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
base_to_scan = tf_snapshot.get((pose_map["child_frame_id"], scan.header.frame_id)) if map_to_scan is not None:
if base_to_scan is not None:
scan_pose_map = { scan_pose_map = {
**compose_2d(pose_map, base_to_scan), **map_to_scan,
"z": pose_map.get("z", 0.0) + base_to_scan.get("z", 0.0),
"roll": 0.0, "roll": 0.0,
"pitch": 0.0, "pitch": 0.0,
"frame_id": "map", "frame_id": "map",
@@ -676,7 +735,7 @@ class SlamNode(Node):
"point_cloud": point_cloud_payload, "point_cloud": point_cloud_payload,
"map": map_payload, "map": map_payload,
"tf": { "tf": {
"map_to_odom": tf_snapshot.get(("map", "odom")), "map_to_odom": resolve_2d_transform(tf_snapshot, "map", "odom"),
}, },
"navigation": navigation, "navigation": navigation,
"plan": { "plan": {
+938 -60
View File
File diff suppressed because it is too large Load Diff
+21 -1
View File
@@ -93,11 +93,15 @@ contextBridge.exposeInMainWorld('titlebar', {
maximize: () => ipcRenderer.send('titlebar-maximize'), maximize: () => ipcRenderer.send('titlebar-maximize'),
close: () => ipcRenderer.send('titlebar-close') close: () => ipcRenderer.send('titlebar-close')
}) })
contextBridge.exposeInMainWorld('appLifecycle', {
quit: () => ipcRenderer.send('app-quit')
})
contextBridge.exposeInMainWorld('learningVideo', { contextBridge.exposeInMainWorld('learningVideo', {
open: (url: string, title?: string) => ipcRenderer.invoke('learning-video:open', { url, title }) open: (url: string, title?: string) => ipcRenderer.invoke('learning-video:open', { url, title })
}) })
contextBridge.exposeInMainWorld('moduleFiles', { contextBridge.exposeInMainWorld('moduleFiles', {
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:read', moduleName, workspaceRoot, folderName), read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:read', moduleName, workspaceRoot, folderName),
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:image', moduleName, path, workspaceRoot, folderName),
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:create', moduleName, path, kind, workspaceRoot, folderName), create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:create', moduleName, path, kind, workspaceRoot, folderName),
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:write', moduleName, path, content, workspaceRoot, folderName), write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:write', moduleName, path, content, workspaceRoot, folderName),
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName), copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName),
@@ -155,6 +159,7 @@ contextBridge.exposeInMainWorld('visualization', {
contextBridge.exposeInMainWorld('localWorkspace', { contextBridge.exposeInMainWorld('localWorkspace', {
moduleFiles: { moduleFiles: {
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName), read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:image', moduleName, path, workspaceRoot, folderName),
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:create', moduleName, path, kind, workspaceRoot, folderName), create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:create', moduleName, path, kind, workspaceRoot, folderName),
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:write', moduleName, path, content, workspaceRoot, folderName), write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:write', moduleName, path, content, workspaceRoot, folderName),
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName), copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:copy', moduleName, fromPath, toPath, mode, workspaceRoot, folderName),
@@ -174,6 +179,15 @@ contextBridge.exposeInMainWorld('localWorkspace', {
logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName),
updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams)
}, },
nav2: {
setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => ipcRenderer.invoke('local-workspace:nav2:set-simulator-pose', pose),
addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => ipcRenderer.invoke('local-workspace:nav2:add-runtime-obstacle', obstacle),
clearRuntimeObstacles: () => ipcRenderer.invoke('local-workspace:nav2:clear-runtime-obstacles')
},
worldLayout: {
read: (workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:world-layout:read', workspaceRoot),
write: (workspaceRoot: string | undefined, layout: unknown) => ipcRenderer.invoke('local-workspace:world-layout:write', workspaceRoot, layout)
},
visualization: { visualization: {
status: () => ipcRenderer.invoke('local-workspace:visualization:status'), status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile), start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile),
@@ -211,6 +225,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
contextBridge.exposeInMainWorld('remoteWorkspace', { contextBridge.exposeInMainWorld('remoteWorkspace', {
moduleFiles: { moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName), read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName),
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName), create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName), write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:copy', target, moduleName, fromPath, toPath, mode, folderName), copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:copy', target, moduleName, fromPath, toPath, mode, folderName),
@@ -224,10 +239,15 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages), build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:build', target, moduleName, folderName, gpuEnabled, rosDomainId, runtimeImages),
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName), stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName),
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules), stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules),
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules), resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules),
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages),
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName) logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName)
}, },
visualization: {
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile),
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
},
git: { git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath), status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ipcRenderer.invoke('remote-workspace:git:status', target, moduleName, folderName, selectedFilePath),
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId), submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ipcRenderer.invoke('remote-workspace:git:submit', target, moduleName, folderName, message, authorName, authorEmail, submissionId),
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,152 @@
<?xml version="1.0" encoding="utf-8"?>
<COLLADA xmlns="http://www.collada.org/2005/11/COLLADASchema" version="1.4.1">
<asset>
<contributor>
<author>Cole</author>
<authoring_tool>OpenCOLLADA for 3ds Max; Version: 1.3.1; Revision: 847M; Platform: x64; Configuration: Release_Max2012_static</authoring_tool>
<source_data>file:///C:/Users/Cole/Desktop/Houses.max</source_data>
</contributor>
<created>2012-12-21T07:14:54</created>
<modified>2012-12-21T07:14:54</modified>
<unit name="inch" meter="0.0254"/>
<up_axis>Z_UP</up_axis>
</asset>
<library_effects>
<effect id="_1_-_Default">
<profile_COMMON>
<technique sid="common">
<blinn>
<emission>
<color>0 0 0 1</color>
</emission>
<ambient>
<color>1 1 1 1</color>
</ambient>
<diffuse>
<color>1 1 1 1</color>
</diffuse>
<specular>
<color>0.9 0.9 0.9 1</color>
</specular>
<shininess>
<float>9.999999</float>
</shininess>
<reflective>
<color>0 0 0 1</color>
</reflective>
<transparent opaque="A_ONE">
<color>1 1 1 1</color>
</transparent>
<transparency>
<float>1</float>
</transparency>
</blinn>
</technique>
</profile_COMMON>
<extra>
<technique profile="OpenCOLLADA3dsMax">
<extended_shader>
<apply_reflection_dimming>0</apply_reflection_dimming>
<dim_level>0</dim_level>
<falloff_type>0</falloff_type>
<index_of_refraction>1.5</index_of_refraction>
<opacity_type>0</opacity_type>
<reflection_level>3</reflection_level>
<wire_size>1</wire_size>
<wire_units>0</wire_units>
</extended_shader>
<shader>
<ambient_diffuse_lock>1</ambient_diffuse_lock>
<ambient_diffuse_texture_lock>1</ambient_diffuse_texture_lock>
<diffuse_specular_lock>0</diffuse_specular_lock>
<soften>0.1</soften>
<use_self_illum_color>0</use_self_illum_color>
</shader>
</technique>
</extra>
</effect>
</library_effects>
<library_materials>
<material id="_1_-_Default-material" name="_1_-_Default">
<instance_effect url="#_1_-_Default"/>
</material>
</library_materials>
<library_geometries>
<geometry id="geom-Cylinder001" name="Cylinder001">
<mesh>
<source id="geom-Cylinder001-positions">
<float_array id="geom-Cylinder001-positions-array" count="279">63.77863 0.3839193 -94.24056 55.23391 31.88931 -94.24056 31.88931 55.23391 -94.24056 1.33153e-6 63.77862 -94.24056 -31.88931 55.23391 -94.24056 -55.2339 31.88931 -94.24056 -63.77862 0.3839193 -94.24056 55.23391 31.88931 101.6574 31.88931 55.23391 101.6574 -2.78785e-6 63.77862 101.6574 -31.88931 55.23391 101.6574 -55.23391 31.88931 101.6574 -63.77862 0.3839242 101.6574 63.77862 0.3839242 101.6574 63.77863 -135.6524 -94.24055 63.77862 -135.6524 101.6575 -63.77863 -135.6524 101.6575 -63.77862 -135.6524 -94.24055 69.09421 0.3839242 101.6574 60.08233 34.68855 101.6574 34.68855 60.08234 101.6574 -3.13332e-6 69.3771 101.6574 -34.68855 60.08233 101.6574 -60.08233 34.68855 101.6574 -69.18634 0.3839242 101.6574 -69.18634 -141.0601 101.6575 69.18633 -141.0602 101.6575 60.08233 34.68855 108.9806 34.68855 60.08234 108.9806 -3.13258e-6 69.3771 108.9806 -34.68855 60.08233 108.9806 -60.08233 34.68855 108.9806 -69.18634 0.3839244 108.9806 -69.18634 -141.0601 108.9806 69.18633 -141.0602 108.9806 69.09421 0.3839244 108.9806 -23.34707 -979.6222 17.01899 23.34707 -979.6222 17.01899 -23.34707 -979.6222 -9.602058 23.34707 -979.6222 -9.602058 -23.34707 -121.6467 17.01897 23.34707 -121.6467 17.01897 -23.34707 -121.6467 -9.602079 23.34707 -121.6467 -9.602079 -65.29713 -90.30914 34.35137 -65.29715 -90.30914 60.09743 -65.29715 -39.25652 63.45726 -73.57328 -90.30915 34.35137 -73.57329 -84.41591 63.45725 -73.57329 -39.25653 39.67269 -73.5733 -39.25652 63.45725 -73.57329 -48.59029 34.35137 -65.29715 -93.29141 67.81843 -65.29715 -39.25652 67.81852 -65.29713 -99.18414 34.35137 -65.29715 -84.41591 63.45726 -73.57329 -90.30914 60.09743 -65.29715 -99.1846 64.45859 -5.55696 73.37229 122.3759 5.556962 70.81001 120.9151 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 85.82349 122.3759 5.556962 85.82349 122.3759 -5.55696 70.81001 120.9151 5.556962 73.37229 122.3759 -5.556959 66.63747 122.3759 5.556964 69.19975 120.9151 -5.55696 69.19975 109.6903 5.556963 69.19975 109.6903 -5.556959 54.18628 122.3759 5.556964 54.18628 122.3759 -5.556959 69.19975 120.9151 5.556964 66.63747 122.3759 -66.63045 -79.03947 42.09508 -66.63046 -79.03947 57.59226 -66.63046 162.3586 42.09508 -66.63046 99.09541 42.09508 -66.63046 162.3586 55.67602 -66.63046 148.0616 12.03616 -66.63045 102.9279 10.30568 -5.556961 70.81001 100.9286 5.556961 70.81001 100.9286 -5.556961 58.29042 100.9286 5.556961 58.29042 100.9286 -5.556961 70.81001 109.6903 5.556962 70.81001 109.6903 -5.55696 46.97038 109.6903 5.556962 46.97038 109.6903 -66.63046 101.0116 40.17884 -66.63046 160.4424 57.59225 -66.63046 145.3223 10.30569 -66.63045 101.0116 12.22192</float_array>
<technique_common>
<accessor source="#geom-Cylinder001-positions-array" count="93" stride="3">
<param name="X" type="float"/>
<param name="Y" type="float"/>
<param name="Z" type="float"/>
</accessor>
</technique_common>
</source>
<source id="geom-Cylinder001-normals">
<float_array id="geom-Cylinder001-normals-array" count="783">0.9912451 0.132035 2.4983e-8 0.8652635 0.5013175 1.68492e-8 0.8652634 0.5013174 1.38252e-8 0.9912451 0.132035 1.60082e-8 0.5000001 0.8660254 9.92266e-9 0.5 0.8660254 9.73644e-9 -5.64708e-8 1 -9.79561e-10 -5.89261e-8 1 -1.8623e-10 -0.5 0.8660254 -1.14197e-8 -0.5 0.8660254 -1.36251e-8 -0.8652635 0.5013175 -1.98732e-8 -0.8652635 0.5013175 -2.00329e-8 -0.9912451 0.132035 -2.25966e-8 -0.9912451 0.132035 -2.56756e-8 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 1 -1.72061e-8 2.69974e-8 1 2.18421e-15 3.89457e-8 0 5.39393e-8 1 0 5.39393e-8 1 0 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 5.94806e-15 5.39393e-8 1 -1 1.08357e-8 -2.69974e-8 -1 2.80417e-8 -3.89457e-8 0 -5.60835e-8 -1 0 -5.60835e-8 -1 0 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -6.70888e-15 -5.60835e-8 -1 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 -1.19623e-7 -1 -4.65881e-15 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 5.60835e-8 -5.60835e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 -2.97263e-16 -5.39393e-8 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -3.14437e-16 -5.60835e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 -5.39745e-8 -5.39745e-8 -1 0.99172 0.1284185 -3.18533e-9 0.8672485 0.4978755 0 0.8672485 0.4978756 -4.85969e-10 0.99172 0.1284184 -3.65799e-9 0.5000001 0.8660254 -1.74247e-12 0.5 0.8660254 0 -1.08031e-7 1 -1.74247e-12 -1.37494e-7 1 1.74249e-12 -0.5 0.8660254 0 -0.5000001 0.8660254 1.74248e-12 -0.8666227 0.498964 -4.20111e-10 -0.8666227 0.498964 0 -0.9916003 0.1293404 -3.1582e-9 -0.9916003 0.1293404 -2.7491e-9 -1 0 0 -1 0 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 -1.10273e-7 -1 0 0.9999998 6.51317e-4 -6.11024e-13 0.9999998 6.51317e-4 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 0 2.44539e-8 1 1 0 0 1 0 0 1 0 0 1 0 0 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 0 -2.44539e-8 -1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 -1.30567e-7 -2.28558e-7 -1 1.68944e-7 -2.96333e-7 -1 -5.90983e-8 -2.5016e-7 -1 -1.06023e-7 -2.34713e-7 -1 1.82876e-7 -3.20771e-7 -1 -7.93843e-8 -2.36668e-7 7.39325e-7 -1 2.37658e-7 0 -1 0 1.82529e-7 -1 5.86744e-8 9.21853e-7 -1 2.96333e-7 -4.60927e-7 -6.88853e-14 1 -4.60927e-7 0 1 -4.60927e-7 -8.98548e-15 1 -4.60927e-7 -7.78708e-14 1 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 0 -5.92665e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -1 -2.49037e-7 -5.06819e-7 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 -4.00423e-7 -0.4952793 0.8687338 0 -0.4952789 0.868734 0 -0.4952789 0.8687341 0 -0.4952789 0.868734 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 0.4952782 -0.8687345 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 -0.4952782 -0.8687345 0 -0.4952782 -0.8687344 0 -0.4952781 -0.8687344 0 1 0 0 1 0 0 1 0 0 1 0 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 -1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 0 0 1 -1 -2.99963e-8 -3.58181e-7 -1 -1.02221e-7 -4.58289e-8 -1 -3.99212e-8 -1.73411e-7 -1 -3.23093e-8 -4.85334e-7 -1 -3.18579e-8 -4.92309e-7 -1 -9.30545e-8 -1.27403e-7 -1 -5.76775e-8 -2.63987e-7 -1 -4.88709e-8 -1.95574e-7 -1 -3.36656e-8 -3.5904e-7 -1 -1.73765e-7 1.23235e-7 -1 -1.79962e-7 2.84877e-7</float_array>
<technique_common>
<accessor source="#geom-Cylinder001-normals-array" count="261" stride="3">
<param name="X" type="float"/>
<param name="Y" type="float"/>
<param name="Z" type="float"/>
</accessor>
</technique_common>
</source>
<source id="geom-Cylinder001-map1">
<float_array id="geom-Cylinder001-map1-array" count="450">0.1889011 0.2758261 0 0.2256371 0.2758261 0 0.2256371 0.6486552 0 0.188901 0.6486552 0 0.2623734 0.2758261 0 0.2623734 0.6486552 0 0.2991094 0.2758261 0 0.2991095 0.6486552 0 0.3358451 0.275826 0 0.3358452 0.6486552 0 0.3725807 0.275826 0 0.3725807 0.6486552 0 0.9257745 0.70301 0 0.8734617 0.7549164 0 0.802001 0.7739153 0 0.7305403 0.7549164 0 0.6782273 0.7030097 0 0.6594725 0.6335595 0 0.4093161 0.2758261 0 0.4093161 0.6486552 0 0.03795872 0.6486552 0 0.03795872 0.2758261 0 0.5602581 0.2758261 0 0.5602581 0.6486552 0 0.9443399 0.6335346 0 0.061661 0.8492339 0 0.09246806 0.8492339 0 0.09246806 0.8621775 0 0.061661 0.8621775 0 0.1245703 0.8492339 0 0.4158128 0.7749463 0 0.4288802 0.7265559 0 0.4645816 0.6911321 0 0.5133504 0.6781658 0 0.5621194 0.6911321 0 0.5978206 0.7265559 0 0.6108883 0.7749462 0 0.1245703 0.8621775 0 0.1566722 0.8492339 0 0.1566722 0.8621775 0 0.1887741 0.8492339 0 0.1887741 0.8621775 0 0.2208759 0.8492339 0 0.2208759 0.8621775 0 0.2523189 0.8492339 0 0.2523189 0.8621775 0 0.3790478 0.8492339 0 0.3790478 0.8621775 0 0.2097653 0.9498755 0 0.3783371 0.826761 0 0.6594725 0.3437682 0 0.06166129 0.8267609 0 0.9445293 0.3437679 0 0.3783363 0.702438 0 0.6108882 0.9807906 0 0.0836773 0.9498755 0 0.0836773 0.9369324 0 0.2097653 0.9369324 0 0.061661 0.7024379 0 0.4158128 0.9807907 0 0.637657 0.6559129 0 0.6597634 0.7377743 0 0.6472197 0.7449604 0 0.6234784 0.6557912 0 0.7201594 0.7977016 0 0.7129173 0.8101482 0 0.802662 0.8196367 0 0.802662 0.8340079 0 0.8851648 0.7977016 0 0.8924065 0.8101479 0 0.2192692 0.9691411 0 0.0836773 0.9691411 0 0.9455608 0.7377743 0 0.9581041 0.7449604 0 0.9676672 0.6559129 0 0.9816576 0.6577407 0 0.9676669 0.3076856 0 0.9816576 0.2938034 0 0.637657 0.3076856 0 0.0836773 0.9573665 0 0.2192692 0.9573665 0 0.6236663 0.2938034 0 0.637657 0.6559129 0 0.973251 0.1941192 0 0.945253 0.1941192 0 0.945253 0.1408443 0 0.973251 0.1408443 0 0.04288544 0.1408443 0 0.04288544 0.194119 0 0.945253 0.03266054 0 0.945253 0.08657608 0 0.04288544 0.08657608 0 0.04288544 0.03266054 0 0.945253 0.2533531 0 0.04288544 0.253353 0 0.8747699 0.9740806 0 0.8747699 0.9228441 0 0.886499 0.9161604 0 0.9763682 0.91616 0 0.8582996 0.9740796 0 0.8582998 0.9228438 0 0.878347 0.9018502 0 0.6516011 0.9556062 0 0.7323457 0.9556062 0 0.748215 0.9695692 0 0.8864987 0.8996912 0 0.9763682 0.8996903 0 0.9763678 0.9634923 0 0.6516011 0.9695697 0 0.7428828 0.9448491 0 0.7428828 0.8624189 0 0.7587513 0.8624189 0 0.9577932 0.9740818 0 0.7587519 0.9588121 0 0.6609319 0.8997087 0 0.6875241 0.8997087 0 0.6875241 0.8657955 0 0.6875241 0.8926538 0 0.6875241 0.9294978 0 0.6609319 0.9294978 0 0.6609319 0.8926538 0 0.701803 0.8955842 0 0.7283945 0.9294978 0 0.701803 0.9294978 0 0.701803 0.90264 0 0.701803 0.8657949 0 0.7283945 0.8657949 0 0.7283945 0.90264 0 0.7283945 0.8955842 0 0.304611 0.9144163 0 0.2467109 0.9144163 0 0.304611 0.8910367 0 0.0836773 0.9144163 0 0.0836773 0.8877379 0 0.291526 0.9661621 0 0.2502185 0.9691411 0 0.6609319 0.8657955 0 0.8666169 0.9085327 0 0.9434043 0.8599022 0 0.9434043 0.8377851 0 0.9683194 0.8377851 0 0.9683194 0.8599022 0 0.9259688 0.8377851 0 0.9259688 0.8599022 0 0.878527 0.8599022 0 0.878527 0.8377851 0 0.2484647 0.9177151 0 0.3028572 0.8877379 0 0.289019 0.9691411 0 0.2484647 0.9658423 0</float_array>
<technique_common>
<accessor source="#geom-Cylinder001-map1-array" count="150" stride="3">
<param name="S" type="float"/>
<param name="T" type="float"/>
<param name="P" type="float"/>
</accessor>
</technique_common>
</source>
<vertices id="geom-Cylinder001-vertices">
<input semantic="POSITION" source="#geom-Cylinder001-positions"/>
</vertices>
<triangles material="_1_-_Default_1" count="121">
<input semantic="VERTEX" source="#geom-Cylinder001-vertices" offset="0"/>
<input semantic="NORMAL" source="#geom-Cylinder001-normals" offset="1"/>
<input semantic="TEXCOORD" source="#geom-Cylinder001-map1" offset="2" set="0"/>
<p>0 0 0 1 1 1 7 2 2 7 2 2 13 3 3 0 0 0 1 1 1 2 4 4 8 5 5 8 5 5 7 2 2 1 1 1 2 4 4 3 6 6 9 7 7 9 7 7 8 5 5 2 4 4 3 6 6 4 8 8 10 9 9 10 9 9 9 7 7 3 6 6 4 8 8 5 10 10 11 11 11 11 11 11 10 9 9 4 8 8 5 10 10 6 12 18 12 13 19 12 13 19 11 11 11 5 10 10 27 14 12 28 15 13 29 16 14 29 17 14 30 18 15 31 19 16 27 20 12 29 21 14 31 22 16 27 23 12 31 24 16 32 25 17 27 26 12 32 27 17 35 28 24 5 29 31 4 30 32 3 31 33 3 32 33 2 33 34 1 34 35 3 35 33 1 36 35 0 37 36 5 38 31 3 39 33 0 40 36 6 41 30 5 42 31 0 43 36 0 0 0 13 3 3 15 44 20 15 44 20 14 45 21 0 0 0 35 46 24 32 47 17 33 48 50 33 49 50 34 50 52 35 51 24 12 13 19 6 12 18 17 52 22 17 52 22 16 53 23 12 13 19 6 54 30 0 55 36 14 56 54 14 57 54 17 58 59 6 59 30 14 60 49 15 61 51 17 62 53 15 63 51 16 64 58 17 65 53 13 66 60 7 67 61 19 68 62 19 69 62 18 70 63 13 71 60 7 72 61 8 73 64 20 74 65 20 75 65 19 76 62 7 77 61 8 78 64 9 79 66 21 80 67 21 81 67 20 82 65 8 83 64 9 84 66 10 85 68 22 86 69 22 87 69 21 88 67 9 89 66 10 90 68 11 91 72 23 92 73 23 93 73 22 94 69 10 95 68 11 96 72 12 97 74 24 98 75 24 99 75 23 100 73 11 101 72 12 102 74 16 103 76 25 104 77 25 105 77 24 106 75 12 107 74 16 108 76 15 109 78 26 110 81 26 111 81 25 112 77 16 113 76 15 114 78 13 115 82 18 116 63 18 117 63 26 118 81 15 119 78 18 120 25 19 121 26 27 122 27 27 122 27 35 123 28 18 120 25 19 121 26 20 124 29 28 125 37 28 125 37 27 122 27 19 121 26 20 124 29 21 126 38 29 127 39 29 127 39 28 125 37 20 124 29 21 126 38 22 128 40 30 129 41 30 129 41 29 127 39 21 126 38 22 128 40 23 130 42 31 131 43 31 131 43 30 129 41 22 128 40 23 130 42 24 132 44 32 133 45 32 133 45 31 131 43 23 130 42 24 132 44 25 134 46 33 135 47 33 135 47 32 133 45 24 132 44 25 136 70 26 137 71 34 138 79 34 139 79 33 140 80 25 141 70 26 142 48 18 120 55 35 123 56 35 123 56 34 143 57 26 142 48 36 144 83 38 145 84 39 146 85 39 146 85 37 147 86 36 144 83 36 148 89 37 149 90 41 150 91 41 150 91 40 151 92 36 148 89 37 152 90 39 153 85 43 154 87 43 154 87 41 155 91 37 152 90 39 156 85 38 157 84 42 158 88 42 158 88 43 159 87 39 156 85 38 160 84 36 161 93 40 162 94 40 162 94 42 163 88 38 160 84 47 164 95 56 165 96 48 166 97 47 164 95 48 166 97 50 167 98 50 167 98 49 168 107 51 169 112 47 164 95 50 167 98 51 169 112 44 170 99 45 171 100 56 172 96 56 172 96 47 173 95 44 170 99 55 174 105 46 175 106 50 176 98 50 176 98 48 177 97 55 174 105 46 178 102 55 179 103 52 180 104 52 181 104 53 182 108 46 183 102 45 184 109 44 185 110 54 186 111 54 187 111 57 188 113 45 189 109 45 190 137 55 191 101 48 192 97 48 193 97 56 194 96 45 195 137 55 196 103 45 197 109 57 198 113 57 199 113 52 200 104 55 201 103 64 202 117 59 203 120 61 204 136 61 204 136 60 205 116 64 202 117 65 206 114 58 207 115 62 208 118 62 209 118 63 210 119 65 211 114 64 212 117 58 213 115 65 214 114 65 215 114 59 216 120 64 217 117 72 218 127 68 219 122 69 220 123 69 220 123 67 221 124 72 218 127 73 222 121 71 223 125 70 224 126 70 225 126 66 226 128 73 227 121 72 228 127 67 229 124 73 230 121 73 231 121 66 232 128 72 233 127 85 234 143 86 235 142 82 236 139 82 236 139 81 237 138 85 234 143 81 238 138 82 239 139 84 240 140 84 241 140 83 242 141 81 243 138 86 244 142 85 245 143 87 246 144 87 247 144 88 248 145 86 249 142 76 250 129 80 251 135 92 252 149 74 253 132 75 254 133 90 255 147 90 255 147 77 256 130 74 253 132 89 257 146 77 256 130 90 255 147 90 255 147 78 258 131 89 257 146 89 257 146 78 258 131 76 250 129 76 250 129 92 252 149 89 257 146 79 259 134 91 260 148 80 251 135 80 251 135 76 250 129 79 259 134</p>
</triangles>
</mesh>
</geometry>
</library_geometries>
<library_lights>
<light id="EnvironmentAmbientLight" name="EnvironmentAmbientLight">
<technique_common>
<ambient>
<color>0 0 0</color>
</ambient>
</technique_common>
</light>
</library_lights>
<library_visual_scenes>
<visual_scene id="MaxScene">
<node name="EnvironmentAmbientLight">
<instance_light url="#EnvironmentAmbientLight"/>
</node>
<node id="node-Cylinder001" name="Cylinder001">
<rotate>5.33851e-8 0.7071068 0.7071068 -180</rotate>
<scale>0.0367934 0.0367934 0.06453677</scale>
<node>
<matrix>1 0 0 0 0 1 0 979.6222 0 0 1 -3.708471 0 0 0 1</matrix>
<instance_geometry url="#geom-Cylinder001">
<bind_material>
<technique_common>
<instance_material symbol="_1_-_Default_1" target="#_1_-_Default-material"/>
</technique_common>
</bind_material>
</instance_geometry>
</node>
</node>
</visual_scene>
</library_visual_scenes>
<scene>
<instance_visual_scene url="#MaxScene"/>
</scene>
</COLLADA>
File diff suppressed because it is too large Load Diff
+698 -56
View File
File diff suppressed because it is too large Load Diff
@@ -60,6 +60,8 @@ export function StatusNoticeModal({
message, message,
detail, detail,
children, children,
actionLabel,
onAction,
onConfirm onConfirm
}: AppModalTheme & { }: AppModalTheme & {
tone: ModalTone tone: ModalTone
@@ -67,6 +69,8 @@ export function StatusNoticeModal({
message: string message: string
detail?: string detail?: string
children?: React.ReactNode children?: React.ReactNode
actionLabel?: string
onAction?: () => void
onConfirm: () => void onConfirm: () => void
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
@@ -84,7 +88,12 @@ export function StatusNoticeModal({
{detail} {detail}
</p> </p>
)} )}
<div className="mt-4 flex justify-end"> <div className="mt-4 flex justify-end gap-2">
{actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
{actionLabel}
</button>
)}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}> <button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
확인 확인
</button> </button>
@@ -195,6 +204,43 @@ export function SessionConfirmModal({
) )
} }
export function ControlExtensionConfirmModal({
overlayClassName,
dark,
platformName,
submitting = false,
onCancel,
onConfirm,
}: AppModalTheme & {
platformName: string
submitting?: boolean
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
<Clock3 size={15} className="text-[#f59e0b]" />
<strong className="text-[13px] font-bold">제어 시간 연장</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName} 제어 시간을 연장할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.
</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
{submitting ? '연장 중' : '연장'}
</button>
</div>
</div>
</section>
</div>
)
}
function ConfirmStatusModal({ function ConfirmStatusModal({
overlayClassName, overlayClassName,
dark, dark,
+10 -10
View File
@@ -33,7 +33,7 @@ export default function Sidebar({
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }> runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
canReleaseControl?: boolean canReleaseControl?: boolean
onOpenSession?: (sessionId?: string) => void onOpenSession?: (sessionId?: string) => void
onDisconnectSession?: () => void onDisconnectSession?: (sessionId?: string) => void
onReleaseControl?: () => void onReleaseControl?: () => void
}): React.JSX.Element { }): React.JSX.Element {
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false) const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
@@ -55,7 +55,7 @@ export default function Sidebar({
return ( return (
<aside <aside
className={[ className={[
'flex shrink-0 flex-col rounded-bl-[18px] bg-[#181b20] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out', 'flex shrink-0 flex-col rounded-bl-[18px] bg-[#111520] px-2.5 pb-3.5 pt-[18px] transition-[width] duration-200 ease-out',
collapsed ? 'w-[68px]' : 'w-[240px]' collapsed ? 'w-[68px]' : 'w-[240px]'
].join(' ')} ].join(' ')}
> >
@@ -133,7 +133,7 @@ export default function Sidebar({
> >
{!collapsed && ( {!collapsed && (
<div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1"> <div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1">
{visibleRunningSessions.map((session, index) => ( {visibleRunningSessions.map((session) => (
<div key={session.id} className="relative"> <div key={session.id} className="relative">
<button <button
type="button" type="button"
@@ -152,13 +152,13 @@ export default function Sidebar({
)} )}
</span> </span>
</button> </button>
{sessionStatus === 'running' && index === 0 && ( {sessionStatus === 'running' && (
<button <button
type="button" type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white" className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
onDisconnectSession?.() onDisconnectSession?.(session.id)
}} }}
aria-label="세션 연결종료" aria-label="세션 연결종료"
title="연결종료" title="연결종료"
@@ -177,13 +177,13 @@ export default function Sidebar({
className="relative grid h-[34px] w-full cursor-pointer place-items-center rounded bg-transparent text-white/75 transition hover:bg-white/[0.06]" className="relative grid h-[34px] w-full cursor-pointer place-items-center rounded bg-transparent text-white/75 transition hover:bg-white/[0.06]"
> >
<Activity size={17} /> <Activity size={17} />
<span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#181b20]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} /> <span className={['absolute right-2 top-1 h-2.5 w-2.5 rounded-full ring-2 ring-[#111520]', visibleRunningSessions[0]?.dotClassName ?? 'bg-[#14b8a6]'].join(' ')} />
</button> </button>
)} )}
{collapsed && sessionPopoverOpen && ( {collapsed && sessionPopoverOpen && (
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#181b20] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]"> <div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1"> <div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
{visibleRunningSessions.map((session, index) => ( {visibleRunningSessions.map((session) => (
<div key={session.id} className="relative border-b border-white/10 last:border-b-0"> <div key={session.id} className="relative border-b border-white/10 last:border-b-0">
<button <button
type="button" type="button"
@@ -205,14 +205,14 @@ export default function Sidebar({
)} )}
</span> </span>
</button> </button>
{sessionStatus === 'running' && index === 0 && ( {sessionStatus === 'running' && (
<button <button
type="button" type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white" className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
setSessionPopoverOpen(false) setSessionPopoverOpen(false)
onDisconnectSession?.() onDisconnectSession?.(session.id)
}} }}
aria-label="세션 연결종료" aria-label="세션 연결종료"
title="연결종료" title="연결종료"
+33 -1
View File
@@ -46,6 +46,9 @@ interface Window {
maximize: () => void maximize: () => void
close: () => void close: () => void
} }
appLifecycle: {
quit: () => void
}
learningVideo: { learningVideo: {
open: (url: string, title?: string) => Promise<{ ok: boolean; message?: string }> open: (url: string, title?: string) => Promise<{ ok: boolean; message?: string }>
} }
@@ -56,6 +59,7 @@ interface Window {
files: Record<string, string> files: Record<string, string>
folders: string[] folders: string[]
}> }>
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
@@ -208,6 +212,15 @@ interface Window {
localWorkspace: { localWorkspace: {
moduleFiles: Window['moduleFiles'] moduleFiles: Window['moduleFiles']
modules: Window['modules'] modules: Window['modules']
nav2: {
setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => Promise<{ ok: boolean; message: string }>
addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => Promise<{ ok: boolean; message: string }>
clearRuntimeObstacles: () => Promise<{ ok: boolean; message: string }>
}
worldLayout: {
read: (workspaceRoot?: string) => Promise<WorldLayout>
write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout>
}
visualization: Window['visualization'] visualization: Window['visualization']
git: Window['git'] git: Window['git']
terminal: Window['terminal'] terminal: Window['terminal']
@@ -215,6 +228,7 @@ interface Window {
remoteWorkspace: { remoteWorkspace: {
moduleFiles: { moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']> read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']>
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']> create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']>
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']> write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']>
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ReturnType<Window['moduleFiles']['copy']> copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ReturnType<Window['moduleFiles']['copy']>
@@ -244,10 +258,15 @@ interface Window {
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
} }
visualization: {
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
}
git: { git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']> status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']>
submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ReturnType<Window['git']['submit']> submit: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, message?: string, authorName?: string, authorEmail?: string, submissionId?: string) => ReturnType<Window['git']['submit']>
@@ -267,6 +286,19 @@ interface Window {
} }
} }
type WorldLayoutObject = {
id: string
type: 'pillar' | 'wall' | 'box'
position: [number, number, number]
rotation: [number, number, number]
size: [number, number, number]
}
type WorldLayout = {
version: 1
objects: WorldLayoutObject[]
}
type RemoteWorkspaceTarget = { type RemoteWorkspaceTarget = {
host: string host: string
user?: string | null user?: string | null
+42 -20
View File
@@ -1,6 +1,6 @@
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react' import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { ExpandableMarkdownMessage, QaInputBox } from '../components/ChatWidgets' import { ExpandableMarkdownMessage, QaInputBox } from '../components/ChatWidgets'
import type { QaChatMessage, QaChatThread } from './workspace/useQaChat' import type { QaChatMessage, QaChatThread } from './workspace/useQaChat'
@@ -40,58 +40,80 @@ export default function AiGuideView(props: Props): React.JSX.Element {
const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null) const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null)
const questionRefs = useRef<Record<string, HTMLElement | null>>({}) const questionRefs = useRef<Record<string, HTMLElement | null>>({})
const conversationScrollRef = useRef<HTMLDivElement | null>(null) const conversationScrollRef = useRef<HTMLDivElement | null>(null)
const latestMessageIdRef = useRef<string | null>(null)
const followConversationRef = useRef(true)
const contextLabel = weekNumber && weekTitle ? `${weekNumber}주차 · ${weekTitle}` : '선택된 학습 주차 없음' const contextLabel = weekNumber && weekTitle ? `${weekNumber}주차 · ${weekTitle}` : '선택된 학습 주차 없음'
const latestAssistantMessageId = [...messages].reverse().find((message) => message.role === 'assistant')?.id const latestAssistantMessageId = [...messages].reverse().find((message) => message.role === 'assistant')?.id
const questionMessages = messages.filter((message) => message.role === 'user') const questionMessages = messages.filter((message) => message.role === 'user')
const latestMessage = messages.at(-1)
useEffect(() => {
const latestMessageId = latestMessage?.id ?? null
const hasNewMessage = latestMessageId !== latestMessageIdRef.current
latestMessageIdRef.current = latestMessageId
if (hasNewMessage) followConversationRef.current = true
if (!hasNewMessage && !followConversationRef.current) return
const frame = window.requestAnimationFrame(() => {
const target = conversationScrollRef.current
if (!target) return
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
})
return () => window.cancelAnimationFrame(frame)
}, [busy, latestMessage?.content, latestMessage?.id, messages.length, typingMessageId])
const selectConversation = (threadId: string | null): void => { const selectConversation = (threadId: string | null): void => {
setSwitchingConversation(true) setSwitchingConversation(true)
setSelectedQuestionId(null) setSelectedQuestionId(null)
onSelectHistory(threadId) onSelectHistory(threadId)
window.requestAnimationFrame(() => window.requestAnimationFrame(() => { window.requestAnimationFrame(() => window.requestAnimationFrame(() => {
followConversationRef.current = true
conversationScrollRef.current?.scrollTo({ top: conversationScrollRef.current.scrollHeight, behavior: 'auto' }) conversationScrollRef.current?.scrollTo({ top: conversationScrollRef.current.scrollHeight, behavior: 'auto' })
window.setTimeout(() => setSwitchingConversation(false), 1000) window.setTimeout(() => setSwitchingConversation(false), 1000)
})) }))
} }
return ( return (
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#202020] text-white"> <section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#10131b] text-white">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#252525] px-3"> <header className="flex h-10 shrink-0 items-center justify-between border-b border-violet-200/15 bg-[#2a1d3f] px-3">
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong> <strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button> <button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded text-white/55 transition hover:bg-white/[0.08] hover:text-white"><X size={16} /></button>
</header> </header>
<div className="flex min-h-0 flex-1"> <div className="flex min-h-0 flex-1">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#202228]"> <aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3"> <div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<div className="rounded-lg border border-[#3b7ffa]/25 bg-[#3b7ffa]/[0.08] px-3 pb-3 pt-2"> <div className="rounded-lg border border-violet-300/20 bg-violet-400/[0.08] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-[#9bbdff]">현재 학습</span> <span className="text-[10px] font-bold tracking-[0.08em] text-violet-200">현재 학습</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong> <strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p> <p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
</div> </div>
<button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-[#82abff]" /><span className="min-w-0 flex-1">새 대화</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button> <button type="button" disabled={busy || disabled} onClick={onClear} className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Plus size={15} className="shrink-0 text-emerald-300" /><span className="min-w-0 flex-1">새 채팅</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>
<div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1"> <div className="mt-5"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">빠른 질문</span><div className="mt-2 grid gap-1">
{quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-[#82abff]" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)} {quickQuestions.map(({ Icon, label, prompt }) => <button key={label} type="button" disabled={disabled || busy || !weekNumber} onClick={() => onQuickSubmit(prompt)} className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"><Icon size={15} className="shrink-0 text-violet-300" /><span className="min-w-0 flex-1">{label}</span><ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" /></button>)}
</div></div> </div></div>
<div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 대화</span><div className="mt-2 grid gap-1"> <div className="mt-6 border-t border-white/[0.08] pt-4"><span className="px-1 text-[10px] font-bold tracking-[0.08em] text-white/38">최근 채팅</span><div className="mt-2 grid gap-1">
{activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 대화'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 대화</span></button> : viewingHistory ? <button type="button" onClick={() => selectConversation(null)} className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[11px] font-semibold text-white/48 transition hover:bg-white/[0.06] hover:text-white/75"><MessageSquareText size={13} /><span className="truncate">새 대화로 돌아가기</span></button> : null} {activeConversation ? <button type="button" onClick={() => selectConversation(null)} className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span></button> : null}
{history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 대화'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="대화 삭제" aria-label="대화 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)} {history.map((thread) => <div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}><button type="button" onClick={() => selectConversation(thread.id)} className="min-w-0 flex-1 cursor-pointer px-2 py-2 text-left"><span className="block truncate text-[11px] font-semibold">{thread.title || '이전 채팅'}</span><span className="mt-0.5 block text-[10px] text-white/32">{formatDate(thread.lastMessageAt)}</span></button><button type="button" title="채팅 삭제" aria-label="채팅 삭제" onClick={() => setDeleteConfirmThread(thread)} className="mr-1.5 grid size-6 shrink-0 cursor-pointer place-items-center rounded text-white/30 opacity-0 transition hover:bg-white/[0.08] hover:text-[#f87171] group-hover:opacity-100"><Trash2 size={13} /></button></div>)}
</div></div> </div></div>
</div> </div>
</aside> </aside>
<main className="relative flex min-w-0 flex-1 flex-col bg-[#181a20]"> <main className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
<div ref={conversationScrollRef} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-7"> <div ref={conversationScrollRef} onScroll={(event) => {
{loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 대화를 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5"> const target = event.currentTarget
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-[#3b7ffa]/25 bg-[#3b7ffa]/10 text-[#96b8ff]"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>} followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#3b7ffa] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(32,91,204,0.24)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-[#8fb4ff]"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)} }} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#22252c] px-4 py-3 text-[12px] font-semibold text-[#8fb4ff]"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>} {loading ? <div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div> : disabled ? <div className="grid h-full min-h-[300px] place-items-center text-center"><div><span className="mx-auto grid size-10 place-items-center rounded-lg bg-white/[0.06] text-white/42"><MessageSquareText size={19} /></span><strong className="mt-3 block text-[14px] text-white/75">AI 가이드를 준비하고 있습니다</strong></div></div> : <div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && <div className="py-9 text-center"><span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span><h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2><p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p></div>}
{messages.map((message) => <article key={message.id} ref={message.role === 'user' ? (node) => { questionRefs.current[message.id] = node } : undefined} className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}><div className={message.role === 'user' ? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]' : 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}>{message.role === 'user' ? <p className="whitespace-pre-wrap">{message.content}</p> : <><>{(message.isTyping || message.id === typingMessageId) && <span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>}</><ExpandableMarkdownMessage content={message.content || '응답을 작성하고 있습니다.'} dark defaultCollapsed={!message.keepExpanded && message.id !== latestAssistantMessageId} /></>}</div>{message.status === 'CANCELED' && <p className="mt-1 text-[10px] font-medium text-white/32">응답이 중단됨</p>}</article>)}
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
</div>} </div>}
</div> </div>
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div> <div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#181a20]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252525] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-[#8fb4ff]" /> 대화 불러오는 중</span></div>} {switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>}
</main> </main>
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#202228]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold tracking-[0.08em] text-white/44">질문 목록</span></div><div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside> <aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
</div> </div>
{deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#252525] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">대화 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 대화 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 대화'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 대화와 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>} {deleteConfirmThread && <div className="absolute inset-0 z-50 grid place-items-center bg-black/36"><section className="w-[360px] overflow-hidden rounded-lg border border-white/10 bg-[#1b2a42] text-white shadow-[0_24px_70px_rgba(0,0,0,0.42)]"><div className="flex items-center gap-2 border-b border-white/10 px-4 py-3"><Trash2 size={15} className="text-[#e87979]" /><strong className="text-[13px] font-bold">채팅 삭제</strong></div><div className="p-4"><p className="text-[13px] font-semibold text-white">이 채팅 내역을 삭제할까요?</p><p className="mt-2 truncate text-[12px] font-medium text-white/52">{deleteConfirmThread.title || '이전 채팅'}</p><p className="mt-2 text-[12px] font-medium leading-relaxed text-white/45">삭제한 채팅과 답변은 복구할 수 없습니다.</p><div className="mt-4 flex justify-end gap-2"><button type="button" onClick={() => setDeleteConfirmThread(null)} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/62 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" onClick={() => { onDeleteHistory(deleteConfirmThread.id); setDeleteConfirmThread(null) }} className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]">삭제</button></div></div></section></div>}
</section> </section>
) )
} }
@@ -65,9 +65,20 @@ export type CurriculumWeek = {
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
defaultParameters?: string[] | null
dockerImage?: string | null dockerImage?: string | null
}> }>
environmentProfiles?: EnvironmentProfile[] environmentProfiles?: EnvironmentProfile[]
maps?: Array<{
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
layout?: unknown
}>
} }
export type CurriculumResponse = { export type CurriculumResponse = {
@@ -25,7 +25,7 @@ export default function PlatformSelectModal({
}: PlatformSelectModalProps): React.JSX.Element { }: PlatformSelectModalProps): React.JSX.Element {
return ( return (
<div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]"> <div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]">
<section className="flex h-[660px] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]"> <section className="flex h-[min(660px,calc(100vh-86px))] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5"> <header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
<div> <div>
<span className="text-[12px] font-medium text-[#6f4df6]">{weekLabel}</span> <span className="text-[12px] font-medium text-[#6f4df6]">{weekLabel}</span>
@@ -45,7 +45,7 @@ export default function PlatformSelectionPanel({
onOpenSettings onOpenSettings
}: PlatformSelectionPanelProps): React.JSX.Element { }: PlatformSelectionPanelProps): React.JSX.Element {
return ( return (
<div className={['min-h-0 w-1/2 shrink-0 px-6 pb-5 pt-3 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}> <div className={['h-full min-h-0 w-1/2 shrink-0 overflow-y-auto px-6 pb-5 pt-3 pr-5 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
{runningPlatforms.length > 0 && ( {runningPlatforms.length > 0 && (
<div className="mb-4 pr-1"> <div className="mb-4 pr-1">
<p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p> <p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p>
@@ -76,12 +76,7 @@ export default function PlatformSelectionPanel({
counts={counts} counts={counts}
onChange={onFilterChange} onChange={onFilterChange}
/> />
<div data-help="platform-list" className={[ <div data-help="platform-list" className="grid content-start gap-3">
'grid content-start gap-3 overflow-y-auto pr-1',
runningPlatforms.length > 0
? 'min-h-[340px] max-h-[340px]'
: 'min-h-[470px] max-h-[470px]'
].join(' ')}>
{filteredPlatforms.length ? filteredPlatforms.map((platform) => { {filteredPlatforms.length ? filteredPlatforms.map((platform) => {
const { id, status, action, state, iconKey } = platform const { id, status, action, state, iconKey } = platform
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
@@ -53,7 +53,7 @@ export async function fetchPlatformStatuses(fetchWithFreshAuth: AuthFetch, platf
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ platformIds }) body: JSON.stringify({ platformIds })
}, { invalidateOnRefreshFailure: false }) })
if (!response.ok) return [] if (!response.ok) return []
const payload = await response.json() as { platforms?: Array<Partial<PlatformOption> & { id: string }> } const payload = await response.json() as { platforms?: Array<Partial<PlatformOption> & { id: string }> }
@@ -75,6 +75,17 @@ export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, plat
if (!response.ok) throw new Error('platform-release failed') if (!response.ok) throw new Error('platform-release failed')
} }
export async function extendPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<PlatformControlActionResponse> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/extend`, {
method: 'POST',
})
const payload = await response.json().catch(() => null) as PlatformControlActionResponse | { message?: string } | null
if (!response.ok) {
throw new Error(payload && 'message' in payload && payload.message ? payload.message : '제어 시간 연장에 실패했습니다.')
}
return payload as PlatformControlActionResponse
}
export async function requestOrCancelPlatformControl( export async function requestOrCancelPlatformControl(
fetchWithFreshAuth: AuthFetch, fetchWithFreshAuth: AuthFetch,
platform: PlatformOption platform: PlatformOption
+3 -1
View File
@@ -30,6 +30,7 @@ export type PlatformOption = {
state: 'ready' | 'request' | 'reserved' | 'locked' state: 'ready' | 'request' | 'reserved' | 'locked'
sourceSettings?: Partial<StudioSourceSettings> | null sourceSettings?: Partial<StudioSourceSettings> | null
environmentProfiles?: EnvironmentProfile[] environmentProfiles?: EnvironmentProfile[]
environmentConfigOverride?: Record<string, unknown> | null
connection?: { connection?: {
registered: boolean registered: boolean
headscaleOnline: boolean headscaleOnline: boolean
@@ -51,6 +52,7 @@ export type PlatformOption = {
startedAt: string startedAt: string
endsAt: string endsAt: string
warningAt: string warningAt: string
extensionCount?: number
} | null } | null
queue: { queue: {
id: string id: string
@@ -63,7 +65,7 @@ export type PlatformOption = {
export type PlatformControlState = NonNullable<PlatformOption['control']> export type PlatformControlState = NonNullable<PlatformOption['control']>
export type PlatformControlActionResponse = { export type PlatformControlActionResponse = {
result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED' result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED' | 'EXTENDED'
platformId?: string platformId?: string
session?: PlatformControlState['session'] session?: PlatformControlState['session']
queue?: PlatformControlState['queue'] queue?: PlatformControlState['queue']
@@ -9,6 +9,7 @@ type DeletableModule = {
type ModuleDeleteConfirmProps<Module extends DeletableModule> = { type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
dark: boolean dark: boolean
module: Module module: Module
allowRepositoryDeletion?: boolean
onCancel: () => void onCancel: () => void
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
} }
@@ -16,6 +17,7 @@ type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
export default function ModuleDeleteConfirm<Module extends DeletableModule>({ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
dark, dark,
module, module,
allowRepositoryDeletion = true,
onCancel, onCancel,
onConfirm onConfirm
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element { }: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
@@ -40,7 +42,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}> <p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
워크스페이스에 설치된 모듈 폴더가 삭제됩니다. 워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
</p> </p>
<label {allowRepositoryDeletion && <label
className={[ className={[
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3', 'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]' dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
@@ -60,7 +62,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다. 제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
</span> </span>
</span> </span>
</label> </label>}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button <button
type="button" type="button"
@@ -74,7 +76,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
</button> </button>
<button <button
type="button" type="button"
onClick={() => onConfirm(module, { deleteRepository })} onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]" className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
> >
삭제 삭제
@@ -17,8 +17,16 @@ type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
type ModuleListRowProps<Module extends ModuleListRowItem> = { type ModuleListRowProps<Module extends ModuleListRowItem> = {
dark: boolean dark: boolean
module: Module module: Module
draggable?: boolean
dragging?: boolean
dropTarget?: boolean
onDragStart?: () => void
onDragOver?: () => void
onDrop?: () => void
onDragEnd?: () => void
installing: boolean installing: boolean
controlling: boolean controlling: boolean
bulkControlling: boolean
onAddModuleTab: (module: Module) => void onAddModuleTab: (module: Module) => void
onOpenSource: (module: Module) => void onOpenSource: (module: Module) => void
onOpenParameters: (module: Module) => void onOpenParameters: (module: Module) => void
@@ -78,8 +86,16 @@ function TooltipLabel({ dark, children }: { dark: boolean; children: React.React
export default function ModuleListRow<Module extends ModuleListRowItem>({ export default function ModuleListRow<Module extends ModuleListRowItem>({
dark, dark,
module, module,
draggable = false,
dragging = false,
dropTarget = false,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
installing, installing,
controlling, controlling,
bulkControlling,
onAddModuleTab, onAddModuleTab,
onOpenSource, onOpenSource,
onOpenParameters, onOpenParameters,
@@ -121,15 +137,37 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
const installAction = module.state === 'uninstalled' const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone } ? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone } : { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
return ( return (
<div <div
draggable={draggable}
onDragStart={(event) => {
if (!draggable) return
event.dataTransfer.effectAllowed = 'move'
event.dataTransfer.setData('text/plain', module.name)
onDragStart?.()
}}
onDragOver={(event) => {
if (!draggable) return
event.preventDefault()
event.dataTransfer.dropEffect = 'move'
onDragOver?.()
}}
onDrop={(event) => {
if (!draggable) return
event.preventDefault()
onDrop?.()
}}
onDragEnd={() => onDragEnd?.()}
onDoubleClick={() => { onDoubleClick={() => {
if (selectable) onOpenSource(module) if (selectable) onOpenSource(module)
}} }}
className={[ className={[
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition', 'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
'cursor-default', 'cursor-default',
dragging ? 'opacity-45' : '',
dropTarget ? (dark ? 'bg-[#2563eb]/14 outline outline-1 outline-[#3b82f6]/50' : 'bg-[#edf6ff] outline outline-1 outline-[#2563eb]/35') : '',
selectable selectable
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]' ? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
: dark ? 'text-white/42' : 'text-[#8a94a5]' : dark ? 'text-white/42' : 'text-[#8a94a5]'
@@ -215,7 +253,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{controlActions.map(({ label, Icon, tone, action }) => ( {controlActions.map(({ label, Icon, tone, action }) => (
(() => { (() => {
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지' const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
const disabled = busy && !canCancelBuild const disabled = bulkControlling || (busy && !canCancelBuild)
return ( return (
<button <button
key={`${module.name}-${label}`} key={`${module.name}-${label}`}
@@ -238,7 +276,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className="flex justify-center"> <span className="flex justify-center">
<button <button
type="button" type="button"
disabled={busy} disabled={installActionDisabled}
onClick={(event) => { onClick={(event) => {
event.stopPropagation() event.stopPropagation()
if (module.state === 'uninstalled') { if (module.state === 'uninstalled') {
@@ -248,7 +286,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
onDeleteModule(module) onDeleteModule(module)
}} }}
aria-label={installAction.label} aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, busy)} className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
> >
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />} {busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel> <TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
@@ -15,6 +15,7 @@ type ModuleListToolbarProps = {
onInstallAll: () => void onInstallAll: () => void
onStartAll: () => void onStartAll: () => void
onStopAll: () => void onStopAll: () => void
onOpenWorldEditor: () => void
} }
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [ const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
@@ -35,14 +36,15 @@ export default function ModuleListToolbar({
onFilterChange, onFilterChange,
onInstallAll, onInstallAll,
onStartAll, onStartAll,
onStopAll onStopAll,
onOpenWorldEditor
}: ModuleListToolbarProps): React.JSX.Element { }: ModuleListToolbarProps): React.JSX.Element {
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const startingAll = installingModuleName === '__start_all__' const startingAll = installingModuleName === '__start_all__'
const stoppingAll = installingModuleName === '__stop_all__' const stoppingAll = installingModuleName === '__stop_all__'
const bulkActions = [ const bulkActions = [
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined }, { label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
{ {
label: '전체 설치', label: '전체 설치',
Icon: Download, Icon: Download,
@@ -17,6 +17,7 @@ type ModuleListViewProps = {
controllingModuleNames: string[] controllingModuleNames: string[]
listError: string | null listError: string | null
installError: string | null installError: string | null
allowRepositoryDeletion?: boolean
onAddModuleTab: (module: ModuleRow) => void onAddModuleTab: (module: ModuleRow) => void
onOpenSource: (module: ModuleRow) => void onOpenSource: (module: ModuleRow) => void
onInstallModule: (module: ModuleRow) => void onInstallModule: (module: ModuleRow) => void
@@ -29,6 +30,8 @@ type ModuleListViewProps = {
onInstallAll: () => void onInstallAll: () => void
onStartAll: () => void onStartAll: () => void
onStopAll: () => void onStopAll: () => void
onReorderModules: (source: ModuleRow, target: ModuleRow) => void
onOpenWorldEditor: () => void
} }
export default function ModuleListView({ export default function ModuleListView({
@@ -39,6 +42,7 @@ export default function ModuleListView({
controllingModuleNames, controllingModuleNames,
listError, listError,
installError, installError,
allowRepositoryDeletion = true,
onAddModuleTab, onAddModuleTab,
onOpenSource, onOpenSource,
onInstallModule, onInstallModule,
@@ -50,16 +54,22 @@ export default function ModuleListView({
onRestartModule, onRestartModule,
onInstallAll, onInstallAll,
onStartAll, onStartAll,
onStopAll onStopAll,
onReorderModules,
onOpenWorldEditor
}: ModuleListViewProps): React.JSX.Element { }: ModuleListViewProps): React.JSX.Element {
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all') const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null) const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null) const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter) const filteredModuleRows = moduleFilter === 'all' ? modules : modules.filter((row) => row.state === moduleFilter)
const canReorder = moduleFilter === 'all' && !loading
const uninstalledCount = modules.filter((row) => row.state === 'uninstalled').length const uninstalledCount = modules.filter((row) => row.state === 'uninstalled').length
const runningCount = modules.filter((row) => row.state === 'running').length const runningCount = modules.filter((row) => row.state === 'running').length
const stoppedCount = modules.filter((row) => row.state === 'stopped').length const stoppedCount = modules.filter((row) => row.state === 'stopped').length
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => { useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => { const handleEscape = (event: KeyboardEvent): void => {
@@ -102,6 +112,7 @@ export default function ModuleListView({
onInstallAll={onInstallAll} onInstallAll={onInstallAll}
onStartAll={onStartAll} onStartAll={onStartAll}
onStopAll={onStopAll} onStopAll={onStopAll}
onOpenWorldEditor={onOpenWorldEditor}
/> />
<ModuleListHeader dark={dark} /> <ModuleListHeader dark={dark} />
{(listError || installError) && ( {(listError || installError) && (
@@ -120,8 +131,25 @@ export default function ModuleListView({
key={row.name} key={row.name}
dark={dark} dark={dark}
module={row} module={row}
draggable={canReorder}
dragging={draggedModule?.name === row.name}
dropTarget={dropTargetModuleName === row.name && draggedModule?.name !== row.name}
onDragStart={() => setDraggedModule(row)}
onDragOver={() => {
if (draggedModule && draggedModule.name !== row.name) setDropTargetModuleName(row.name)
}}
onDrop={() => {
if (draggedModule && draggedModule.name !== row.name) onReorderModules(draggedModule, row)
setDraggedModule(null)
setDropTargetModuleName(null)
}}
onDragEnd={() => {
setDraggedModule(null)
setDropTargetModuleName(null)
}}
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')} installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)} controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling}
onAddModuleTab={onAddModuleTab} onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource} onOpenSource={onOpenSource}
onOpenParameters={(module) => { onOpenParameters={(module) => {
@@ -148,6 +176,7 @@ export default function ModuleListView({
<ModuleDeleteConfirm <ModuleDeleteConfirm
dark={dark} dark={dark}
module={deleteConfirmModule} module={deleteConfirmModule}
allowRepositoryDeletion={allowRepositoryDeletion}
onCancel={() => setDeleteConfirmModule(null)} onCancel={() => setDeleteConfirmModule(null)}
onConfirm={(module, options) => { onConfirm={(module, options) => {
onDeleteModule(module, options) onDeleteModule(module, options)
@@ -196,7 +196,7 @@ export default function WorkspaceFilePanel({
}: WorkspaceFilePanelProps): React.JSX.Element { }: WorkspaceFilePanelProps): React.JSX.Element {
return ( return (
<aside <aside
className={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')} className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined} style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
> >
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}> <div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
@@ -396,7 +396,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
} = props } = props
return ( return (
<div className={['px-2 py-2 text-[13px] font-medium', textMid].join(' ')}> <div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<button <button
type="button" type="button"
onClick={onSelectRoot} onClick={onSelectRoot}
@@ -16,6 +16,7 @@ type WorkspaceTopBarProps = {
previewOpen: boolean previewOpen: boolean
showControlCountdown: boolean showControlCountdown: boolean
controlCountdownDanger: boolean controlCountdownDanger: boolean
canExtendControl: boolean
controlEndsAt: string | null controlEndsAt: string | null
submitVisible: boolean submitVisible: boolean
submitDisabled: boolean submitDisabled: boolean
@@ -28,6 +29,7 @@ type WorkspaceTopBarProps = {
onTogglePreview: () => void onTogglePreview: () => void
onSubmit: () => void onSubmit: () => void
onOpenPowerActions: () => void onOpenPowerActions: () => void
onRequestControlExtension: () => void
onClose: () => void onClose: () => void
} }
@@ -45,6 +47,7 @@ export default function WorkspaceTopBar({
previewOpen, previewOpen,
showControlCountdown, showControlCountdown,
controlCountdownDanger, controlCountdownDanger,
canExtendControl,
controlEndsAt, controlEndsAt,
submitVisible, submitVisible,
submitDisabled, submitDisabled,
@@ -57,6 +60,7 @@ export default function WorkspaceTopBar({
onTogglePreview, onTogglePreview,
onSubmit, onSubmit,
onOpenPowerActions, onOpenPowerActions,
onRequestControlExtension,
onClose onClose
}: WorkspaceTopBarProps): React.JSX.Element { }: WorkspaceTopBarProps): React.JSX.Element {
return ( return (
@@ -101,6 +105,15 @@ export default function WorkspaceTopBar({
<Clock3 size={13} /> <Clock3 size={13} />
{formatControlRemainingTime(controlEndsAt)} {formatControlRemainingTime(controlEndsAt)}
</span> </span>
{controlCountdownDanger && canExtendControl && (
<button
type="button"
onClick={onRequestControlExtension}
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
>
연장
</button>
)}
</> </>
)} )}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} /> <span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -1,7 +1,8 @@
import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react' import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
import type React from 'react' import type React from 'react'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
export type Nav2VisualizationMode = 'slam' | 'navigation'
type WorkspaceVisualizationPanelProps = { type WorkspaceVisualizationPanelProps = {
dark: boolean dark: boolean
@@ -16,6 +17,8 @@ type WorkspaceVisualizationPanelProps = {
running: boolean running: boolean
busy: boolean busy: boolean
viewerUrl: string viewerUrl: string
nav2Mode: Nav2VisualizationMode
navigationMapSelected: boolean
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onToggleLarge: () => void onToggleLarge: () => void
onClose: () => void onClose: () => void
@@ -38,6 +41,8 @@ export default function WorkspaceVisualizationPanel({
running, running,
busy, busy,
viewerUrl, viewerUrl,
nav2Mode,
navigationMapSelected,
onDragStart, onDragStart,
onToggleLarge, onToggleLarge,
onClose, onClose,
@@ -46,6 +51,15 @@ export default function WorkspaceVisualizationPanel({
onStop, onStop,
onResizeStart onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element { }: WorkspaceVisualizationPanelProps): React.JSX.Element {
const selectableStacks = navigationMapSelected
? ['nav2'] as VisualizationStack[]
: stacks.filter((stack) => stack !== 'nav2')
const stackLabel = (stack: VisualizationStack): string => {
if (stack === 'slam') return '2D SLAM'
if (stack === 'slam3d') return '3D SLAM'
if (stack === 'nav2') return '주행'
return 'Autoware'
}
return ( return (
<section <section
className={[ className={[
@@ -106,47 +120,60 @@ export default function WorkspaceVisualizationPanel({
style={large ? undefined : { height: size.height }} style={large ? undefined : { height: size.height }}
> >
<div className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#101624] px-3"> <div className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#101624] px-3">
<div className="flex items-center gap-2"> {navigationMapSelected ? (
{stacks.map((stack) => ( <span className="text-[11px] font-bold text-white/72">주행</span>
) : selectableStacks.length > 1 ? (
<div className="flex items-center gap-1">
{selectableStacks.map((stack) => (
<button <button
key={stack} key={stack}
type="button" type="button"
disabled={running || busy} disabled={busy}
onClick={() => onStackChange(stack)} onClick={() => onStackChange(stack)}
className={[ className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black uppercase transition', 'h-7 rounded-md px-2.5 text-[11px] font-bold transition',
activeStack === stack ? 'bg-[#2563eb] text-white' : 'bg-white/8 text-white/58 hover:bg-white/12 hover:text-white', stack === activeStack
running || busy ? 'cursor-default opacity-70' : '' ? 'bg-white/14 text-white'
: 'text-white/52 hover:bg-white/8 hover:text-white/80',
busy ? 'cursor-default opacity-55' : 'cursor-pointer'
].join(' ')} ].join(' ')}
> >
{stack === 'nav2' ? 'Nav2' : stack === 'slam' ? 'SLAM' : 'Autoware'} {stackLabel(stack)}
</button> </button>
))} ))}
{!stacks.length && (
<span className="text-[11px] font-bold text-white/48">지원되는 시각화 없음</span>
)}
</div> </div>
) : <span />}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{running ? (
<button <button
type="button" type="button"
disabled={busy || !stacks.length} disabled={busy || !stacks.length}
onClick={() => { onClick={onStop}
if (running) onStop()
else onStart()
}}
className={[ className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition', 'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : '' busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')} ].join(' ')}
> >
{busy ? '처리 중' : running ? '종료' : '시작'} {busy ? '처리 중' : '종료'}
</button> </button>
) : (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStart}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '시작'}
</button>
)}
</div> </div>
</div> </div>
<div className="relative min-h-0 flex-1"> <div className="relative min-h-0 flex-1">
<iframe <iframe
key={`${activeStack}-${running ? 'running' : 'offline'}`} key={`${activeStack}-${nav2Mode}-${running ? 'running' : 'offline'}`}
title="Autonomy Studio Visualization" title="Autonomy Studio Visualization"
src={running ? viewerUrl : 'about:blank'} src={running ? viewerUrl : 'about:blank'}
className="h-full w-full border-0" className="h-full w-full border-0"
@@ -0,0 +1,509 @@
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
type WorldObjectType = WorldLayoutObject['type']
type WorldEditorModalProps = {
workspaceRoot: string
onClose: () => void
onSaved?: () => void
}
type TransformMode = 'translate' | 'rotate' | 'scale'
const objectLabels: Record<WorldObjectType, string> = {
pillar: '기둥',
wall: '벽',
box: '박스'
}
const defaultsByType: Record<WorldObjectType, Omit<WorldLayoutObject, 'id' | 'type'>> = {
pillar: { position: [0, 0, 1], rotation: [0, 0, 0], size: [0.5, 0.5, 2] },
wall: { position: [0, 0, 1], rotation: [0, 0, 0], size: [4, 0.2, 2] },
box: { position: [0, 0, 0.5], rotation: [0, 0, 0], size: [1, 1, 1] }
}
function createObject(type: WorldObjectType, index: number): WorldLayoutObject {
const template = defaultsByType[type]
return {
id: `${type}-${Date.now()}-${index}`,
type,
position: [template.position[0] + index * 0.7, template.position[1] + index * 0.45, template.position[2]],
rotation: [...template.rotation],
size: [...template.size]
}
}
function objectMaterial(type: WorldObjectType): THREE.MeshStandardMaterial {
const color = type === 'pillar' ? '#fb923c' : type === 'wall' ? '#60a5fa' : '#a78bfa'
return new THREE.MeshStandardMaterial({ color, roughness: 0.68, metalness: 0.05 })
}
function createMesh(item: WorldLayoutObject): THREE.Mesh {
const [width, depth, height] = item.size
const geometry = item.type === 'pillar'
? new THREE.CylinderGeometry(Math.max(width, depth) / 2, Math.max(width, depth) / 2, height, 32)
: new THREE.BoxGeometry(width, depth, height)
const mesh = new THREE.Mesh(geometry, objectMaterial(item.type))
mesh.position.set(item.position[0], item.position[1], item.position[2])
mesh.rotation.set(item.rotation[0], item.rotation[1], item.rotation[2])
if (item.type === 'pillar') mesh.rotateX(Math.PI / 2)
mesh.castShadow = true
mesh.receiveShadow = true
mesh.userData.objectId = item.id
return mesh
}
const modelRoot = '/slam-viewer/turtlebot3_world_model/turtlebot3_world'
const modelRoots: Record<string, string> = {
turtlebot3_world: modelRoot,
turtlebot3_house: '/slam-viewer/turtlebot3_world_model/turtlebot3_house',
turtlebot3_common: '/slam-viewer/turtlebot3_world_model/turtlebot3_common'
}
function parseNumbers(value: string | null | undefined, count = 0): number[] {
const numbers = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isFinite)
while (numbers.length < count) numbers.push(0)
return numbers
}
function directChild(element: Element | null | undefined, name: string): Element | null {
return Array.from(element?.children ?? []).find((child) => child.tagName === name) ?? null
}
function childText(element: Element | null | undefined, name: string, fallback = ''): string {
return directChild(element, name)?.textContent?.trim() || fallback
}
function applySdfPose(target: THREE.Object3D, value: string): void {
const [x, y, z, roll, pitch, yaw] = parseNumbers(value, 6)
target.position.set(x, y, z)
target.rotation.set(roll, pitch, yaw, 'XYZ')
}
function worldMaterial(visual: Element): THREE.MeshLambertMaterial {
const text = `${visual.getAttribute('name') ?? ''} ${Array.from(visual.querySelectorAll('uri, script name')).map((item) => item.textContent ?? '').join(' ')}`.toLowerCase()
const ambient = visual.querySelector('ambient')?.textContent
const [red, green, blue] = parseNumbers(ambient, 3)
const color = ambient ? new THREE.Color(red, green, blue) : text.includes('wall') ? '#171717' : text.includes('green') ? '#16a34a' : '#e5e7eb'
// TurtleBot3 기본 월드는 외곽 벽 메시의 안쪽에서 보게 된다.
// 내부 면도 표시해야 다각형 전체 벽 구조가 보인다.
return new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide })
}
function gazeboAssetPath(uri: string): string {
return uri.replace(/^model:\/\/(\w+)\//, (_match, name: string) => `${modelRoots[name] ?? `/slam-viewer/turtlebot3_world_model/${name}`}/`)
}
async function loadWorldSdf(parent: THREE.Group): Promise<void> {
const response = await fetch(`${modelRoot}/model.sdf`)
if (!response.ok) throw new Error(`기본 월드 모델을 불러오지 못했습니다. (${response.status})`)
const documentNode = new DOMParser().parseFromString(await response.text(), 'application/xml')
const model = documentNode.querySelector('model')
if (!model) throw new Error('기본 월드 모델 형식이 올바르지 않습니다.')
const colladaLoader = new ColladaLoader()
const stlLoader = new STLLoader()
const modelGroup = new THREE.Group()
applySdfPose(modelGroup, childText(model, 'pose', '0 0 0 0 0 0'))
parent.add(modelGroup)
const loadMesh = async (target: THREE.Group, visual: Element, geometry: Element): Promise<void> => {
const meshNode = directChild(geometry, 'mesh')
const uri = childText(meshNode, 'uri')
if (!uri) return
const scale = parseNumbers(childText(meshNode, 'scale', '1 1 1'), 3)
const url = gazeboAssetPath(uri)
const material = worldMaterial(visual)
if (url.endsWith('.stl')) {
const geometryValue = await stlLoader.loadAsync(url)
geometryValue.computeVertexNormals()
const mesh = new THREE.Mesh(geometryValue, material)
mesh.scale.set(scale[0], scale[1], scale[2])
mesh.castShadow = true
mesh.receiveShadow = true
target.add(mesh)
return
}
const collada = await colladaLoader.loadAsync(url)
collada.scene.scale.set(scale[0], scale[1], scale[2])
collada.scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return
child.material = material.clone()
child.castShadow = true
child.receiveShadow = true
})
target.add(collada.scene)
}
for (const link of Array.from(model.querySelectorAll(':scope > link'))) {
const linkGroup = new THREE.Group()
applySdfPose(linkGroup, childText(link, 'pose', '0 0 0 0 0 0'))
modelGroup.add(linkGroup)
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
const visualGroup = new THREE.Group()
applySdfPose(visualGroup, childText(visual, 'pose', '0 0 0 0 0 0'))
linkGroup.add(visualGroup)
const geometry = directChild(visual, 'geometry')
if (!geometry) continue
const material = worldMaterial(visual)
if (directChild(geometry, 'mesh')) {
await loadMesh(visualGroup, visual, geometry).catch(() => undefined)
continue
}
const box = directChild(geometry, 'box')
const cylinder = directChild(geometry, 'cylinder')
if (box) {
const [width, depth, height] = parseNumbers(childText(box, 'size', '1 1 1'), 3)
visualGroup.add(new THREE.Mesh(new THREE.BoxGeometry(width, depth, height), material))
} else if (cylinder) {
const radius = Number(childText(cylinder, 'radius', '0.1')) || 0.1
const height = Number(childText(cylinder, 'length', '1')) || 1
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 32), material)
mesh.rotateX(Math.PI / 2)
visualGroup.add(mesh)
}
}
}
}
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 selectedIdRef = useRef<string | null>(null)
const renderSceneRef = useRef<(() => void) | null>(null)
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [transformMode, setTransformMode] = useState<TransformMode>('translate')
useEffect(() => {
let active = true
void window.localWorkspace.worldLayout.read(workspaceRoot)
.then((nextLayout) => {
if (!active) return
worldRef.current = nextLayout
setLayout(nextLayout)
setSelectedId(nextLayout.objects[0]?.id ?? null)
})
.catch((cause: unknown) => {
if (!active) return
setError(cause instanceof Error ? cause.message : '월드 정보를 불러오지 못했습니다.')
})
.finally(() => active && setLoading(false))
return () => { active = false }
}, [workspaceRoot])
useEffect(() => {
worldRef.current = layout
}, [layout])
useEffect(() => {
selectedIdRef.current = selectedId
renderSceneRef.current?.()
sendLayoutToViewer(worldRef.current)
}, [selectedId])
const sendLayoutToViewer = (nextLayout: WorldLayout): void => {
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-layout',
layout: nextLayout,
selectedId: selectedIdRef.current,
}, '*')
}
useEffect(() => {
sendLayoutToViewer(layout)
}, [layout])
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
if (event.data?.source !== 'autonomy-world-editor') return
if (event.data.type === 'ready') {
sendLayoutToViewer(worldRef.current)
return
}
if (event.data.type === 'select-object') setSelectedId(typeof event.data.id === 'string' ? event.data.id : null)
if (event.data.type === 'transform-object' && typeof event.data.id === 'string' && event.data.patch) {
const patch = event.data.patch as Partial<WorldLayoutObject>
setLayout((current) => {
const next = {
...current,
objects: current.objects.map((item) => item.id === event.data.id ? { ...item, ...patch } : item),
}
worldRef.current = next
return next
})
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
useEffect(() => {
const host = canvasHostRef.current
// The editor iframe owns the actual TurtleBot3 world rendering. Keep the
// previous canvas path dormant while the shared viewer is mounted.
if (!host || loading || viewerRef.current) return
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b1220')
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 100)
camera.position.set(4, 6, 5)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
host.replaceChildren(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.target.set(0, 0, 0)
controls.enableDamping = true
controls.maxPolarAngle = Math.PI / 2.08
controls.update()
scene.add(new THREE.HemisphereLight('#dbeafe', '#0b1220', 1.7))
const keyLight = new THREE.DirectionalLight('#ffffff', 1.8)
keyLight.position.set(6, -5, 11)
keyLight.castShadow = true
scene.add(keyLight)
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: '#17233a', roughness: 0.95, metalness: 0 })
)
ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true
scene.add(ground)
scene.add(new THREE.GridHelper(20, 20, '#3b82f6', '#24324c'))
// Gazebo SDF uses Z-up, while Three.js uses Y-up.
const sdfRoot = new THREE.Group()
sdfRoot.rotation.x = -Math.PI / 2
scene.add(sdfRoot)
const objectGroup = new THREE.Group()
const selectionGroup = new THREE.Group()
sdfRoot.add(objectGroup)
scene.add(selectionGroup)
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const renderScene = (): void => {
objectGroup.clear()
selectionGroup.clear()
for (const item of worldRef.current.objects) {
const mesh = createMesh(item)
objectGroup.add(mesh)
if (item.id === selectedIdRef.current) {
const outline = new THREE.Box3().setFromObject(mesh)
const size = outline.getSize(new THREE.Vector3())
const center = outline.getCenter(new THREE.Vector3())
const helper = new THREE.Box3Helper(new THREE.Box3().setFromCenterAndSize(center, size.multiplyScalar(1.08)), '#93c5fd')
selectionGroup.add(helper)
}
}
}
renderSceneRef.current = renderScene
renderScene()
let disposed = false
void loadWorldSdf(sdfRoot).catch((cause: unknown) => {
if (!disposed) setError(cause instanceof Error ? cause.message : '기본 TurtleBot3 월드 모델을 불러오지 못했습니다.')
})
const resize = (): void => {
const { width, height } = host.getBoundingClientRect()
if (!width || !height) return
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
const observer = new ResizeObserver(resize)
observer.observe(host)
resize()
const handlePointerDown = (event: PointerEvent): void => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hit = raycaster.intersectObjects(objectGroup.children, false)[0]
setSelectedId(typeof hit?.object.userData.objectId === 'string' ? hit.object.userData.objectId : null)
}
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
let frameId = 0
const animate = (): void => {
controls.update()
renderer.render(scene, camera)
frameId = window.requestAnimationFrame(animate)
}
animate()
return () => {
disposed = true
renderSceneRef.current = null
window.cancelAnimationFrame(frameId)
observer.disconnect()
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
controls.dispose()
renderer.dispose()
host.replaceChildren()
}
}, [loading])
const selectTransformMode = (mode: TransformMode): void => {
setTransformMode(mode)
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-transform-mode',
mode,
}, '*')
}
const selected = layout.objects.find((item) => item.id === selectedId) ?? null
const updateObject = (id: string, patch: Partial<WorldLayoutObject>): void => {
setLayout((current) => {
const next = { ...current, objects: current.objects.map((item) => item.id === id ? { ...item, ...patch } : item) }
worldRef.current = next
return next
})
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const updateTuple = (key: 'position' | 'rotation' | 'size', index: number, value: string): void => {
if (!selected) return
const numeric = Number(value)
if (!Number.isFinite(numeric)) return
const next = [...selected[key]] as [number, number, number]
next[index] = key === 'size' ? Math.max(0.1, numeric) : numeric
updateObject(selected.id, { [key]: next })
}
const addObject = (type: WorldObjectType): void => {
const object = createObject(type, layout.objects.length)
setLayout((current) => {
const next = { ...current, objects: [...current.objects, object] }
worldRef.current = next
return next
})
setSelectedId(object.id)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const removeSelected = (): void => {
if (!selected) return
const remaining = layout.objects.filter((item) => item.id !== selected.id)
const next = { ...layout, objects: remaining }
worldRef.current = next
setLayout(next)
setSelectedId(remaining[0]?.id ?? null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const resetLayout = (): void => {
const next: WorldLayout = { version: 1, objects: [] }
worldRef.current = next
setLayout(next)
setSelectedId(null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const save = async (): Promise<void> => {
setSaving(true)
setError(null)
try {
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
setLayout(saved)
onSaved?.()
onClose()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
} finally {
setSaving(false)
}
}
return (
<div className="absolute inset-0 z-[70] 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>
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
</div>
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
<div className="border-b border-white/10 px-3 py-3">
<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="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
const selected = item.id === selectedId
return (
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
</button>
)
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
</div>
<div className="border-t border-white/10 p-2">
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
<div className="grid grid-cols-3 gap-1">
{([
['pillar', Cylinder, '기둥'],
['wall', PanelsTopLeft, '벽'],
['box', Box, '박스'],
] as const).map(([type, Icon, label]) => (
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
))}
</div>
</div>
</aside>
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="/slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
{([
['translate', Move3D, '이동'],
['rotate', Rotate3D, '회전'],
['scale', Maximize2, '크기'],
] as const).map(([mode, Icon, label]) => (
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
))}
</div>
</main>
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
{selected ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
{(['position', 'rotation', 'size'] as const).map((key) => {
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, 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>
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
</aside>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md px-3 text-[11px] font-semibold text-white/58 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
</footer>
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
</section>
</div>
)
}
@@ -0,0 +1,273 @@
import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
export type WorkspaceMapOption = {
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
saved?: boolean
savedMapId?: string
createdAt?: string
sourceMapKey?: string
yamlPath?: string
pgmPath?: string
databasePath?: string
previewPgm?: string
layout?: unknown
remoteSlam?: boolean
}
export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
{
key: 'nav2-turtlebot3-world',
platformKey: 'NAV2',
name: '기본 월드',
description: 'TurtleBot3 기본 장애물 환경',
worldFile: 'turtlebot3_world.world',
viewerModel: 'turtlebot3_world'
},
{
key: 'nav2-turtlebot3-house',
platformKey: 'NAV2',
name: '실내 환경',
description: '방과 복도로 구성된 TurtleBot3 House',
worldFile: 'turtlebot3_house.world',
viewerModel: 'turtlebot3_house'
},
{
key: 'nav2-custom',
platformKey: 'NAV2',
name: '커스텀 맵',
description: '장애물을 직접 배치해 학습 환경 구성',
worldFile: 'empty_world.world',
viewerModel: 'turtlebot3_world',
custom: true
}
]
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const saved = Boolean(map.saved || map.remoteSlam)
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!map.previewPgm || !canvasRef.current) return
const canvas = canvasRef.current
const context = canvas.getContext('2d')
if (!context) return
try {
const bytes = Uint8Array.from(atob(map.previewPgm), (char) => char.charCodeAt(0))
let cursor = 0
const nextToken = (): string => {
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (bytes[cursor] === 35) {
while (cursor < bytes.length && bytes[cursor] !== 10) cursor += 1
return nextToken()
}
const start = cursor
while (cursor < bytes.length && !/\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
return String.fromCharCode(...bytes.slice(start, cursor))
}
if (nextToken() !== 'P5') throw new Error('unsupported_pgm')
const width = Number(nextToken())
const height = Number(nextToken())
const maxValue = Number(nextToken())
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (!Number.isFinite(width) || !Number.isFinite(height) || maxValue !== 255 || bytes.length - cursor < width * height) throw new Error('invalid_pgm')
const targetWidth = 192
const targetHeight = 128
canvas.width = targetWidth
canvas.height = targetHeight
context.fillStyle = '#d9dce0'
context.fillRect(0, 0, targetWidth, targetHeight)
const scale = Math.min(targetWidth / width, targetHeight / height)
const renderedWidth = Math.max(1, Math.floor(width * scale))
const renderedHeight = Math.max(1, Math.floor(height * scale))
const offsetX = Math.floor((targetWidth - renderedWidth) / 2)
const offsetY = Math.floor((targetHeight - renderedHeight) / 2)
const image = context.createImageData(renderedWidth, renderedHeight)
for (let y = 0; y < renderedHeight; y += 1) {
const sourceY = Math.min(height - 1, Math.floor((y / renderedHeight) * height))
for (let x = 0; x < renderedWidth; x += 1) {
const sourceX = Math.min(width - 1, Math.floor((x / renderedWidth) * width))
const value = bytes[cursor + sourceY * width + sourceX]
const index = (y * renderedWidth + x) * 4
image.data[index] = value
image.data[index + 1] = value
image.data[index + 2] = value
image.data[index + 3] = 255
}
}
context.putImageData(image, offsetX, offsetY)
} catch {
// Keep the fallback map schematic visible when a legacy preview is invalid.
}
}, [map.previewPgm])
return (
<span
aria-hidden="true"
className={[
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
selected ? 'border-[#6f9cff]' : 'border-white/15'
].join(' ')}
>
{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 ? (
<>
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
<span className="absolute bottom-[17%] left-[15%] size-[10%] rounded-full bg-[#536273]" />
</>
) : map.viewerModel === 'turtlebot3_house' ? (
<>
<span className="absolute inset-[12%] border-[3px] border-[#455363]" />
<span className="absolute left-[42%] top-[12%] h-[43%] w-[3px] bg-[#455363]" />
<span className="absolute bottom-[28%] left-[12%] h-[3px] w-[52%] bg-[#455363]" />
<span className="absolute bottom-[12%] right-[25%] h-[30%] w-[3px] bg-[#455363]" />
</>
) : (
<>
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
<span className="absolute left-[28%] top-[30%] size-[16%] rounded-full bg-[#455363]" />
<span className="absolute right-[22%] top-[22%] h-[16%] w-[24%] bg-[#455363]" />
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</>
))}
{saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" />
</span>
</span>
)
}
type WorldMapSelectorModalProps = {
platformKey: string
weekNumber: number
maps?: WorkspaceMapOption[]
showEnvironment?: boolean
selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void
onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void
}
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, showEnvironment = true, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const maps = configuredMaps?.length
? [...configuredMaps, ...(showEnvironment && !configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [LOCAL_CUSTOM_MAP] : [])]
: (showEnvironment && platformKey.toUpperCase() === 'NAV2' ? NAV2_MAP_OPTIONS : [])
const environmentMaps = maps.filter((map) => !map.saved && !map.remoteSlam)
const savedMaps = maps.filter((map) => map.saved)
const remoteSlamMaps = maps.filter((map) => map.remoteSlam)
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [tab, setTab] = useState<'environment' | 'saved'>(() => (!showEnvironment || selectedMap?.saved ? 'saved' : 'environment'))
const visibleMaps = tab === 'environment' ? environmentMaps : [...remoteSlamMaps, ...savedMaps]
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => {
setTab(!showEnvironment || selectedMap?.saved ? 'saved' : 'environment')
setPendingMap(selectedMap)
}, [selectedMapKey, showEnvironment])
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
</button>
</header>
{showEnvironment && (
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? '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">
{visibleMaps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
return (
<button
key={map.key}
type="button"
onClick={() => setPendingMap(map)}
className={[
'group relative flex w-full cursor-pointer items-start gap-3 rounded-md border px-3 py-3 text-left transition',
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
].join(' ')}
>
<MapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</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>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && map.key !== selectedMapKey && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" />
</span>
)}
</button>
)
})}
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
</div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{tab === 'environment' && 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>
</footer>
</section>
</div>
)
}
type WorldMapRestartConfirmModalProps = {
map: WorkspaceMapOption
onConfirm: () => void
}
export function WorldMapRestartConfirmModal({ map, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
return (
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
<AlertTriangle className="size-4 text-amber-300" />
<h2 className="text-[15px] font-bold">맵 변경 안내</h2>
</header>
<div className="px-5 py-4">
<p className="text-[13px] font-semibold">{map.name}으로 변경되었습니다.</p>
<p className="mt-2 text-[12px] leading-5 text-white/55">새 맵을 적용하려면 실행 중인 모듈을 전체 재시작해야 합니다.</p>
<div className="mt-5 flex justify-end">
<button type="button" onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8]">
확인
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,105 @@
import type { CurriculumWeek } from '../curriculum/types'
import type { EnvironmentProfile } from '../platforms/types'
import type { ModulePrebuiltArtifact } from './types'
type CurriculumModule = NonNullable<CurriculumWeek['modules']>[number]
type RuntimeModuleOverride = {
id?: string
folderName?: string
name?: string
enabled?: boolean
sortOrder?: number
defaultParameters?: string[]
prebuiltArtifacts?: ModulePrebuiltArtifact[]
dockerImage?: string | null
}
type RuntimeModuleSet = {
target?: 'local' | 'platform'
includeUnlisted?: boolean
modules?: RuntimeModuleOverride[]
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function readModuleSet(profile: EnvironmentProfile | null, runtimeTarget: 'local' | 'platform'): RuntimeModuleSet | null {
const config = asRecord(profile?.config)
const raw = asRecord(config?.moduleSet)
if (!raw) return null
const target = raw.target === 'local' || raw.target === 'platform' ? raw.target : undefined
if (target && target !== runtimeTarget) return null
const modules = Array.isArray(raw.modules)
? raw.modules.map(asRecord).filter((item): item is Record<string, unknown> => item !== null).map((item) => ({
id: typeof item.id === 'string' ? item.id : undefined,
folderName: typeof item.folderName === 'string' ? item.folderName : undefined,
name: typeof item.name === 'string' ? item.name : undefined,
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : undefined,
defaultParameters: Array.isArray(item.defaultParameters)
? item.defaultParameters.filter((value): value is string => typeof value === 'string')
: undefined,
prebuiltArtifacts: Array.isArray(item.prebuiltArtifacts)
? item.prebuiltArtifacts.filter((value): value is ModulePrebuiltArtifact => typeof value === 'object' && value !== null)
: undefined,
dockerImage: typeof item.dockerImage === 'string' ? item.dockerImage : item.dockerImage === null ? null : undefined,
}))
: []
return {
target,
includeUnlisted: raw.includeUnlisted !== false,
modules,
}
}
function matchesModule(module: CurriculumModule, override: RuntimeModuleOverride): boolean {
return Boolean(
(override.id && override.id === module.id)
|| (override.folderName && override.folderName === module.folderName)
|| (override.name && override.name === module.name)
)
}
/**
* Environment profiles select a runtime module set without duplicating student
* source repositories. An empty profile configuration preserves legacy weeks.
*/
export function resolveRuntimeModules(
modules: CurriculumWeek['modules'],
profile: EnvironmentProfile | EnvironmentProfile[] | null,
runtimeTarget: 'local' | 'platform',
): CurriculumWeek['modules'] {
const baseModules = modules ?? []
const profiles = Array.isArray(profile) ? profile : profile ? [profile] : []
const moduleSets = profiles
.map((item) => readModuleSet(item, runtimeTarget))
.filter((item): item is RuntimeModuleSet => item !== null)
if (!moduleSets.length) return baseModules
const overrides = moduleSets.flatMap((moduleSet) => moduleSet.modules ?? [])
const includeUnlisted = moduleSets.every((moduleSet) => moduleSet.includeUnlisted !== false)
return baseModules
.filter((module) => {
const override = overrides.find((item) => matchesModule(module, item))
return includeUnlisted || Boolean(override?.enabled !== false && override)
})
.filter((module) => overrides.find((item) => matchesModule(module, item))?.enabled !== false)
.map((module) => {
const override = overrides.find((item) => matchesModule(module, item))
if (!override) return module
return {
...module,
sortOrder: override.sortOrder ?? module.sortOrder,
defaultParameters: override.defaultParameters ?? module.defaultParameters,
prebuiltArtifacts: override.prebuiltArtifacts ?? module.prebuiltArtifacts,
dockerImage: override.dockerImage !== undefined ? override.dockerImage : module.dockerImage,
}
})
.sort((left, right) => left.sortOrder - right.sortOrder)
}
@@ -25,11 +25,7 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string { export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '') const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber) if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const safePlatformKey = platformKey const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
.replace(/^futuredrive[-_]/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.toLowerCase()
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
return `${normalizedRoot}/${suffix}` return `${normalizedRoot}/${suffix}`
} }
@@ -37,9 +37,11 @@ export function useRemoteWorkspaceRuntime({
onRegisterStopRunningModules onRegisterStopRunningModules
}: UseRemoteWorkspaceRuntimeOptions): UseRemoteWorkspaceRuntimeResult { }: UseRemoteWorkspaceRuntimeOptions): UseRemoteWorkspaceRuntimeResult {
const prepareRemoteRuntimeModule = useCallback(async (module: ModuleRow): Promise<void> => { const prepareRemoteRuntimeModule = useCallback(async (module: ModuleRow): Promise<void> => {
const requestAccessToken = await getFreshAccessToken()
await saveDirtyFilesForModule(module) await saveDirtyFilesForModule(module)
// A remote install resets build/install/log. Reinstall only when the module is absent.
if (module.state === 'uninstalled') {
const requestAccessToken = await getFreshAccessToken()
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, { const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
method: 'POST', method: 'POST',
headers: { headers: {
@@ -62,19 +64,6 @@ export function useRemoteWorkspaceRuntime({
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
} }
const submitResult = await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.remoteWorkspace.modules.install( await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(), getRemoteWorkspaceTarget(),
@@ -87,23 +76,28 @@ export function useRemoteWorkspaceRuntime({
runtimeImages: activeRuntimeImages runtimeImages: activeRuntimeImages
} }
) )
}
// Running a module must not create or push a Git commit. Submission is explicit in source control.
}, [ }, [
activeRuntimeImages, activeRuntimeImages,
clonePathPreview, clonePathPreview,
courseKey, courseKey,
getFreshAccessToken, getFreshAccessToken,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
remoteWorkspace,
saveDirtyFilesForModule, saveDirtyFilesForModule,
weekNumber weekNumber
]) ])
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => { const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
if (remoteWorkspace) { if (remoteWorkspace) {
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>() const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null; sortOrder?: number | null }>()
for (const module of availableModules) { for (const module of availableModules) {
moduleMap.set(module.name, { moduleMap.set(module.name, {
moduleName: module.name, moduleName: module.name,
folderName: module.folderName, folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder sortOrder: module.sortOrder
}) })
} }
@@ -112,13 +106,14 @@ export function useRemoteWorkspaceRuntime({
moduleMap.set(module.name, { moduleMap.set(module.name, {
moduleName: module.name, moduleName: module.name,
folderName: module.folderName, folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder sortOrder: module.sortOrder
}) })
} }
} }
const modules = Array.from(moduleMap.values()) const modules = Array.from(moduleMap.values())
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
.map(({ moduleName, folderName }) => ({ moduleName, folderName })) .map(({ moduleName, folderName, templateRepositoryUrl }) => ({ moduleName, folderName, templateRepositoryUrl }))
if (!modules.length) return if (!modules.length) return
await window.remoteWorkspace.modules.resetSession(getRemoteWorkspaceTarget(), modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) await window.remoteWorkspace.modules.resetSession(getRemoteWorkspaceTarget(), modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' }))
return return
@@ -8,6 +8,8 @@ import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from '.
type UseWorkspaceFileActionsOptions = { type UseWorkspaceFileActionsOptions = {
selectedModule: ModuleRow | null selectedModule: ModuleRow | null
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
selectedTreePath: string | null selectedTreePath: string | null
setSelectedTreePath: Dispatch<SetStateAction<string | null>> setSelectedTreePath: Dispatch<SetStateAction<string | null>>
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>> setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
@@ -75,6 +77,8 @@ type UseWorkspaceFileActionsResult = {
export function useWorkspaceFileActions({ export function useWorkspaceFileActions({
selectedModule, selectedModule,
clonePathPreview, clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
selectedTreePath, selectedTreePath,
setSelectedTreePath, setSelectedTreePath,
setSelectedExampleTreePath, setSelectedExampleTreePath,
@@ -115,7 +119,9 @@ export function useWorkspaceFileActions({
const loadModuleFiles = async (module: ModuleRow): Promise<void> => { const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true })) setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
try { try {
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const nextFiles = result.files const nextFiles = result.files
const nextFolders = result.folders.length ? result.folders : [] const nextFolders = result.folders.length ? result.folders : []
@@ -168,7 +174,11 @@ export function useWorkspaceFileActions({
if (newFileDraft.kind === 'folder') { if (newFileDraft.kind === 'folder') {
const nextFolder = `${newFileDraft.parent}/${normalizedName}` const nextFolder = `${newFileDraft.parent}/${normalizedName}`
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFolder, 'folder', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] create folder failed', error) console.error('[module-files] create folder failed', error)
return return
@@ -183,7 +193,11 @@ export function useWorkspaceFileActions({
const nextFile = `${newFileDraft.parent}/${normalizedName}` const nextFile = `${newFileDraft.parent}/${normalizedName}`
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFile, 'file', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] create file failed', error) console.error('[module-files] create file failed', error)
return return
@@ -215,7 +229,11 @@ export function useWorkspaceFileActions({
const saveEditorFile = async (file: EditorFilePath): Promise<void> => { const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
if (!selectedModule || file.startsWith('examples/example-code/')) return if (!selectedModule || file.startsWith('examples/example-code/')) return
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
}
markEditorFileSaved(file, editorFiles[file] ?? '') markEditorFileSaved(file, editorFiles[file] ?? '')
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget) if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
} }
@@ -293,7 +311,11 @@ export function useWorkspaceFileActions({
} }
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}` const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.rename(getRemoteWorkspaceTarget(), selectedModule.name, currentPath, nextPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] rename failed', error) console.error('[module-files] rename failed', error)
return return
@@ -334,7 +356,11 @@ export function useWorkspaceFileActions({
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => { const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
if (!selectedModule) return if (!selectedModule) return
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), selectedModule.name, targetPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] delete failed', error) console.error('[module-files] delete failed', error)
return return
@@ -364,7 +390,11 @@ export function useWorkspaceFileActions({
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))])) const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders) const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
try { try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName) await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
}
} catch (error) { } catch (error) {
console.error('[module-files] copy failed', error) console.error('[module-files] copy failed', error)
return return
@@ -1,4 +1,4 @@
import { useCallback, useState } from 'react' import { useCallback, useRef, useState } from 'react'
import { LMS_API_BASE_URL } from '../../config/appConfig' import { LMS_API_BASE_URL } from '../../config/appConfig'
import type { StudioSourceSettings } from '../platforms/types' import type { StudioSourceSettings } from '../platforms/types'
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters' import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
@@ -12,19 +12,38 @@ type ModuleActionResult = {
message?: string message?: string
} }
const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system']
function compareModuleStartupPriority(a: ModuleRow, b: ModuleRow): number {
const aPriority = startupCategoryOrder.indexOf(a.category.toLowerCase())
const bPriority = startupCategoryOrder.indexOf(b.category.toLowerCase())
const normalizedA = aPriority < 0 ? Number.MAX_SAFE_INTEGER : aPriority
const normalizedB = bPriority < 0 ? Number.MAX_SAFE_INTEGER : bPriority
return normalizedA - normalizedB || compareModulePriority(a, b)
}
function waitForSimulatorStartup(): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, 4000))
}
type UseWorkspaceModuleControlOptions = { type UseWorkspaceModuleControlOptions = {
availableModules: ModuleRow[] availableModules: ModuleRow[]
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>> setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
loadAvailableModules: (options?: { silent?: boolean }) => Promise<void> loadAvailableModules: (options?: { silent?: boolean }) => Promise<void>
courseKey: string courseKey: string
weekNumber: number weekNumber: number
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean remoteWorkspace: boolean
sourceSettings: StudioSourceSettings sourceSettings: StudioSourceSettings
simulatorWorldFile?: string | null
savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null
activeRuntimeImages: RuntimeImageArtifact[] activeRuntimeImages: RuntimeImageArtifact[]
getFreshAccessToken: () => Promise<string> getFreshAccessToken: () => Promise<string>
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void> prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
onStopVisualization: () => Promise<void>
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
} }
@@ -38,6 +57,7 @@ type UseWorkspaceModuleControlResult = {
installAllModules: () => Promise<void> installAllModules: () => Promise<void>
startAllModules: () => Promise<void> startAllModules: () => Promise<void>
stopAllModules: () => Promise<void> stopAllModules: () => Promise<void>
restartRunningModules: (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }) => Promise<boolean>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void> runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build') => Promise<void>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void> updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
} }
@@ -45,23 +65,53 @@ type UseWorkspaceModuleControlResult = {
export function useWorkspaceModuleControl({ export function useWorkspaceModuleControl({
availableModules, availableModules,
setAvailableModules, setAvailableModules,
markModuleState,
loadAvailableModules, loadAvailableModules,
courseKey, courseKey,
weekNumber, weekNumber,
clonePathPreview, clonePathPreview,
remoteWorkspace, remoteWorkspace,
sourceSettings, sourceSettings,
simulatorWorldFile,
savedMapYamlPath,
savedMapDatabasePath,
activeRuntimeImages, activeRuntimeImages,
getFreshAccessToken, getFreshAccessToken,
getRemoteWorkspaceTarget, getRemoteWorkspaceTarget,
prepareRemoteRuntimeModule, prepareRemoteRuntimeModule,
onStopVisualization,
onTitlebarNotice onTitlebarNotice
}: UseWorkspaceModuleControlOptions): UseWorkspaceModuleControlResult { }: UseWorkspaceModuleControlOptions): UseWorkspaceModuleControlResult {
const [installingModuleName, setInstallingModuleName] = useState<string | null>(null) const [installingModuleName, setInstallingModuleName] = useState<string | null>(null)
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([]) const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null) const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
const moduleActionQueueRef = useRef<Promise<void>>(Promise.resolve())
const queuedModuleNamesRef = useRef(new Set<string>())
const getModuleRosParams = useCallback((module: ModuleRow): string => module.parameters.join(' '), []) const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null; savedMapDatabasePath?: string | null }): string => {
const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const parameters = module.parameters.filter((parameter) => (
!parameter.startsWith('world:=')
&& !parameter.startsWith('world_layout_enabled:=')
&& !parameter.startsWith('map:=')
&& !parameter.startsWith('database_path:=')
&& !parameter.startsWith('localization:=')
))
if (module.category.toLowerCase() === 'simulator' && worldFile) {
parameters.push(`world:=${worldFile}`)
}
if (module.category.toLowerCase() === 'map') {
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')
}
return parameters.join(' ')
}, [savedMapDatabasePath, savedMapYamlPath, simulatorWorldFile])
const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => { const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => {
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, { const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
@@ -87,11 +137,30 @@ export function useWorkspaceModuleControl({
} }
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, { const installOptions = {
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null, prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
runtimeImages: activeRuntimeImages runtimeImages: activeRuntimeImages
}) }
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber]) if (remoteWorkspace) {
await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
templateRepositoryUrl,
prepared.repositoryUrlWithToken,
installOptions,
)
return
}
await window.localWorkspace.modules.install(
module.name,
clonePathPreview,
module.folderName,
templateRepositoryUrl,
prepared.repositoryUrlWithToken,
installOptions,
)
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => { const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module) await prepareRemoteRuntimeModule(module)
@@ -164,11 +233,13 @@ export function useWorkspaceModuleControl({
setInstallingModuleName('__start_all__') setInstallingModuleName('__start_all__')
setModuleInstallError(null) setModuleInstallError(null)
try { try {
for (const module of targets) { for (const module of [...targets].sort(compareModuleStartupPriority)) {
const result = remoteWorkspace const result = remoteWorkspace
? await remoteModuleStart(module) ? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
markModuleState(module, 'running')
if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup()
} }
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
onTitlebarNotice('모듈이 전체 시작되었습니다', 'info') onTitlebarNotice('모듈이 전체 시작되었습니다', 'info')
@@ -187,6 +258,7 @@ export function useWorkspaceModuleControl({
getModuleRosParams, getModuleRosParams,
installingModuleName, installingModuleName,
loadAvailableModules, loadAvailableModules,
markModuleState,
onTitlebarNotice, onTitlebarNotice,
remoteModuleStart, remoteModuleStart,
remoteWorkspace, remoteWorkspace,
@@ -197,7 +269,12 @@ export function useWorkspaceModuleControl({
const stopAllModules = useCallback(async (): Promise<void> => { const stopAllModules = useCallback(async (): Promise<void> => {
if (installingModuleName) return if (installingModuleName) return
const targets = availableModules.filter((module) => module.state === 'running').sort(compareModuleShutdownPriority) const targets = availableModules.filter((module) => module.state === 'running').sort(compareModuleShutdownPriority)
if (!targets.length) { const stopLocalVisualization = async (): Promise<void> => {
if (remoteWorkspace) return
await onStopVisualization()
}
if (!targets.length && remoteWorkspace) {
onTitlebarNotice('종료할 모듈이 없습니다', 'warning') onTitlebarNotice('종료할 모듈이 없습니다', 'warning')
return return
} }
@@ -206,19 +283,23 @@ export function useWorkspaceModuleControl({
setModuleInstallError(null) setModuleInstallError(null)
try { try {
if (remoteWorkspace) { if (remoteWorkspace) {
for (const module of targets) { const result = await window.remoteWorkspace.modules.stopMany(
const result = await remoteModuleStop(module) getRemoteWorkspaceTarget(),
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
}
} else { } else {
if (targets.length) {
const result = await window.localWorkspace.modules.stopMany( const result = await window.localWorkspace.modules.stopMany(
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })), targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
clonePathPreview clonePathPreview
) )
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
} }
await stopLocalVisualization()
}
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
onTitlebarNotice('모듈이 전체 종료되었습니다', 'info') onTitlebarNotice('모듈과 시각화가 전체 종료되었습니다', 'info')
} catch (error) { } catch (error) {
console.error('[modules] stop all failed', error) console.error('[modules] stop all failed', error)
setModuleInstallError(error instanceof Error ? error.message : '전체 중지 실패') setModuleInstallError(error instanceof Error ? error.message : '전체 중지 실패')
@@ -227,13 +308,64 @@ export function useWorkspaceModuleControl({
} finally { } finally {
setInstallingModuleName(null) setInstallingModuleName(null)
} }
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace]) }, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace])
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
if (installingModuleName) return false
const targets = availableModules.filter((module) => module.state === 'running')
if (!targets.length) return true
setInstallingModuleName('__restart_all__')
setModuleInstallError(null)
try {
if (remoteWorkspace) {
const result = await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
[...targets]
.sort(compareModuleShutdownPriority)
.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
if (!result.ok) throw new Error(result.message)
} else {
const result = await window.localWorkspace.modules.stopMany(
[...targets]
.sort(compareModuleShutdownPriority)
.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
clonePathPreview
)
if (!result.ok) throw new Error(result.message)
await onStopVisualization()
}
for (const module of [...targets].sort(compareModuleStartupPriority)) {
const result = remoteWorkspace
? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module, overrides), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
if (!result.ok) throw new Error(result.message)
markModuleState(module, 'running')
if (module.category.toLowerCase() === 'simulator') await waitForSimulatorStartup()
}
await loadAvailableModules({ silent: true })
onTitlebarNotice('실행 중인 모듈이 전체 재시작되었습니다', 'info')
return true
} catch (error) {
console.error('[modules] restart running modules failed', error)
setModuleInstallError(error instanceof Error ? error.message : '모듈 전체 재시작 실패')
onTitlebarNotice('모듈 전체 재시작에 실패했습니다', 'error')
await loadAvailableModules({ silent: true })
return false
} finally {
setInstallingModuleName(null)
}
}, [activeRuntimeImages, availableModules, clonePathPreview, getModuleRosParams, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, remoteModuleStart, remoteWorkspace, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => { const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || controllingModuleNames.includes(module.name)) return if (installingModuleName || queuedModuleNamesRef.current.has(module.name)) return
queuedModuleNamesRef.current.add(module.name)
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
setModuleInstallError(null) setModuleInstallError(null)
const execute = async (): Promise<void> => {
try { try {
const result = action === 'stop' const result = action === 'stop'
? remoteWorkspace ? remoteWorkspace
@@ -251,6 +383,24 @@ export function useWorkspaceModuleControl({
? await remoteModuleStart(module) ? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages) : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
if (!result.ok) throw new Error(result.message) if (!result.ok) throw new Error(result.message)
if (action === 'start' || action === 'restart') {
markModuleState(module, 'running')
} else if (action === 'stop') {
markModuleState(module, 'stopped')
}
// Gazebo is replaced on restart, so the one-shot Burger spawn from the
// Vehicle module must run again against the new world.
if (action === 'restart' && module.category.toLowerCase() === 'simulator') {
const vehicle = availableModules.find((item) => item.category.toLowerCase() === 'vehicle' && item.state === 'running')
if (vehicle) {
const vehicleResult = remoteWorkspace
? await remoteModuleRestart(vehicle)
: await window.localWorkspace.modules.restart(vehicle.name, clonePathPreview, vehicle.folderName, getModuleRosParams(vehicle), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
if (!vehicleResult.ok) throw new Error(vehicleResult.message ?? '로봇 모듈 재시작 실패')
}
}
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
const actionLabel = action === 'stop' const actionLabel = action === 'stop'
? '종료되었습니다' ? '종료되었습니다'
@@ -273,15 +423,27 @@ export function useWorkspaceModuleControl({
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error') onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
await loadAvailableModules({ silent: true }) await loadAvailableModules({ silent: true })
} finally { } finally {
queuedModuleNamesRef.current.delete(module.name)
setControllingModuleNames((names) => names.filter((name) => name !== module.name)) setControllingModuleNames((names) => names.filter((name) => name !== module.name))
} }
}
if (action === 'stop') {
await execute()
return
}
const queuedWork = moduleActionQueueRef.current.then(execute, execute)
moduleActionQueueRef.current = queuedWork.then(() => undefined, () => undefined)
await queuedWork
}, [ }, [
activeRuntimeImages, activeRuntimeImages,
availableModules,
clonePathPreview, clonePathPreview,
controllingModuleNames,
getModuleRosParams, getModuleRosParams,
installingModuleName, installingModuleName,
loadAvailableModules, loadAvailableModules,
markModuleState,
onTitlebarNotice, onTitlebarNotice,
remoteModuleRestart, remoteModuleRestart,
remoteModuleStart, remoteModuleStart,
@@ -316,6 +478,7 @@ export function useWorkspaceModuleControl({
installAllModules, installAllModules,
startAllModules, startAllModules,
stopAllModules, stopAllModules,
restartRunningModules,
runModuleControl, runModuleControl,
updateModuleParameters updateModuleParameters
} }
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react' import type { Dispatch, SetStateAction } from 'react'
import type { CurriculumWeek } from '../curriculum/types' import type { CurriculumWeek } from '../curriculum/types'
import type { ModuleRow } from './types' import type { ModuleRow } from './types'
@@ -21,6 +21,7 @@ type UseWorkspaceModuleListOptions = {
type UseWorkspaceModuleListResult = { type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[] availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>> setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
modulesLoading: boolean modulesLoading: boolean
moduleListError: string | null moduleListError: string | null
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void> loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
@@ -49,43 +50,97 @@ export function useWorkspaceModuleList({
const [modulesLoading, setModulesLoading] = useState(false) const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null) const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey]) const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides })
const loadSequenceRef = useRef(0)
const moduleLoadInFlightRef = useRef(false)
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
const applyPendingModuleStates = useCallback((modules: ModuleRow[]): ModuleRow[] => {
const now = Date.now()
return modules.map((module) => {
const key = moduleStateKey(module)
const pending = pendingModuleStatesRef.current.get(key)
if (!pending) return module
if (module.state === pending.state || pending.expiresAt <= now) {
pendingModuleStatesRef.current.delete(key)
return module
}
return {
...module,
state: pending.state,
runtime: pending.state === 'running' ? '시작 중' : '-',
cpu: pending.state === 'running' ? module.cpu : null,
memory: pending.state === 'running' ? module.memory : null,
}
})
}, [])
const markModuleState = useCallback((module: ModuleRow, state: ModuleRow['state']): void => {
const key = moduleStateKey(module)
// Docker can report the previous state for a few polling cycles after a
// start/restart request completes. Keep the requested state until the
// runtime confirms it, rather than briefly switching the row back.
const pendingDuration = state === 'running' ? 120_000 : 30_000
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + pendingDuration })
setAvailableModules((current) => current.map((item) => {
if (moduleStateKey(item) !== key) return item
return {
...item,
state,
runtime: state === 'running' ? '시작 중' : '-',
cpu: state === 'running' ? item.cpu : null,
memory: state === 'running' ? item.memory : null,
}
}))
}, [])
useEffect(() => {
moduleInputsRef.current = { curriculumModules, applyModuleParameterOverrides }
}, [applyModuleParameterOverrides, curriculumModuleKey, curriculumModules])
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => { const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
if (!remoteTarget) return { modules: [] } if (!remoteTarget) return { modules: [] }
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? []) return window.remoteWorkspace.modules.list(remoteTarget, moduleInputsRef.current.curriculumModules ?? [])
}, [curriculumModuleKey, remoteTarget]) }, [curriculumModuleKey, remoteTarget])
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => { const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
// Remote probes open an SSH session. Do not let polling overlap a slow
// probe, otherwise every response becomes stale and loading never settles.
if (moduleLoadInFlightRef.current) return
moduleLoadInFlightRef.current = true
const requestSequence = ++loadSequenceRef.current
if (!silent) setModulesLoading(true) if (!silent) setModulesLoading(true)
try { try {
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? []) const { curriculumModules: currentCurriculumModules, applyModuleParameterOverrides: applyOverrides } = moduleInputsRef.current
if (remoteWorkspace) { if (remoteWorkspace) {
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] })) const result = await loadRemoteModules()
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module])) if (requestSequence !== loadSequenceRef.current) return
result.modules = result.modules.map((module) => {
const remoteModule = remoteStateByName.get(module.name)
return remoteModule?.state === 'running'
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
: module
})
}
setModuleListError(null) setModuleListError(null)
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)) setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
return
}
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
} catch (error) { } catch (error) {
if (requestSequence !== loadSequenceRef.current) return
console.error('[modules] list failed', error) console.error('[modules] list failed', error)
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.' const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
if (!silent) { if (!silent) {
setModuleListError(message) setModuleListError(message)
setAvailableModules(makeUninstalledModuleRows(curriculumModules)) setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules))
} }
} finally { } finally {
if (!silent) setModulesLoading(false) moduleLoadInFlightRef.current = false
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
} }
}, [ }, [
applyModuleParameterOverrides, applyPendingModuleStates,
clonePathPreview, clonePathPreview,
curriculumModuleKey, curriculumModuleKey,
curriculumModules,
loadRemoteModules, loadRemoteModules,
remoteWorkspace remoteWorkspace
]) ])
@@ -99,7 +154,7 @@ export function useWorkspaceModuleList({
const intervalId = window.setInterval(() => { const intervalId = window.setInterval(() => {
void loadAvailableModules({ silent: true }) void loadAvailableModules({ silent: true })
}, 5000) }, remoteWorkspace ? 10_000 : 5_000)
return () => window.clearInterval(intervalId) return () => window.clearInterval(intervalId)
}, [loadAvailableModules, pollingActive]) }, [loadAvailableModules, pollingActive])
@@ -107,6 +162,7 @@ export function useWorkspaceModuleList({
return { return {
availableModules, availableModules,
setAvailableModules, setAvailableModules,
markModuleState,
modulesLoading, modulesLoading,
moduleListError, moduleListError,
loadAvailableModules, loadAvailableModules,
@@ -14,6 +14,8 @@ type UseWorkspaceTerminalOptions = {
moduleName?: string | null moduleName?: string | null
moduleFolderName?: string | null moduleFolderName?: string | null
clonePathPreview: string clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
dark: boolean dark: boolean
terminalActive: boolean terminalActive: boolean
resetKey: string resetKey: string
@@ -36,6 +38,8 @@ export function useWorkspaceTerminal({
moduleName, moduleName,
moduleFolderName, moduleFolderName,
clonePathPreview, clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
dark, dark,
terminalActive, terminalActive,
resetKey, resetKey,
@@ -47,11 +51,14 @@ export function useWorkspaceTerminal({
const terminalRef = useRef<XTerm | null>(null) const terminalRef = useRef<XTerm | null>(null)
const terminalFitRef = useRef<FitAddon | null>(null) const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null) const terminalSessionIdRef = useRef<string | null>(null)
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([]) const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({}) const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set()) const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235) const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
const fitTerminal = (): void => { const fitTerminal = (): void => {
terminalFitRef.current?.fit() terminalFitRef.current?.fit()
} }
@@ -71,7 +78,12 @@ export function useWorkspaceTerminal({
void navigator.clipboard.readText() void navigator.clipboard.readText()
.then((text) => { .then((text) => {
if (text) window.localWorkspace.terminal.write(text, terminalSessionId) if (!text) return
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(text, terminalSessionId)
return
}
window.localWorkspace.terminal.write(text, terminalSessionId)
}) })
.catch((error) => console.error('[terminal] paste failed', error)) .catch((error) => console.error('[terminal] paste failed', error))
.finally(() => { .finally(() => {
@@ -153,7 +165,7 @@ export function useWorkspaceTerminal({
terminalRef.current = term terminalRef.current = term
terminalFitRef.current = fitAddon terminalFitRef.current = fitAddon
const terminalApi = window.localWorkspace.terminal const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
const removeDataListener = terminalApi.onData((data) => { const removeDataListener = terminalApi.onData((data) => {
const suppressQueue = terminalSuppressQueueRef.current const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) { if (suppressQueue.length && suppressQueue[0] === data) {
@@ -183,8 +195,24 @@ export function useWorkspaceTerminal({
startNestedFrame = window.requestAnimationFrame(() => { startNestedFrame = window.requestAnimationFrame(() => {
fitAddon.fit() fitAddon.fit()
if (disposed) return resolvePromise(false) if (disposed) return resolvePromise(false)
void window.localWorkspace.terminal const start = remoteWorkspace
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined) ? window.remoteWorkspace.terminal.start(
remoteTargetResolverRef.current(),
moduleName,
term.cols,
term.rows,
terminalSessionId,
moduleFolderName ?? undefined,
)
: window.localWorkspace.terminal.start(
moduleName,
term.cols,
term.rows,
terminalSessionId,
clonePathPreview,
moduleFolderName ?? undefined,
)
void start
.then(resolvePromise) .then(resolvePromise)
.catch(() => resolvePromise(false)) .catch(() => resolvePromise(false))
}) })
@@ -195,7 +223,11 @@ export function useWorkspaceTerminal({
if (!started) return if (!started) return
sizeSyncTimer = window.setTimeout(() => { sizeSyncTimer = window.setTimeout(() => {
fitAddon.fit() fitAddon.fit()
void terminalApi.resize(term.cols, term.rows, terminalSessionId) if (remoteWorkspace) {
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
return
}
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
}, 150) }, 150)
fallbackPromptTimer = window.setTimeout(() => { fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
@@ -228,14 +260,15 @@ export function useWorkspaceTerminal({
// Full-screen programs such as vi cannot be restored from a partial raw output buffer. // Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId] delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId) terminalFallbackPromptShownRef.current.delete(terminalSessionId)
terminalApi.stop(terminalSessionId) if (remoteWorkspace) window.remoteWorkspace.terminal.stop(terminalSessionId)
else window.localWorkspace.terminal.stop(terminalSessionId)
term.dispose() term.dispose()
terminalRef.current = null terminalRef.current = null
terminalFitRef.current = null terminalFitRef.current = null
terminalSessionIdRef.current = null terminalSessionIdRef.current = null
terminalSuppressQueueRef.current = [] terminalSuppressQueueRef.current = []
} }
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive]) }, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
return { return {
terminalContextMenu, terminalContextMenu,
@@ -1,14 +1,46 @@
import { useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import type { EnvironmentProfile } from '../platforms/types' import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types' import type { RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel' import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation'
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
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 {
return ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '')
}
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
))
return matchingProfiles.length ? matchingProfiles : profiles
}
type NoticeTone = 'info' | 'warning' | 'error' type NoticeTone = 'info' | 'warning' | 'error'
type UseWorkspaceVisualizationOptions = { type UseWorkspaceVisualizationOptions = {
profiles: EnvironmentProfile[] profiles: EnvironmentProfile[]
clonePathPreview: string clonePathPreview: string
rosDomainId: string rosDomainId: string
fallbackStack: VisualizationStack
remoteWorkspace: boolean
remoteTarget: RemoteWorkspaceTarget | null
mapKey?: string | null
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
} }
@@ -19,16 +51,19 @@ type UseWorkspaceVisualizationResult = {
visualizationRunning: boolean visualizationRunning: boolean
visualizationBusy: boolean visualizationBusy: boolean
visualizationViewerUrl: string visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
activeEnvironmentProfile: EnvironmentProfile | null activeEnvironmentProfile: EnvironmentProfile | null
activeRuntimeImages: RuntimeImageArtifact[] activeRuntimeImages: RuntimeImageArtifact[]
refreshVisualizationStatus: () => Promise<void> refreshVisualizationStatus: () => Promise<void>
startVisualizationBridge: () => Promise<void> startVisualizationBridge: (mode?: Nav2VisualizationMode) => Promise<void>
stopVisualizationBridge: () => Promise<void> stopVisualizationBridge: () => Promise<void>
} }
function formatVisualizationStackLabel(stack: VisualizationStack | null | undefined): string { function formatVisualizationStackLabel(stack: VisualizationStack | null | undefined): string {
if (stack === 'nav2') return 'Nav2' if (stack === 'nav2') return 'Nav2'
if (stack === 'slam') return 'SLAM' if (stack === 'slam') return 'SLAM'
if (stack === 'slam3d') return '3D SLAM'
return 'Autoware' return 'Autoware'
} }
@@ -36,20 +71,37 @@ export function useWorkspaceVisualization({
profiles, profiles,
clonePathPreview, clonePathPreview,
rosDomainId, rosDomainId,
fallbackStack,
remoteWorkspace,
remoteTarget,
mapKey,
worldLayoutVersion,
onTitlebarNotice onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult { }: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware') const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>(fallbackStack)
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false) const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기') const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false) const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => ( const visualizationStacks = useMemo(() => (
profiles runtimeProfiles
.map((profile) => profile.profileType.toLowerCase()) .map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam') .filter((stack): stack is VisualizationStack => stack !== null)
), [profiles]) ), [runtimeProfiles])
const visualizationStackStorageKey = useMemo(() => {
const workspaceKey = remoteWorkspace
? `${remoteTarget?.host ?? ''}:${remoteTarget?.workspaceRoot ?? ''}`
: clonePathPreview
return workspaceKey ? `lms:visualization-stack:${workspaceKey}` : null
}, [clonePathPreview, remoteTarget?.host, remoteTarget?.workspaceRoot, remoteWorkspace])
const selectVisualizationStack = useCallback((stack: VisualizationStack) => {
setVisualizationStack(stack)
if (visualizationStackStorageKey) window.localStorage.setItem(visualizationStackStorageKey, stack)
}, [visualizationStackStorageKey])
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null const activeEnvironmentProfile = runtimeProfiles.find((profile) => resolveVisualizationStack(profile.profileType) === visualizationStack) ?? runtimeProfiles[0] ?? null
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {} const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages) const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => ( ? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
@@ -62,9 +114,10 @@ export function useWorkspaceVisualization({
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string' const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
? activeEnvironmentAgent.url.replace(/\/+$/, '') ? activeEnvironmentAgent.url.replace(/\/+$/, '')
: '' : ''
const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && Boolean(activeEnvironmentAgentUrl || remoteTarget?.host)
const visualizationRemoteBridgeHttpUrl = (() => { const visualizationRemoteBridgeHttpUrl = (() => {
if (!visualizationRemote) return '' if (!visualizationRemote) return ''
if (!activeEnvironmentAgentUrl && remoteTarget?.host) return `http://${remoteTarget.host}:8765`
try { try {
const url = new URL(activeEnvironmentAgentUrl) const url = new URL(activeEnvironmentAgentUrl)
url.port = '8765' url.port = '8765'
@@ -78,26 +131,46 @@ export function useWorkspaceVisualization({
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') ? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765' : 'ws://localhost:8765'
const visualizationBackendUrl = visualizationBridgeHttpUrl const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationViewerUrl = visualizationStack === 'slam' const visualizationRuntimeStack = visualizationStack === 'slam' || visualizationStack === 'slam3d' ? 'nav2' : visualizationStack
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}` const pointCloudQuery = visualizationStack === 'slam3d' ? '&pointCloud=1' : ''
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'slam3d' || visualizationStack === 'nav2'
? `/slam-viewer/index.html?directBridge=1&mode=${visualizationStack === 'nav2' ? nav2VisualizationMode : 'slam'}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}${pointCloudQuery}`
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}` : `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => { useEffect(() => {
const profileType = profiles[0]?.profileType?.toLowerCase() const fallback = runtimeProfiles[0] ? resolveVisualizationStack(runtimeProfiles[0].profileType) : null
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') { const savedStack = visualizationStackStorageKey
setVisualizationStack(profileType) ? window.localStorage.getItem(visualizationStackStorageKey)
} : null
}, [profiles.map((profile) => profile.id).join('|')]) const stack = savedStack && visualizationStacks.includes(savedStack as VisualizationStack)
? savedStack as VisualizationStack
: fallback ?? fallbackStack
setVisualizationStack(stack)
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
const refreshVisualizationStatus = async (): Promise<void> => { const refreshVisualizationStatus = async (): Promise<void> => {
const status = await window.localWorkspace.visualization.status().catch((error) => ({ const status = remoteWorkspace
? remoteTarget?.host
? await window.remoteWorkspace.visualization.status(remoteTarget).catch((error) => ({
ok: false,
running: false,
stack: null,
message: error instanceof Error ? error.message : String(error),
}))
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.status().catch((error) => ({
ok: false, ok: false,
running: false, running: false,
stack: null, stack: null,
message: error instanceof Error ? error.message : String(error) message: error instanceof Error ? error.message : String(error)
})) }))
setVisualizationRunning(Boolean(status.running)) const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam'
if (status.stack) setVisualizationStack(status.stack) ? 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)
setVisualizationStatusMessage(status.running setVisualizationStatusMessage(status.running
? formatVisualizationStackLabel(status.stack) ? formatVisualizationStackLabel(status.stack)
: status.message : status.message
@@ -105,21 +178,29 @@ export function useWorkspaceVisualization({
: '대기') : '대기')
} }
const startVisualizationBridge = async (): Promise<void> => { const startVisualizationBridge = async (mode = nav2VisualizationMode): Promise<void> => {
if (visualizationBusy) return if (visualizationBusy) return
if (!activeEnvironmentProfile) { if (!activeEnvironmentProfile) {
setVisualizationStatusMessage('지원 안함') setVisualizationStatusMessage('지원 안함')
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning') onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
return return
} }
if (mode !== nav2VisualizationMode) setNav2VisualizationMode(mode)
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중') setVisualizationStatusMessage('시작 중')
try { try {
const status = await window.localWorkspace.visualization.start(visualizationStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config) const status = remoteWorkspace
? remoteTarget?.host
? await window.remoteWorkspace.visualization.start(remoteTarget, visualizationStack, rosDomainId, activeEnvironmentProfile.config)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.start(visualizationRuntimeStack, clonePathPreview, rosDomainId, activeEnvironmentProfile.config)
if (!status.ok) throw new Error(status.message ?? '시작 실패') if (!status.ok) throw new Error(status.message ?? '시작 실패')
setVisualizationRunning(Boolean(status.running)) setVisualizationRunning(Boolean(status.running))
if (status.stack) setVisualizationStack(status.stack) const statusStack = status.stack === 'nav2' && visualizationStack === 'slam3d'
setVisualizationStatusMessage(formatVisualizationStackLabel(status.stack)) ? 'slam3d'
: status.stack
if (statusStack === 'autoware' || statusStack === 'nav2' || statusStack === 'slam' || statusStack === 'slam3d') setVisualizationStack(statusStack)
setVisualizationStatusMessage(formatVisualizationStackLabel(statusStack))
onTitlebarNotice('화면이 시작되었습니다', 'info') onTitlebarNotice('화면이 시작되었습니다', 'info')
} catch (error) { } catch (error) {
setVisualizationRunning(false) setVisualizationRunning(false)
@@ -135,7 +216,11 @@ export function useWorkspaceVisualization({
setVisualizationBusy(true) setVisualizationBusy(true)
setVisualizationStatusMessage('종료 중') setVisualizationStatusMessage('종료 중')
try { try {
const status = await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config) const status = remoteWorkspace
? remoteTarget?.host
? await window.remoteWorkspace.visualization.stop(remoteTarget)
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
: await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config)
if (!status.ok) throw new Error(status.message ?? '종료 실패') if (!status.ok) throw new Error(status.message ?? '종료 실패')
setVisualizationRunning(false) setVisualizationRunning(false)
setVisualizationStatusMessage('대기') setVisualizationStatusMessage('대기')
@@ -151,10 +236,12 @@ export function useWorkspaceVisualization({
return { return {
visualizationStacks, visualizationStacks,
visualizationStack, visualizationStack,
setVisualizationStack, setVisualizationStack: selectVisualizationStack,
visualizationRunning, visualizationRunning,
visualizationBusy, visualizationBusy,
visualizationViewerUrl, visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile, activeEnvironmentProfile,
activeRuntimeImages, activeRuntimeImages,
refreshVisualizationStatus, refreshVisualizationStatus,
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript' if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp' if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
if (file.endsWith('.json')) return 'json' if (file.endsWith('.json')) return 'json'
if (file.endsWith('.xml')) return 'xml' if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
if (file.endsWith('.html')) return 'html' if (file.endsWith('.html')) return 'html'
if (file.endsWith('.css')) return 'css' if (file.endsWith('.css')) return 'css'
if (file.endsWith('.py')) return 'python' if (file.endsWith('.py')) return 'python'
@@ -18,7 +18,7 @@ export function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['mod
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null, sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null, prebuiltArtifacts: module.prebuiltArtifacts ?? null,
parameters: [], parameters: module.defaultParameters?.filter((parameter): parameter is string => typeof parameter === 'string') ?? [],
cpu: null, cpu: null,
memory: null, memory: null,
runtime: '-' runtime: '-'
+80
View File
@@ -0,0 +1,80 @@
#!/usr/bin/env python3
"""Convert a Gaussian-splat PLY into a lightweight colored point-cloud GLB."""
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
import trimesh
SH_C0 = 0.28209479177387814
ALIGN_XY_SCALE = 3.672624762730334
ALIGN_YAW_RADIANS = 2.8292228430345236
ALIGN_XY_TRANSLATION = np.array([-1.4822845855746674, 0.8021416967574936], dtype=np.float32)
ALIGN_Z_SCALE = 3.5309628207501587
ALIGN_Z_OFFSET = 0.9007954323633903
VIEWER_FLOOR_OFFSET_Y = 0.41
def read_gaussian_ply(path: Path) -> tuple[np.ndarray, np.ndarray]:
payload = path.read_bytes()
header_end = payload.index(b"end_header\n") + len(b"end_header\n")
header = payload[:header_end].decode("utf-8")
vertex_count = int(next(line.split()[2] for line in header.splitlines() if line.startswith("element vertex ")))
properties = [line.split()[2] for line in header.splitlines() if line.startswith("property ")]
stride = len(properties) * 4
expected_size = header_end + vertex_count * stride
if len(payload) < expected_size:
raise ValueError("PLY vertex payload is truncated")
values = np.frombuffer(payload, dtype="<f4", count=vertex_count * len(properties), offset=header_end)
values = values.reshape(vertex_count, len(properties))
property_index = {name: index for index, name in enumerate(properties)}
positions = values[:, [property_index["x"], property_index["y"], property_index["z"]]].copy()
dc = values[:, [property_index["f_dc_0"], property_index["f_dc_1"], property_index["f_dc_2"]]]
colors = np.clip((0.5 + SH_C0 * dc) * 255.0, 0, 255).astype(np.uint8)
return positions, colors
def align_to_viewer(positions: np.ndarray) -> np.ndarray:
# The CloudCompare registration included roughly 2.5 degrees of roll and
# pitch. A 2D occupancy map is planar, so retain only its fitted XY yaw.
cosine = np.cos(ALIGN_YAW_RADIANS)
sine = np.sin(ALIGN_YAW_RADIANS)
rotation = np.array([[cosine, -sine], [sine, cosine]], dtype=np.float32)
aligned_xy = (positions[:, :2] @ rotation.T) * ALIGN_XY_SCALE + ALIGN_XY_TRANSLATION
aligned_z = positions[:, 2] * ALIGN_Z_SCALE + ALIGN_Z_OFFSET
# ROS map is Z-up; the viewer uses Three.js Y-up.
viewer_positions = np.column_stack((aligned_xy[:, 0], aligned_z, -aligned_xy[:, 1])).astype(np.float32)
viewer_positions[:, 1] += VIEWER_FLOOR_OFFSET_Y
return viewer_positions
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--max-points", type=int, default=398_137)
parser.add_argument("--seed", type=int, default=731)
args = parser.parse_args()
positions, colors = read_gaussian_ply(args.input)
positions = align_to_viewer(positions)
if args.max_points < len(positions):
generator = np.random.default_rng(args.seed)
selected = generator.choice(len(positions), size=args.max_points, replace=False)
positions = positions[selected]
colors = colors[selected]
rgba = np.column_stack((colors, np.full(len(colors), 255, dtype=np.uint8)))
args.output.parent.mkdir(parents=True, exist_ok=True)
trimesh.points.PointCloud(positions, colors=rgba).export(args.output, file_type="glb")
print(f"Wrote {len(positions):,} points to {args.output}")
if __name__ == "__main__":
main()