Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 97d6a0219f | |||
| d560ce6f82 | |||
| 4b181e5ad3 |
Generated
+78
-3
@@ -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",
|
||||||
|
|||||||
+5
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.0.23",
|
"version": "1.1.1",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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.
|
||||||
Executable
+4
@@ -0,0 +1,4 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
docker build -t nav2-dev:humble "$(cd "$(dirname "$0")" && pwd)"
|
||||||
+12
@@ -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 "$@"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ COPY bridge.py /app/bridge.py
|
|||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs && \
|
apt-get install -y --no-install-recommends python3-pip ros-humble-nav2-msgs && \
|
||||||
pip3 install --no-cache-dir -r /app/requirements.txt && \
|
pip3 install --no-cache-dir --retries 5 --timeout 180 -r /app/requirements.txt && \
|
||||||
rm -rf /var/lib/apt/lists/*
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
CMD bash -c "source /opt/ros/humble/setup.bash && python3 /app/bridge.py"
|
CMD bash -c "source /opt/ros/humble/setup.bash && python3 /app/bridge.py"
|
||||||
|
|||||||
Binary file not shown.
@@ -1,34 +1,42 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
import asyncio
|
import asyncio
|
||||||
import array
|
import array
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import struct
|
import struct
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
import traceback
|
import traceback
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import rclpy
|
import rclpy
|
||||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from geometry_msgs.msg import Twist
|
from geometry_msgs.msg import PoseStamped, PoseWithCovarianceStamped, Twist
|
||||||
from geometry_msgs.msg import PoseStamped
|
from action_msgs.msg import GoalStatus
|
||||||
from nav_msgs.msg import OccupancyGrid, Odometry
|
from nav_msgs.msg import OccupancyGrid, Odometry, Path as NavPath
|
||||||
from nav2_msgs.action import NavigateToPose
|
from nav2_msgs.action import NavigateToPose
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from rclpy.action import ActionClient
|
from rclpy.action import ActionClient
|
||||||
from rclpy.executors import MultiThreadedExecutor
|
from rclpy.executors import MultiThreadedExecutor
|
||||||
from rclpy.node import Node
|
from rclpy.node import Node
|
||||||
|
from rclpy.parameter import Parameter
|
||||||
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
|
from rclpy.qos import DurabilityPolicy, QoSProfile, ReliabilityPolicy, qos_profile_sensor_data
|
||||||
from sensor_msgs.msg import LaserScan
|
from sensor_msgs.msg import LaserScan, PointCloud2
|
||||||
from tf2_msgs.msg import TFMessage
|
from tf2_msgs.msg import TFMessage
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
|
||||||
|
_POINT_CLOUD_LIMIT = max(1_000, int(os.environ.get("POINT_CLOUD_LIMIT", "50000")))
|
||||||
|
|
||||||
|
|
||||||
def yaw_from_quaternion(q) -> float:
|
def yaw_from_quaternion(q) -> float:
|
||||||
return math.atan2(
|
return math.atan2(
|
||||||
@@ -76,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
|
||||||
@@ -88,15 +134,29 @@ class NavigationGoalPayload(BaseModel):
|
|||||||
frame_id: str = "map"
|
frame_id: str = "map"
|
||||||
|
|
||||||
|
|
||||||
|
class InitialPosePayload(NavigationGoalPayload):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SaveMapPayload(BaseModel):
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class SlamNode(Node):
|
class SlamNode(Node):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__("slam_web_bridge")
|
super().__init__("slam_web_bridge")
|
||||||
|
self.set_parameters([Parameter("use_sim_time", value=True)])
|
||||||
|
|
||||||
self.odom_topic = os.environ.get("ODOM_TOPIC", "/odom")
|
self.odom_topic = os.environ.get("ODOM_TOPIC", "/odom")
|
||||||
self.scan_topic = os.environ.get("SCAN_TOPIC", "/scan")
|
self.scan_topic = os.environ.get("SCAN_TOPIC", "/scan")
|
||||||
|
self.point_cloud_topic = os.environ.get("POINT_CLOUD_TOPIC", "/cloud_map")
|
||||||
|
self.fallback_point_cloud_topic = os.environ.get("FALLBACK_POINT_CLOUD_TOPIC", "/lidar_filtered")
|
||||||
|
self.icp_odom_topic = os.environ.get("ICP_ODOM_TOPIC", "/icp_odom")
|
||||||
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
|
self.cmd_vel_topic = os.environ.get("CMD_VEL_TOPIC", "/cmd_vel")
|
||||||
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
|
self.map_topic = os.environ.get("MAP_TOPIC", "/map")
|
||||||
|
self.plan_topic = os.environ.get("PLAN_TOPIC", "/plan")
|
||||||
self.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
|
self.navigate_to_pose_action = os.environ.get("NAVIGATE_TO_POSE_ACTION", "/navigate_to_pose")
|
||||||
|
self.read_only = os.environ.get("READ_ONLY", "false").lower() in ("1", "true", "yes")
|
||||||
|
|
||||||
best_effort_qos = QoSProfile(
|
best_effort_qos = QoSProfile(
|
||||||
depth=1,
|
depth=1,
|
||||||
@@ -108,33 +168,62 @@ class SlamNode(Node):
|
|||||||
reliability=ReliabilityPolicy.RELIABLE,
|
reliability=ReliabilityPolicy.RELIABLE,
|
||||||
durability=DurabilityPolicy.VOLATILE,
|
durability=DurabilityPolicy.VOLATILE,
|
||||||
)
|
)
|
||||||
|
map_qos = QoSProfile(
|
||||||
|
depth=1,
|
||||||
|
reliability=ReliabilityPolicy.RELIABLE,
|
||||||
|
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||||
|
)
|
||||||
|
tf_static_qos = QoSProfile(
|
||||||
|
depth=10,
|
||||||
|
reliability=ReliabilityPolicy.RELIABLE,
|
||||||
|
durability=DurabilityPolicy.TRANSIENT_LOCAL,
|
||||||
|
)
|
||||||
|
|
||||||
self._odom: Optional[Odometry] = None
|
self._odom: Optional[Odometry] = None
|
||||||
self._scan: Optional[LaserScan] = None
|
self._scan: Optional[LaserScan] = None
|
||||||
|
self._point_cloud: Optional[PointCloud2] = None
|
||||||
|
self._fallback_point_cloud: Optional[PointCloud2] = None
|
||||||
|
self._icp_odom: Optional[Odometry] = None
|
||||||
self._map: Optional[OccupancyGrid] = None
|
self._map: Optional[OccupancyGrid] = None
|
||||||
|
self._plan: Optional[NavPath] = None
|
||||||
self._tf = {}
|
self._tf = {}
|
||||||
self._odom_count = 0
|
self._odom_count = 0
|
||||||
self._scan_count = 0
|
self._scan_count = 0
|
||||||
|
self._point_cloud_count = 0
|
||||||
|
self._fallback_point_cloud_count = 0
|
||||||
|
self._icp_odom_count = 0
|
||||||
self._map_count = 0
|
self._map_count = 0
|
||||||
self._tf_count = 0
|
self._tf_count = 0
|
||||||
self._last_odom_wall_time = None
|
self._last_odom_wall_time = None
|
||||||
self._last_scan_wall_time = None
|
self._last_scan_wall_time = None
|
||||||
|
self._last_point_cloud_wall_time = None
|
||||||
|
self._last_fallback_point_cloud_wall_time = None
|
||||||
|
self._last_icp_odom_wall_time = None
|
||||||
self._last_map_wall_time = None
|
self._last_map_wall_time = None
|
||||||
|
self._navigation = {"status": "idle", "goal_id": None}
|
||||||
|
self._active_goal_handle = None
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos)
|
self.create_subscription(Odometry, self.odom_topic, self._on_odom, reliable_qos)
|
||||||
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
|
self.create_subscription(Odometry, self.odom_topic, self._on_odom, best_effort_qos)
|
||||||
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
|
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, qos_profile_sensor_data)
|
||||||
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
|
self.create_subscription(LaserScan, self.scan_topic, self._on_scan, reliable_qos)
|
||||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, reliable_qos)
|
self.create_subscription(PointCloud2, self.point_cloud_topic, self._on_point_cloud, map_qos)
|
||||||
|
if self.fallback_point_cloud_topic != self.point_cloud_topic:
|
||||||
|
self.create_subscription(PointCloud2, self.fallback_point_cloud_topic, self._on_fallback_point_cloud, qos_profile_sensor_data)
|
||||||
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, best_effort_qos)
|
||||||
|
self.create_subscription(Odometry, self.icp_odom_topic, self._on_icp_odom, reliable_qos)
|
||||||
|
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, map_qos)
|
||||||
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, best_effort_qos)
|
self.create_subscription(OccupancyGrid, self.map_topic, self._on_map, best_effort_qos)
|
||||||
|
self.create_subscription(NavPath, self.plan_topic, self._on_plan, reliable_qos)
|
||||||
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
self.create_subscription(TFMessage, "/tf", self._on_tf, qos_profile_sensor_data)
|
||||||
self.create_subscription(TFMessage, "/tf_static", self._on_tf, reliable_qos)
|
self.create_subscription(TFMessage, "/tf_static", self._on_tf, tf_static_qos)
|
||||||
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
self._cmd_vel_pub = self.create_publisher(Twist, self.cmd_vel_topic, 10)
|
||||||
|
self._initial_pose_pub = self.create_publisher(PoseWithCovarianceStamped, "/initialpose", 10)
|
||||||
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
self._navigate_to_pose_client = ActionClient(self, NavigateToPose, self.navigate_to_pose_action)
|
||||||
|
|
||||||
self.get_logger().info(
|
self.get_logger().info(
|
||||||
f"SLAM bridge started: odom={self.odom_topic}, scan={self.scan_topic}, map={self.map_topic}, cmd_vel={self.cmd_vel_topic}, navigate={self.navigate_to_pose_action}"
|
f"SLAM bridge started: odom={self.odom_topic}, icp_odom={self.icp_odom_topic}, scan={self.scan_topic}, point_cloud={self.point_cloud_topic}, fallback_point_cloud={self.fallback_point_cloud_topic}, map={self.map_topic}, read_only={self.read_only}"
|
||||||
)
|
)
|
||||||
|
|
||||||
def _on_odom(self, msg: Odometry):
|
def _on_odom(self, msg: Odometry):
|
||||||
@@ -143,18 +232,239 @@ class SlamNode(Node):
|
|||||||
self._odom_count += 1
|
self._odom_count += 1
|
||||||
self._last_odom_wall_time = time.time()
|
self._last_odom_wall_time = time.time()
|
||||||
|
|
||||||
|
def publish_initial_pose(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> None:
|
||||||
|
message = PoseWithCovarianceStamped()
|
||||||
|
message.header.stamp = self.get_clock().now().to_msg()
|
||||||
|
message.header.frame_id = frame_id
|
||||||
|
message.pose.pose.position.x = x
|
||||||
|
message.pose.pose.position.y = y
|
||||||
|
orientation = quaternion_from_yaw(yaw)
|
||||||
|
message.pose.pose.orientation.x = orientation["x"]
|
||||||
|
message.pose.pose.orientation.y = orientation["y"]
|
||||||
|
message.pose.pose.orientation.z = orientation["z"]
|
||||||
|
message.pose.pose.orientation.w = orientation["w"]
|
||||||
|
message.pose.covariance[0] = 0.25
|
||||||
|
message.pose.covariance[7] = 0.25
|
||||||
|
message.pose.covariance[35] = 0.0685
|
||||||
|
self._initial_pose_pub.publish(message)
|
||||||
|
|
||||||
|
def save_map(self, name: Optional[str] = None) -> dict:
|
||||||
|
with self._lock:
|
||||||
|
grid = self._map
|
||||||
|
if grid is None or not grid.data:
|
||||||
|
raise ValueError("저장할 SLAM 맵이 없습니다.")
|
||||||
|
width = grid.info.width
|
||||||
|
height = grid.info.height
|
||||||
|
resolution = grid.info.resolution
|
||||||
|
origin = grid.info.origin
|
||||||
|
data = list(grid.data)
|
||||||
|
|
||||||
|
if width <= 0 or height <= 0 or len(data) < width * height:
|
||||||
|
raise ValueError("저장할 SLAM 맵 데이터가 올바르지 않습니다.")
|
||||||
|
|
||||||
|
safe_name = "".join(char if char.isalnum() or char in "-_" else "-" for char in (name or "").strip())
|
||||||
|
if not safe_name:
|
||||||
|
safe_name = time.strftime("slam-map-%Y%m%d-%H%M%S")
|
||||||
|
maps_dir = Path(os.environ.get("SAVED_MAPS_PATH", "/workspace/maps"))
|
||||||
|
maps_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
pgm_path = maps_dir / f"{safe_name}.pgm"
|
||||||
|
yaml_path = maps_dir / f"{safe_name}.yaml"
|
||||||
|
|
||||||
|
# OccupancyGrid starts at the lower-left origin; PGM rows start at the top.
|
||||||
|
pixels = bytearray()
|
||||||
|
for row in range(height - 1, -1, -1):
|
||||||
|
for column in range(width):
|
||||||
|
value = data[row * width + column]
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 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(
|
||||||
|
"\n".join([
|
||||||
|
f"image: {pgm_path.name}",
|
||||||
|
"mode: trinary",
|
||||||
|
f"resolution: {resolution}",
|
||||||
|
f"origin: [{origin.position.x}, {origin.position.y}, {yaw_from_quaternion(origin.orientation)}]",
|
||||||
|
"negate: 0",
|
||||||
|
"occupied_thresh: 0.65",
|
||||||
|
"free_thresh: 0.25",
|
||||||
|
"",
|
||||||
|
]),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keep the RTAB-Map database beside the Nav2-compatible PGM/YAML map.
|
||||||
|
# The live database is reset when a new SLAM session starts, so each
|
||||||
|
# explicit save receives its own SQLite-consistent snapshot.
|
||||||
|
database_path = None
|
||||||
|
database_source = Path(os.environ.get("RTABMAP_DATABASE_PATH", ""))
|
||||||
|
if os.environ.get("RTABMAP_MAP_SAVE_ENABLED", "").lower() == "true" and database_source.is_file():
|
||||||
|
database_snapshot = maps_dir / f"{safe_name}.db"
|
||||||
|
source_connection = None
|
||||||
|
destination_connection = None
|
||||||
|
try:
|
||||||
|
source_connection = sqlite3.connect(f"file:{database_source}?mode=ro", uri=True)
|
||||||
|
destination_connection = sqlite3.connect(str(database_snapshot))
|
||||||
|
source_connection.backup(destination_connection)
|
||||||
|
except sqlite3.Error:
|
||||||
|
if destination_connection is not None:
|
||||||
|
destination_connection.close()
|
||||||
|
destination_connection = None
|
||||||
|
shutil.copy2(database_source, database_snapshot)
|
||||||
|
finally:
|
||||||
|
if destination_connection is not None:
|
||||||
|
destination_connection.close()
|
||||||
|
if source_connection is not None:
|
||||||
|
source_connection.close()
|
||||||
|
database_path = str(database_snapshot)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": safe_name,
|
||||||
|
"yamlPath": str(yaml_path),
|
||||||
|
"pgmPath": str(pgm_path),
|
||||||
|
"databasePath": database_path,
|
||||||
|
"resolution": resolution,
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"previewPgm": base64.b64encode(preview_pgm).decode("ascii"),
|
||||||
|
}
|
||||||
|
|
||||||
def _on_scan(self, msg: LaserScan):
|
def _on_scan(self, msg: LaserScan):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._scan = msg
|
self._scan = msg
|
||||||
self._scan_count += 1
|
self._scan_count += 1
|
||||||
self._last_scan_wall_time = time.time()
|
self._last_scan_wall_time = time.time()
|
||||||
|
|
||||||
|
def _on_point_cloud(self, msg: PointCloud2):
|
||||||
|
with self._lock:
|
||||||
|
self._point_cloud = msg
|
||||||
|
self._point_cloud_count += 1
|
||||||
|
self._last_point_cloud_wall_time = time.time()
|
||||||
|
|
||||||
|
def _on_fallback_point_cloud(self, msg: PointCloud2):
|
||||||
|
with self._lock:
|
||||||
|
self._fallback_point_cloud = msg
|
||||||
|
self._fallback_point_cloud_count += 1
|
||||||
|
self._last_fallback_point_cloud_wall_time = time.time()
|
||||||
|
|
||||||
|
def _active_point_cloud(self) -> Optional[PointCloud2]:
|
||||||
|
with self._lock:
|
||||||
|
primary = self._point_cloud
|
||||||
|
fallback = self._fallback_point_cloud
|
||||||
|
|
||||||
|
# A global RTAB-Map cloud is the visualization source. Raw LiDAR is
|
||||||
|
# only a startup fallback before the first global map is received.
|
||||||
|
if primary is not None:
|
||||||
|
return primary
|
||||||
|
return fallback
|
||||||
|
|
||||||
|
def _on_icp_odom(self, msg: Odometry):
|
||||||
|
with self._lock:
|
||||||
|
self._icp_odom = msg
|
||||||
|
self._icp_odom_count += 1
|
||||||
|
self._last_icp_odom_wall_time = time.time()
|
||||||
|
|
||||||
def _on_map(self, msg: OccupancyGrid):
|
def _on_map(self, msg: OccupancyGrid):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._map = msg
|
self._map = msg
|
||||||
self._map_count += 1
|
self._map_count += 1
|
||||||
self._last_map_wall_time = time.time()
|
self._last_map_wall_time = time.time()
|
||||||
|
|
||||||
|
def _on_plan(self, msg: NavPath):
|
||||||
|
with self._lock:
|
||||||
|
self._plan = msg
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _has_populated_map(grid: Optional[OccupancyGrid]) -> bool:
|
||||||
|
return bool(grid and grid.info.width > 0 and grid.info.height > 0 and grid.data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _initial_scan_map(scan: Optional[LaserScan], odom: Optional[Odometry]) -> Optional[dict]:
|
||||||
|
if scan is None or not scan.ranges:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Show the first LiDAR sweep before slam_toolbox has produced an occupancy grid.
|
||||||
|
resolution = 0.05
|
||||||
|
width = 160
|
||||||
|
height = 160
|
||||||
|
origin_x = (odom.pose.pose.position.x if odom else 0.0) - (width * resolution / 2.0)
|
||||||
|
origin_y = (odom.pose.pose.position.y if odom else 0.0) - (height * resolution / 2.0)
|
||||||
|
robot_x = odom.pose.pose.position.x if odom else 0.0
|
||||||
|
robot_y = odom.pose.pose.position.y if odom else 0.0
|
||||||
|
robot_yaw = yaw_from_quaternion(odom.pose.pose.orientation) if odom else 0.0
|
||||||
|
data = array.array("b", [-1]) * (width * height)
|
||||||
|
|
||||||
|
def to_cell(x: float, y: float) -> tuple[int, int]:
|
||||||
|
return int((x - origin_x) / resolution), int((y - origin_y) / resolution)
|
||||||
|
|
||||||
|
for index, distance in enumerate(scan.ranges):
|
||||||
|
if not math.isfinite(distance) or distance < scan.range_min or distance >= scan.range_max:
|
||||||
|
continue
|
||||||
|
angle = robot_yaw + scan.angle_min + index * scan.angle_increment
|
||||||
|
steps = max(1, int(distance / resolution))
|
||||||
|
for step in range(0, steps, 2):
|
||||||
|
x, y = to_cell(
|
||||||
|
robot_x + math.cos(angle) * step * resolution,
|
||||||
|
robot_y + math.sin(angle) * step * resolution,
|
||||||
|
)
|
||||||
|
if 0 <= x < width and 0 <= y < height:
|
||||||
|
data[y * width + x] = 0
|
||||||
|
x, y = to_cell(
|
||||||
|
robot_x + math.cos(angle) * distance,
|
||||||
|
robot_y + math.sin(angle) * distance,
|
||||||
|
)
|
||||||
|
if 0 <= x < width and 0 <= y < height:
|
||||||
|
data[y * width + x] = 100
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_id": odom.header.frame_id if odom else "odom",
|
||||||
|
"width": width,
|
||||||
|
"height": height,
|
||||||
|
"resolution": resolution,
|
||||||
|
"origin": {"x": origin_x, "y": origin_y, "z": 0.0, "yaw": 0.0},
|
||||||
|
"data": data,
|
||||||
|
"t": time.time(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _effective_map(self, grid: Optional[OccupancyGrid], scan: Optional[LaserScan], odom: Optional[Odometry]) -> Optional[dict]:
|
||||||
|
if self._has_populated_map(grid):
|
||||||
|
origin = grid.info.origin
|
||||||
|
return {
|
||||||
|
"frame_id": grid.header.frame_id,
|
||||||
|
"width": grid.info.width,
|
||||||
|
"height": grid.info.height,
|
||||||
|
"resolution": grid.info.resolution,
|
||||||
|
"origin": {
|
||||||
|
"x": origin.position.x,
|
||||||
|
"y": origin.position.y,
|
||||||
|
"z": origin.position.z,
|
||||||
|
"yaw": yaw_from_quaternion(origin.orientation),
|
||||||
|
},
|
||||||
|
"data": array.array("b", grid.data),
|
||||||
|
"t": grid.header.stamp.sec + grid.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
return self._initial_scan_map(scan, odom)
|
||||||
|
|
||||||
def _on_tf(self, msg: TFMessage):
|
def _on_tf(self, msg: TFMessage):
|
||||||
with self._lock:
|
with self._lock:
|
||||||
for transform in msg.transforms:
|
for transform in msg.transforms:
|
||||||
@@ -172,12 +482,16 @@ class SlamNode(Node):
|
|||||||
self._tf_count += 1
|
self._tf_count += 1
|
||||||
|
|
||||||
def publish_cmd_vel(self, linear: float, angular: float):
|
def publish_cmd_vel(self, linear: float, angular: float):
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 차량 제어를 지원하지 않습니다.")
|
||||||
msg = Twist()
|
msg = Twist()
|
||||||
msg.linear.x = float(linear)
|
msg.linear.x = float(linear)
|
||||||
msg.angular.z = float(angular)
|
msg.angular.z = float(angular)
|
||||||
self._cmd_vel_pub.publish(msg)
|
self._cmd_vel_pub.publish(msg)
|
||||||
|
|
||||||
def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
|
def send_navigation_goal(self, x: float, y: float, yaw: float = 0.0, frame_id: str = "map") -> dict:
|
||||||
|
if self.read_only:
|
||||||
|
raise RuntimeError("읽기 전용 3D SLAM 화면에서는 목표점을 설정할 수 없습니다.")
|
||||||
if not self._navigate_to_pose_client.wait_for_server(timeout_sec=2.0):
|
if not self._navigate_to_pose_client.wait_for_server(timeout_sec=2.0):
|
||||||
raise RuntimeError(f"NavigateToPose action server is not available: {self.navigate_to_pose_action}")
|
raise RuntimeError(f"NavigateToPose action server is not available: {self.navigate_to_pose_action}")
|
||||||
|
|
||||||
@@ -206,21 +520,83 @@ class SlamNode(Node):
|
|||||||
if not goal_handle.accepted:
|
if not goal_handle.accepted:
|
||||||
return {"ok": False, "accepted": False, "message": "Navigation goal was rejected"}
|
return {"ok": False, "accepted": False, "message": "Navigation goal was rejected"}
|
||||||
|
|
||||||
|
goal_id = bytes(goal_handle.goal_id.uuid).hex()
|
||||||
|
with self._lock:
|
||||||
|
self._navigation = {"status": "running", "goal_id": goal_id}
|
||||||
|
self._active_goal_handle = goal_handle
|
||||||
|
goal_handle.get_result_async().add_done_callback(
|
||||||
|
lambda result_future, expected_goal_id=goal_id: self._on_navigation_result(
|
||||||
|
result_future, expected_goal_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"accepted": True,
|
"accepted": True,
|
||||||
"goal_id": bytes(goal_handle.goal_id.uuid).hex(),
|
"goal_id": goal_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _on_navigation_result(self, future, goal_id: str):
|
||||||
|
try:
|
||||||
|
status = future.result().status
|
||||||
|
except Exception:
|
||||||
|
self.get_logger().warning("NavigateToPose result could not be read")
|
||||||
|
return
|
||||||
|
|
||||||
|
if status == GoalStatus.STATUS_SUCCEEDED:
|
||||||
|
result_status = "succeeded"
|
||||||
|
elif status == GoalStatus.STATUS_CANCELED:
|
||||||
|
result_status = "canceled"
|
||||||
|
else:
|
||||||
|
result_status = "failed"
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# A previous goal can finish after a newer goal has been accepted.
|
||||||
|
# Do not let that completion overwrite the current navigation state.
|
||||||
|
if self._navigation.get("goal_id") != goal_id:
|
||||||
|
return
|
||||||
|
self._navigation = {**self._navigation, "status": result_status}
|
||||||
|
self._active_goal_handle = None
|
||||||
|
|
||||||
|
def cancel_navigation(self, wait: bool = False) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
goal_handle = self._active_goal_handle
|
||||||
|
self._navigation = {**self._navigation, "status": "canceled"}
|
||||||
|
if goal_handle is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
cancel_future = goal_handle.cancel_goal_async()
|
||||||
|
if not wait:
|
||||||
|
return True
|
||||||
|
|
||||||
|
deadline = time.time() + 2.0
|
||||||
|
while not cancel_future.done() and time.time() < deadline:
|
||||||
|
time.sleep(0.02)
|
||||||
|
if not cancel_future.done():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
cancel_future.result()
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
self.get_logger().warning("NavigateToPose goal cancellation could not be confirmed")
|
||||||
|
return False
|
||||||
|
|
||||||
def snapshot(self, include_map: bool = True) -> dict:
|
def snapshot(self, include_map: bool = True) -> dict:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
odom = self._odom
|
odom = self._odom
|
||||||
|
icp_odom = self._icp_odom
|
||||||
scan = self._scan
|
scan = self._scan
|
||||||
grid = self._map
|
grid = self._map
|
||||||
|
plan = self._plan
|
||||||
tf_snapshot = dict(self._tf)
|
tf_snapshot = dict(self._tf)
|
||||||
|
navigation = dict(self._navigation)
|
||||||
|
effective_map = self._effective_map(grid, scan, odom)
|
||||||
|
point_cloud = self._active_point_cloud()
|
||||||
|
|
||||||
pose = None
|
pose = None
|
||||||
pose_map = None
|
pose_map = None
|
||||||
|
scan_pose = None
|
||||||
|
scan_pose_map = None
|
||||||
if odom is not None:
|
if odom is not None:
|
||||||
pos = odom.pose.pose.position
|
pos = odom.pose.pose.position
|
||||||
ori = odom.pose.pose.orientation
|
ori = odom.pose.pose.orientation
|
||||||
@@ -239,16 +615,72 @@ 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,
|
||||||
}
|
}
|
||||||
map_to_odom = tf_snapshot.get(("map", odom.header.frame_id))
|
odom_to_scan = (
|
||||||
if map_to_odom is not None:
|
resolve_2d_transform(tf_snapshot, odom.header.frame_id, scan.header.frame_id)
|
||||||
|
if scan is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if odom_to_scan is not None:
|
||||||
|
scan_pose = {
|
||||||
|
**odom_to_scan,
|
||||||
|
"roll": 0.0,
|
||||||
|
"pitch": 0.0,
|
||||||
|
"frame_id": pose["frame_id"],
|
||||||
|
"source_frame_id": scan.header.frame_id,
|
||||||
|
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
map_to_base = resolve_2d_transform(tf_snapshot, "map", odom.child_frame_id)
|
||||||
|
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,
|
||||||
"t": pose["t"],
|
"t": pose["t"],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if scan is not None and pose_map is not None:
|
||||||
|
# Resolve map -> sensor directly through the published TF graph so
|
||||||
|
# the scan uses the same localization correction as the map.
|
||||||
|
map_to_scan = resolve_2d_transform(tf_snapshot, "map", scan.header.frame_id)
|
||||||
|
if map_to_scan is not None:
|
||||||
|
scan_pose_map = {
|
||||||
|
**map_to_scan,
|
||||||
|
"roll": 0.0,
|
||||||
|
"pitch": 0.0,
|
||||||
|
"frame_id": "map",
|
||||||
|
"source_frame_id": scan.header.frame_id,
|
||||||
|
"t": scan.header.stamp.sec + scan.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
|
||||||
|
pose_3d = None
|
||||||
|
if icp_odom is not None:
|
||||||
|
pos = icp_odom.pose.pose.position
|
||||||
|
ori = icp_odom.pose.pose.orientation
|
||||||
|
euler = euler_from_quaternion(ori)
|
||||||
|
pose_3d = {
|
||||||
|
"frame_id": icp_odom.header.frame_id,
|
||||||
|
"child_frame_id": icp_odom.child_frame_id,
|
||||||
|
"x": pos.x,
|
||||||
|
"y": pos.y,
|
||||||
|
"z": pos.z,
|
||||||
|
"roll": euler["roll"],
|
||||||
|
"pitch": euler["pitch"],
|
||||||
|
"yaw": euler["yaw"],
|
||||||
|
"t": icp_odom.header.stamp.sec + icp_odom.header.stamp.nanosec * 1e-9,
|
||||||
|
}
|
||||||
|
cloud_frame = point_cloud.header.frame_id if point_cloud is not None else ""
|
||||||
|
map_to_icp_odom = tf_snapshot.get((cloud_frame, icp_odom.header.frame_id)) if cloud_frame else None
|
||||||
|
if map_to_icp_odom is not None:
|
||||||
|
# RTAB-Map publishes /cloud_map in map, while ICP odometry is
|
||||||
|
# expressed in icp_odom. Render both in the cloud frame.
|
||||||
|
pose_3d = {
|
||||||
|
**pose_3d,
|
||||||
|
**compose_2d(map_to_icp_odom, pose_3d),
|
||||||
|
"frame_id": cloud_frame,
|
||||||
|
"source_frame_id": icp_odom.header.frame_id,
|
||||||
|
}
|
||||||
|
|
||||||
scan_payload = None
|
scan_payload = None
|
||||||
if scan is not None:
|
if scan is not None:
|
||||||
scan_payload = {
|
scan_payload = {
|
||||||
@@ -264,21 +696,23 @@ class SlamNode(Node):
|
|||||||
}
|
}
|
||||||
|
|
||||||
map_payload = None
|
map_payload = None
|
||||||
if include_map and grid is not None:
|
if include_map and effective_map is not None:
|
||||||
origin = grid.info.origin
|
|
||||||
map_payload = {
|
map_payload = {
|
||||||
"frame_id": grid.header.frame_id,
|
"frame_id": effective_map["frame_id"],
|
||||||
"width": grid.info.width,
|
"width": effective_map["width"],
|
||||||
"height": grid.info.height,
|
"height": effective_map["height"],
|
||||||
"resolution": grid.info.resolution,
|
"resolution": effective_map["resolution"],
|
||||||
"origin": {
|
"origin": effective_map["origin"],
|
||||||
"x": origin.position.x,
|
|
||||||
"y": origin.position.y,
|
|
||||||
"z": origin.position.z,
|
|
||||||
"yaw": yaw_from_quaternion(origin.orientation),
|
|
||||||
},
|
|
||||||
"encoding": "binary-int8-map1",
|
"encoding": "binary-int8-map1",
|
||||||
"t": grid.header.stamp.sec + grid.header.stamp.nanosec * 1e-9,
|
"t": effective_map["t"],
|
||||||
|
}
|
||||||
|
|
||||||
|
point_cloud_payload = None
|
||||||
|
if point_cloud is not None:
|
||||||
|
point_cloud_payload = {
|
||||||
|
"frame_id": point_cloud.header.frame_id,
|
||||||
|
"count": point_cloud.width * point_cloud.height,
|
||||||
|
"t": point_cloud.header.stamp.sec + point_cloud.header.stamp.nanosec * 1e-9,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -286,27 +720,83 @@ class SlamNode(Node):
|
|||||||
"topics": {
|
"topics": {
|
||||||
"odom": self.odom_topic,
|
"odom": self.odom_topic,
|
||||||
"scan": self.scan_topic,
|
"scan": self.scan_topic,
|
||||||
|
"point_cloud": self.point_cloud_topic,
|
||||||
|
"icp_odom": self.icp_odom_topic,
|
||||||
"map": self.map_topic,
|
"map": self.map_topic,
|
||||||
"cmd_vel": self.cmd_vel_topic,
|
"cmd_vel": self.cmd_vel_topic,
|
||||||
"navigate_to_pose": self.navigate_to_pose_action,
|
"navigate_to_pose": self.navigate_to_pose_action,
|
||||||
},
|
},
|
||||||
"pose": pose,
|
"pose": pose,
|
||||||
"pose_map": pose_map,
|
"pose_map": pose_map,
|
||||||
|
"scan_pose": scan_pose,
|
||||||
|
"scan_pose_map": scan_pose_map,
|
||||||
|
"pose_3d": pose_3d,
|
||||||
"scan": scan_payload,
|
"scan": scan_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,
|
||||||
|
"plan": {
|
||||||
|
"frame_id": plan.header.frame_id,
|
||||||
|
"poses": [
|
||||||
|
{
|
||||||
|
"x": pose.pose.position.x,
|
||||||
|
"y": pose.pose.position.y,
|
||||||
|
"yaw": yaw_from_quaternion(pose.pose.orientation),
|
||||||
|
}
|
||||||
|
for pose in plan.poses
|
||||||
|
],
|
||||||
|
} if plan is not None else None,
|
||||||
"server_time": time.time(),
|
"server_time": time.time(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def point_cloud_payload(self, limit: int = _POINT_CLOUD_LIMIT) -> Optional[dict]:
|
||||||
|
cloud = self._active_point_cloud()
|
||||||
|
if cloud is None or cloud.point_step <= 0 or not cloud.data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
offsets = {field.name: field.offset for field in cloud.fields}
|
||||||
|
if not all(axis in offsets for axis in ("x", "y", "z")):
|
||||||
|
return None
|
||||||
|
|
||||||
|
total = cloud.width * cloud.height
|
||||||
|
if total <= 0:
|
||||||
|
return None
|
||||||
|
stride = max(1, math.ceil(total / max(1, limit)))
|
||||||
|
prefix = ">" if cloud.is_bigendian else "<"
|
||||||
|
data = cloud.data
|
||||||
|
points = []
|
||||||
|
for index in range(0, total, stride):
|
||||||
|
offset = index * cloud.point_step
|
||||||
|
if offset + cloud.point_step > len(data):
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
x = struct.unpack_from(f"{prefix}f", data, offset + offsets["x"])[0]
|
||||||
|
y = struct.unpack_from(f"{prefix}f", data, offset + offsets["y"])[0]
|
||||||
|
z = struct.unpack_from(f"{prefix}f", data, offset + offsets["z"])[0]
|
||||||
|
except struct.error:
|
||||||
|
break
|
||||||
|
if math.isfinite(x) and math.isfinite(y) and math.isfinite(z):
|
||||||
|
points.append([x, y, z])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"frame_id": cloud.header.frame_id,
|
||||||
|
"stamp": cloud.header.stamp.sec + cloud.header.stamp.nanosec * 1e-9,
|
||||||
|
"points": points,
|
||||||
|
}
|
||||||
|
|
||||||
def map_binary(self) -> Optional[bytes]:
|
def map_binary(self) -> Optional[bytes]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
grid = self._map
|
grid = self._map
|
||||||
if grid is None:
|
scan = self._scan
|
||||||
|
odom = self._odom
|
||||||
|
effective_map = self._effective_map(grid, scan, odom)
|
||||||
|
if effective_map is None:
|
||||||
return None
|
return None
|
||||||
header = struct.pack("<4sII", b"MAP1", grid.info.width, grid.info.height)
|
header = struct.pack("<4sII", b"MAP1", effective_map["width"], effective_map["height"])
|
||||||
data = array.array("b", grid.data).tobytes()
|
data = effective_map["data"].tobytes()
|
||||||
return header + data
|
return header + data
|
||||||
|
|
||||||
def scan_binary(self) -> Optional[bytes]:
|
def scan_binary(self) -> Optional[bytes]:
|
||||||
@@ -329,28 +819,40 @@ class SlamNode(Node):
|
|||||||
def topic_debug(self) -> dict:
|
def topic_debug(self) -> dict:
|
||||||
topics = {}
|
topics = {}
|
||||||
for name, types in self.get_topic_names_and_types():
|
for name, types in self.get_topic_names_and_types():
|
||||||
if name in (self.odom_topic, self.scan_topic, self.map_topic, self.cmd_vel_topic):
|
if name in (self.odom_topic, self.icp_odom_topic, self.scan_topic, self.point_cloud_topic, self.fallback_point_cloud_topic, self.map_topic, self.cmd_vel_topic):
|
||||||
topics[name] = types
|
topics[name] = types
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"configured": {
|
"configured": {
|
||||||
"odom": self.odom_topic,
|
"odom": self.odom_topic,
|
||||||
"scan": self.scan_topic,
|
"scan": self.scan_topic,
|
||||||
|
"point_cloud": self.point_cloud_topic,
|
||||||
|
"fallback_point_cloud": self.fallback_point_cloud_topic,
|
||||||
|
"icp_odom": self.icp_odom_topic,
|
||||||
"map": self.map_topic,
|
"map": self.map_topic,
|
||||||
"cmd_vel": self.cmd_vel_topic,
|
"cmd_vel": self.cmd_vel_topic,
|
||||||
"navigate_to_pose": self.navigate_to_pose_action,
|
"navigate_to_pose": self.navigate_to_pose_action,
|
||||||
},
|
},
|
||||||
"visible": topics,
|
"visible": topics,
|
||||||
"scan_publishers": self.count_publishers(self.scan_topic),
|
"scan_publishers": self.count_publishers(self.scan_topic),
|
||||||
|
"point_cloud_publishers": self.count_publishers(self.point_cloud_topic),
|
||||||
|
"fallback_point_cloud_publishers": self.count_publishers(self.fallback_point_cloud_topic),
|
||||||
|
"icp_odom_publishers": self.count_publishers(self.icp_odom_topic),
|
||||||
"odom_publishers": self.count_publishers(self.odom_topic),
|
"odom_publishers": self.count_publishers(self.odom_topic),
|
||||||
"map_publishers": self.count_publishers(self.map_topic),
|
"map_publishers": self.count_publishers(self.map_topic),
|
||||||
"cmd_vel_subscribers": self.count_subscribers(self.cmd_vel_topic),
|
"cmd_vel_subscribers": self.count_subscribers(self.cmd_vel_topic),
|
||||||
"received": {
|
"received": {
|
||||||
"scan_count": self._scan_count,
|
"scan_count": self._scan_count,
|
||||||
|
"point_cloud_count": self._point_cloud_count,
|
||||||
|
"fallback_point_cloud_count": self._fallback_point_cloud_count,
|
||||||
|
"icp_odom_count": self._icp_odom_count,
|
||||||
"odom_count": self._odom_count,
|
"odom_count": self._odom_count,
|
||||||
"map_count": self._map_count,
|
"map_count": self._map_count,
|
||||||
"tf_count": self._tf_count,
|
"tf_count": self._tf_count,
|
||||||
"last_scan_wall_time": self._last_scan_wall_time,
|
"last_scan_wall_time": self._last_scan_wall_time,
|
||||||
|
"last_point_cloud_wall_time": self._last_point_cloud_wall_time,
|
||||||
|
"last_fallback_point_cloud_wall_time": self._last_fallback_point_cloud_wall_time,
|
||||||
|
"last_icp_odom_wall_time": self._last_icp_odom_wall_time,
|
||||||
"last_odom_wall_time": self._last_odom_wall_time,
|
"last_odom_wall_time": self._last_odom_wall_time,
|
||||||
"last_map_wall_time": self._last_map_wall_time,
|
"last_map_wall_time": self._last_map_wall_time,
|
||||||
},
|
},
|
||||||
@@ -399,6 +901,7 @@ def health():
|
|||||||
"ok": True,
|
"ok": True,
|
||||||
"has_pose": bool(snap.get("pose")),
|
"has_pose": bool(snap.get("pose")),
|
||||||
"has_scan": bool(snap.get("scan")),
|
"has_scan": bool(snap.get("scan")),
|
||||||
|
"has_point_cloud": bool(snap.get("point_cloud")),
|
||||||
"has_map": bool(snap.get("map")),
|
"has_map": bool(snap.get("map")),
|
||||||
"topics": snap.get("topics", {}),
|
"topics": snap.get("topics", {}),
|
||||||
}
|
}
|
||||||
@@ -411,6 +914,37 @@ def state():
|
|||||||
return slam_node.snapshot()
|
return slam_node.snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/point-cloud")
|
||||||
|
def point_cloud():
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
payload = slam_node.point_cloud_payload()
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=404, detail="포인트클라우드를 아직 수신하지 못했습니다.")
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/world-layout")
|
||||||
|
def world_layout():
|
||||||
|
path = Path(os.environ.get("WORLD_LAYOUT_PATH", "/workspace/world-layout.json"))
|
||||||
|
try:
|
||||||
|
parsed = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
objects = parsed.get("objects", []) if isinstance(parsed, dict) else []
|
||||||
|
return {"version": 1, "objects": objects if isinstance(objects, list) else []}
|
||||||
|
except (FileNotFoundError, OSError, json.JSONDecodeError):
|
||||||
|
return {"version": 1, "objects": []}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/save-map")
|
||||||
|
def save_map(payload: SaveMapPayload):
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
try:
|
||||||
|
return {"ok": True, "map": slam_node.save_map(payload.name)}
|
||||||
|
except ValueError as error:
|
||||||
|
raise HTTPException(status_code=409, detail=str(error)) from error
|
||||||
|
|
||||||
|
|
||||||
@app.get("/debug/topics")
|
@app.get("/debug/topics")
|
||||||
def debug_topics():
|
def debug_topics():
|
||||||
if not slam_node:
|
if not slam_node:
|
||||||
@@ -430,7 +964,10 @@ def cmd_vel(payload: VelocityPayload):
|
|||||||
def stop():
|
def stop():
|
||||||
if not slam_node:
|
if not slam_node:
|
||||||
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
slam_node.publish_cmd_vel(0.0, 0.0)
|
try:
|
||||||
|
slam_node.publish_cmd_vel(0.0, 0.0)
|
||||||
|
except RuntimeError as error:
|
||||||
|
raise HTTPException(status_code=403, detail=str(error)) from error
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@@ -445,6 +982,25 @@ def navigate_to_pose(payload: NavigationGoalPayload):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/cancel_navigation")
|
||||||
|
def cancel_navigation():
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
if not slam_node.cancel_navigation(wait=True):
|
||||||
|
raise HTTPException(status_code=504, detail="기존 자율주행 목표 취소 시간이 초과되었습니다.")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/initialpose")
|
||||||
|
def initial_pose(payload: InitialPosePayload):
|
||||||
|
if not slam_node:
|
||||||
|
raise HTTPException(status_code=503, detail="ROS node is not ready")
|
||||||
|
if slam_node.read_only:
|
||||||
|
raise HTTPException(status_code=403, detail="읽기 전용 3D SLAM 화면에서는 초기 위치를 설정할 수 없습니다.")
|
||||||
|
slam_node.publish_initial_pose(payload.x, payload.y, payload.yaw, payload.frame_id)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.websocket("/ws/state")
|
@app.websocket("/ws/state")
|
||||||
async def ws_state(websocket: WebSocket):
|
async def ws_state(websocket: WebSocket):
|
||||||
await websocket.accept()
|
await websocket.accept()
|
||||||
@@ -468,6 +1024,29 @@ async def ws_state(websocket: WebSocket):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@app.websocket("/ws/control")
|
||||||
|
async def ws_control(websocket: WebSocket):
|
||||||
|
await websocket.accept()
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
payload = await websocket.receive_json()
|
||||||
|
if not slam_node or payload.get("type") != "cmd_vel":
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
velocity = VelocityPayload(
|
||||||
|
linear=payload.get("linear", 0.0),
|
||||||
|
angular=payload.get("angular", 0.0),
|
||||||
|
)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
slam_node.publish_cmd_vel(velocity.linear, velocity.angular)
|
||||||
|
except RuntimeError:
|
||||||
|
continue
|
||||||
|
except WebSocketDisconnect:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
uvicorn.run(
|
uvicorn.run(
|
||||||
app,
|
app,
|
||||||
|
|||||||
+965
-87
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -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.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
+152
@@ -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
+713
-71
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,
|
||||||
|
|||||||
@@ -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="연결종료"
|
||||||
|
|||||||
Vendored
+33
-1
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{navigationMapSelected ? (
|
||||||
|
<span className="text-[11px] font-bold text-white/72">주행</span>
|
||||||
|
) : selectableStacks.length > 1 ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{selectableStacks.map((stack) => (
|
||||||
|
<button
|
||||||
|
key={stack}
|
||||||
|
type="button"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onStackChange(stack)}
|
||||||
|
className={[
|
||||||
|
'h-7 rounded-md px-2.5 text-[11px] font-bold transition',
|
||||||
|
stack === activeStack
|
||||||
|
? 'bg-white/14 text-white'
|
||||||
|
: 'text-white/52 hover:bg-white/8 hover:text-white/80',
|
||||||
|
busy ? 'cursor-default opacity-55' : 'cursor-pointer'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{stackLabel(stack)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : <span />}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{stacks.map((stack) => (
|
{running ? (
|
||||||
<button
|
<button
|
||||||
key={stack}
|
|
||||||
type="button"
|
type="button"
|
||||||
disabled={running || busy}
|
disabled={busy || !stacks.length}
|
||||||
onClick={() => onStackChange(stack)}
|
onClick={onStop}
|
||||||
className={[
|
className={[
|
||||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black uppercase transition',
|
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
|
||||||
activeStack === stack ? 'bg-[#2563eb] text-white' : 'bg-white/8 text-white/58 hover:bg-white/12 hover:text-white',
|
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
||||||
running || busy ? 'cursor-default opacity-70' : ''
|
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
>
|
>
|
||||||
{stack === 'nav2' ? 'Nav2' : stack === 'slam' ? 'SLAM' : 'Autoware'}
|
{busy ? '처리 중' : '종료'}
|
||||||
|
</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>
|
</button>
|
||||||
))}
|
|
||||||
{!stacks.length && (
|
|
||||||
<span className="text-[11px] font-bold text-white/48">지원되는 시각화 없음</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={busy || !stacks.length}
|
|
||||||
onClick={() => {
|
|
||||||
if (running) onStop()
|
|
||||||
else onStart()
|
|
||||||
}}
|
|
||||||
className={[
|
|
||||||
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
|
|
||||||
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
|
|
||||||
busy || !stacks.length ? 'cursor-default opacity-60' : ''
|
|
||||||
].join(' ')}
|
|
||||||
>
|
|
||||||
{busy ? '처리 중' : running ? '종료' : '시작'}
|
|
||||||
</button>
|
|
||||||
</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,73 +37,67 @@ 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)
|
||||||
|
|
||||||
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
|
// A remote install resets build/install/log. Reinstall only when the module is absent.
|
||||||
method: 'POST',
|
if (module.state === 'uninstalled') {
|
||||||
headers: {
|
const requestAccessToken = await getFreshAccessToken()
|
||||||
Authorization: `Bearer ${requestAccessToken}`,
|
const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, {
|
||||||
'Content-Type': 'application/json'
|
method: 'POST',
|
||||||
},
|
headers: {
|
||||||
body: JSON.stringify({
|
Authorization: `Bearer ${requestAccessToken}`,
|
||||||
courseId: courseKey,
|
'Content-Type': 'application/json'
|
||||||
week: weekNumber,
|
},
|
||||||
moduleName: module.name,
|
body: JSON.stringify({
|
||||||
folderName: module.folderName,
|
courseId: courseKey,
|
||||||
templateRepositoryUrl: module.templateRepositoryUrl
|
week: weekNumber,
|
||||||
|
moduleName: module.name,
|
||||||
|
folderName: module.folderName,
|
||||||
|
templateRepositoryUrl: module.templateRepositoryUrl
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
if (!response.ok) throw new Error(await response.text())
|
||||||
if (!response.ok) throw new Error(await response.text())
|
const prepared = await response.json() as {
|
||||||
const prepared = await response.json() as {
|
repositoryUrlWithToken?: string
|
||||||
repositoryUrlWithToken?: string
|
templateRepositoryUrlWithToken?: string
|
||||||
templateRepositoryUrlWithToken?: string
|
templateRepositoryUrl?: string
|
||||||
templateRepositoryUrl?: string
|
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
|
|
||||||
await window.remoteWorkspace.modules.install(
|
|
||||||
getRemoteWorkspaceTarget(),
|
|
||||||
module.name,
|
|
||||||
module.folderName,
|
|
||||||
templateRepositoryUrl,
|
|
||||||
prepared.repositoryUrlWithToken,
|
|
||||||
{
|
|
||||||
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
|
|
||||||
runtimeImages: activeRuntimeImages
|
|
||||||
}
|
}
|
||||||
)
|
|
||||||
|
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
|
||||||
|
await window.remoteWorkspace.modules.install(
|
||||||
|
getRemoteWorkspaceTarget(),
|
||||||
|
module.name,
|
||||||
|
module.folderName,
|
||||||
|
templateRepositoryUrl,
|
||||||
|
prepared.repositoryUrlWithToken,
|
||||||
|
{
|
||||||
|
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
|
||||||
|
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 {
|
||||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
|
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)
|
||||||
|
}
|
||||||
} 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 {
|
||||||
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
|
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)
|
||||||
|
}
|
||||||
} 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
|
||||||
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
|
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)
|
||||||
|
}
|
||||||
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 {
|
||||||
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
|
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)
|
||||||
|
}
|
||||||
} 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 {
|
||||||
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
|
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)
|
||||||
|
}
|
||||||
} 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 {
|
||||||
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
|
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)
|
||||||
|
}
|
||||||
} 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(),
|
||||||
if (!result.ok) throw new Error(result.message)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
if (!result.ok) throw new Error(result.message)
|
if (!result.ok) throw new Error(result.message)
|
||||||
|
} else {
|
||||||
|
if (targets.length) {
|
||||||
|
const result = await window.localWorkspace.modules.stopMany(
|
||||||
|
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||||
|
clonePathPreview
|
||||||
|
)
|
||||||
|
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,61 +308,142 @@ 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 runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
|
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
|
||||||
if (installingModuleName || controllingModuleNames.includes(module.name)) return
|
if (installingModuleName) return false
|
||||||
|
const targets = availableModules.filter((module) => module.state === 'running')
|
||||||
|
if (!targets.length) return true
|
||||||
|
|
||||||
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
setInstallingModuleName('__restart_all__')
|
||||||
setModuleInstallError(null)
|
setModuleInstallError(null)
|
||||||
try {
|
try {
|
||||||
const result = action === 'stop'
|
if (remoteWorkspace) {
|
||||||
? remoteWorkspace
|
const result = await window.remoteWorkspace.modules.stopMany(
|
||||||
? await remoteModuleStop(module)
|
getRemoteWorkspaceTarget(),
|
||||||
: await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName)
|
[...targets]
|
||||||
: action === 'build'
|
.sort(compareModuleShutdownPriority)
|
||||||
? remoteWorkspace
|
.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||||
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
)
|
||||||
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
if (!result.ok) throw new Error(result.message)
|
||||||
: action === 'restart'
|
} else {
|
||||||
? remoteWorkspace
|
const result = await window.localWorkspace.modules.stopMany(
|
||||||
? await remoteModuleRestart(module)
|
[...targets]
|
||||||
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
.sort(compareModuleShutdownPriority)
|
||||||
: remoteWorkspace
|
.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||||
? await remoteModuleStart(module)
|
clonePathPreview
|
||||||
: 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)
|
||||||
|
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 })
|
await loadAvailableModules({ silent: true })
|
||||||
const actionLabel = action === 'stop'
|
onTitlebarNotice('실행 중인 모듈이 전체 재시작되었습니다', 'info')
|
||||||
? '종료되었습니다'
|
return true
|
||||||
: action === 'build'
|
|
||||||
? '빌드되었습니다'
|
|
||||||
: action === 'restart'
|
|
||||||
? '재시작되었습니다'
|
|
||||||
: '실행되었습니다'
|
|
||||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[modules] ${action} failed`, error)
|
console.error('[modules] restart running modules failed', error)
|
||||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
setModuleInstallError(error instanceof Error ? error.message : '모듈 전체 재시작 실패')
|
||||||
const actionLabel = action === 'stop'
|
onTitlebarNotice('모듈 전체 재시작에 실패했습니다', 'error')
|
||||||
? '종료 실패'
|
|
||||||
: action === 'build'
|
|
||||||
? '빌드 실패'
|
|
||||||
: action === 'restart'
|
|
||||||
? '재시작 실패'
|
|
||||||
: '실행 실패'
|
|
||||||
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
|
||||||
await loadAvailableModules({ silent: true })
|
await loadAvailableModules({ silent: true })
|
||||||
|
return false
|
||||||
} finally {
|
} finally {
|
||||||
setControllingModuleNames((names) => names.filter((name) => name !== module.name))
|
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> => {
|
||||||
|
if (installingModuleName || queuedModuleNamesRef.current.has(module.name)) return
|
||||||
|
|
||||||
|
queuedModuleNamesRef.current.add(module.name)
|
||||||
|
setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name]))
|
||||||
|
setModuleInstallError(null)
|
||||||
|
const execute = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const result = action === 'stop'
|
||||||
|
? remoteWorkspace
|
||||||
|
? await remoteModuleStop(module)
|
||||||
|
: await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName)
|
||||||
|
: action === 'build'
|
||||||
|
? remoteWorkspace
|
||||||
|
? await window.remoteWorkspace.modules.build(getRemoteWorkspaceTarget(), module.name, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||||
|
: await window.localWorkspace.modules.build(module.name, clonePathPreview, module.folderName, sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||||
|
: action === 'restart'
|
||||||
|
? remoteWorkspace
|
||||||
|
? await remoteModuleRestart(module)
|
||||||
|
: await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
|
||||||
|
: remoteWorkspace
|
||||||
|
? await remoteModuleStart(module)
|
||||||
|
: 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 (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 })
|
||||||
|
const actionLabel = action === 'stop'
|
||||||
|
? '종료되었습니다'
|
||||||
|
: action === 'build'
|
||||||
|
? '빌드되었습니다'
|
||||||
|
: action === 'restart'
|
||||||
|
? '재시작되었습니다'
|
||||||
|
: '실행되었습니다'
|
||||||
|
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info')
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[modules] ${action} failed`, error)
|
||||||
|
setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패')
|
||||||
|
const actionLabel = action === 'stop'
|
||||||
|
? '종료 실패'
|
||||||
|
: action === 'build'
|
||||||
|
? '빌드 실패'
|
||||||
|
: action === 'restart'
|
||||||
|
? '재시작 실패'
|
||||||
|
: '실행 실패'
|
||||||
|
onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error')
|
||||||
|
await loadAvailableModules({ silent: true })
|
||||||
|
} finally {
|
||||||
|
queuedModuleNamesRef.current.delete(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) => {
|
setModuleListError(null)
|
||||||
const remoteModule = remoteStateByName.get(module.name)
|
setAvailableModules(applyPendingModuleStates(applyOverrides(result.modules).sort(compareModulePriority)))
|
||||||
return remoteModule?.state === 'running'
|
return
|
||||||
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
|
|
||||||
: module
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const result = await window.localWorkspace.modules.list(clonePathPreview, currentCurriculumModules ?? [])
|
||||||
|
if (requestSequence !== loadSequenceRef.current) return
|
||||||
setModuleListError(null)
|
setModuleListError(null)
|
||||||
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority))
|
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
|
||||||
ok: false,
|
? remoteTarget?.host
|
||||||
running: false,
|
? await window.remoteWorkspace.visualization.status(remoteTarget).catch((error) => ({
|
||||||
stack: null,
|
ok: false,
|
||||||
message: error instanceof Error ? error.message : String(error)
|
running: false,
|
||||||
}))
|
stack: null,
|
||||||
setVisualizationRunning(Boolean(status.running))
|
message: error instanceof Error ? error.message : String(error),
|
||||||
if (status.stack) setVisualizationStack(status.stack)
|
}))
|
||||||
|
: { ok: false, running: false, stack: null, message: '원격 플랫폼 SSH 주소를 확인하지 못했습니다.' }
|
||||||
|
: await window.localWorkspace.visualization.status().catch((error) => ({
|
||||||
|
ok: false,
|
||||||
|
running: false,
|
||||||
|
stack: null,
|
||||||
|
message: error instanceof Error ? error.message : String(error)
|
||||||
|
}))
|
||||||
|
const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam'
|
||||||
|
? status.stack
|
||||||
|
: null
|
||||||
|
const statusStack = rawStatusStack === 'nav2' && visualizationStack === 'slam3d' ? 'slam3d' : rawStatusStack
|
||||||
|
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack && visualizationStacks.includes(statusStack)))
|
||||||
|
if (statusStack && visualizationStacks.includes(statusStack)) setVisualizationStack(statusStack)
|
||||||
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: '-'
|
||||||
|
|||||||
Binary file not shown.
@@ -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()
|
||||||
Reference in New Issue
Block a user