From c010d0c913e59b9041e7586c996dac8f68b0ea75 Mon Sep 17 00:00:00 2001 From: chyoo Date: Wed, 12 Aug 2026 14:46:39 +0900 Subject: [PATCH] add commits.. --- .env.example | 4 +- resources/nav2-dev-image/Dockerfile | 41 ---- resources/nav2-dev-image/README.md | 34 ---- resources/nav2-dev-image/build.sh | 4 - resources/nav2-dev-image/docker-entrypoint.sh | 12 -- src/main/index.ts | 182 +++++++++++------- src/renderer/public/slam-viewer/index.html | 114 ++++++----- src/renderer/src/App.tsx | 127 +++++++----- src/renderer/src/env.d.ts | 4 + .../src/features/workspace/ModuleListRow.tsx | 56 +++--- .../workspace/WorkspaceSettingsView.tsx | 5 +- .../features/workspace/WorldEditorModal.tsx | 33 +++- .../workspace/WorldMapSelectorModal.tsx | 33 +++- .../workspace/useWorkspaceModuleControl.ts | 48 ++++- .../workspace/useWorkspaceVisualization.ts | 27 +-- 15 files changed, 422 insertions(+), 302 deletions(-) delete mode 100644 resources/nav2-dev-image/Dockerfile delete mode 100644 resources/nav2-dev-image/README.md delete mode 100755 resources/nav2-dev-image/build.sh delete mode 100755 resources/nav2-dev-image/docker-entrypoint.sh diff --git a/.env.example b/.env.example index d674863..5c70058 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,9 @@ VITE_LAUNCHER_API_BASE_URL=http://localhost:4200/api # Electron main process runtime overrides LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge LOCAL_NAV2_BRIDGE_IMAGE=nav2-ros-bridge -LOCAL_NAV2_RUNTIME_IMAGE=nav2-slam:humble +LOCAL_NAV2_RUNTIME_IMAGE=future-nav2-gazebo:humble LOCAL_SLAM_BRIDGE_IMAGE=slam-ros-bridge -LOCAL_SLAM_RUNTIME_IMAGE=nav2-slam:humble +LOCAL_SLAM_RUNTIME_IMAGE=future-nav2-gazebo:humble # Remote Jetson runtime overrides JETSON_AGENT_URL=http://100.64.0.2:8787 diff --git a/resources/nav2-dev-image/Dockerfile b/resources/nav2-dev-image/Dockerfile deleted file mode 100644 index 806ed5d..0000000 --- a/resources/nav2-dev-image/Dockerfile +++ /dev/null @@ -1,41 +0,0 @@ -FROM future-nav2:humble - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -ENV DEBIAN_FRONTEND=noninteractive \ - ROS_DISTRO=humble \ - TURTLEBOT3_MODEL=burger \ - NAV2_WORKSPACE=/workspace/nav2-slam_ws - -# Nav2 and SLAM Toolbox are provided by the base image. Module workspaces may -# override those packages with local sources, while Gazebo and TurtleBot3 are -# added here for the source-based simulator modules. -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - build-essential \ - cmake \ - git \ - python3-argcomplete \ - python3-colcon-common-extensions \ - python3-rosdep \ - python3-vcstool \ - python3-pip \ - ros-humble-gazebo-plugins \ - ros-humble-gazebo-ros-pkgs \ - ros-humble-turtlebot3-description \ - ros-humble-turtlebot3-gazebo \ - ros-humble-turtlebot3-msgs \ - ros-humble-xacro \ - ros-humble-tf-transformations \ - && rm -rf /var/lib/apt/lists/* \ - && rosdep init 2>/dev/null || true - -RUN rosdep update - -COPY docker-entrypoint.sh /usr/local/bin/studio-nav2-entrypoint -RUN chmod +x /usr/local/bin/studio-nav2-entrypoint \ - && mkdir -p "${NAV2_WORKSPACE}/src" - -WORKDIR /workspace -ENTRYPOINT ["/usr/local/bin/studio-nav2-entrypoint"] -CMD ["bash"] diff --git a/resources/nav2-dev-image/README.md b/resources/nav2-dev-image/README.md deleted file mode 100644 index 3ed50dc..0000000 --- a/resources/nav2-dev-image/README.md +++ /dev/null @@ -1,34 +0,0 @@ -# Nav2 Development Image - -`nav2-dev:humble` is the source-build runtime for Nav2 learning modules. It -contains ROS 2 Humble, Gazebo, colcon, and rosdep, but intentionally does not -contain Nav2, TurtleBot3, or SLAM Toolbox source code. - -Build it from this directory: - -```bash -./build.sh -``` - -Mount a composed module workspace and build its sources: - -```bash -docker run --rm -it --network host \ - -e DISPLAY="$DISPLAY" \ - -e TURTLEBOT3_MODEL=burger \ - -v /tmp/.X11-unix:/tmp/.X11-unix \ - -v "$HOME/autonomy-studio/workspaces/nav2-slam_ws:/workspace/nav2-slam_ws" \ - nav2-dev:humble bash - -cd "$NAV2_WORKSPACE" -rosdep install --from-paths src --ignore-src -r -y -colcon build -``` - -The composed workspace must contain all direct source dependencies. For Nav2, -include its shared packages such as `nav2_common`, `nav2_util`, and `nav2_msgs` -alongside the module packages being built. - -On later container starts, the entrypoint automatically sources -`/workspace/nav2-slam_ws/install/setup.bash`, so source-built packages override -the base ROS installation. diff --git a/resources/nav2-dev-image/build.sh b/resources/nav2-dev-image/build.sh deleted file mode 100755 index 2fed559..0000000 --- a/resources/nav2-dev-image/build.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -docker build -t nav2-dev:humble "$(cd "$(dirname "$0")" && pwd)" diff --git a/resources/nav2-dev-image/docker-entrypoint.sh b/resources/nav2-dev-image/docker-entrypoint.sh deleted file mode 100755 index 2409557..0000000 --- a/resources/nav2-dev-image/docker-entrypoint.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash -set -e - -source "/opt/ros/${ROS_DISTRO}/setup.bash" - -# A prior colcon build takes precedence over apt packages when the workspace -# is mounted. This makes source edits immediately available to ROS commands. -if [[ -f "${NAV2_WORKSPACE}/install/setup.bash" ]]; then - source "${NAV2_WORKSPACE}/install/setup.bash" -fi - -exec "$@" diff --git a/src/main/index.ts b/src/main/index.ts index dfb7cd1..3bf2930 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -26,10 +26,10 @@ const moduleCacheRoot = resolve(homedir(), '.caches') const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' -const nav2DockerImage = 'nav2-dev:humble' +const nav2DockerImage = 'future-nav2-gazebo:humble' const autowareRosBridgeImage = 'autoware-ros-bridge' const slamRosBridgeImage = 'slam-ros-bridge' -const slamSimulationRuntimeImage = 'nav2-slam:humble' +const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble' const slamSimulationRuntimeContainerName = 'nav2-slam' const rosBridgePort = 8765 const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code') @@ -199,6 +199,14 @@ type RuntimeImageArtifact = { required?: boolean | null } +const defaultNav2GazeboRuntimeImage: RuntimeImageArtifact = { + image: 'future-nav2-gazebo:humble', + url: 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/gazebo/latest/future-nav2-gazebo-humble.tar.zst', + fileName: 'future-nav2-gazebo-humble.tar.zst', + checksumSha256: '54230e039c1fa5dc82615cef3828c5acae7b8bea8e921b70cfdc5351d8301644', + required: true, +} + type ModuleInstallOptions = { prebuiltArtifacts?: ModulePrebuiltArtifact[] | null runtimeImages?: RuntimeImageArtifact[] | null @@ -236,6 +244,10 @@ type WorldLayoutObject = { type WorldLayout = { version: 1 + bounds: { + width: number + depth: number + } objects: WorldLayoutObject[] } @@ -255,6 +267,7 @@ type SavedMapArtifactDownload = { function defaultWorldLayout(): WorldLayout { return { version: 1, + bounds: { width: 10, depth: 10 }, objects: [] } } @@ -278,7 +291,14 @@ function normalizeWorldLayout(value: unknown): WorldLayout { size: tuple(object.size, [1, 1, 1]) }] }).filter((object) => !['wall-1', 'pillar-1'].includes(object.id)) - return { version: 1, objects } + const boundsInput = (value as { bounds?: unknown }).bounds + const bounds = boundsInput && typeof boundsInput === 'object' + ? boundsInput as { width?: unknown; depth?: unknown } + : {} + const size = (input: unknown, fallback: number): number => ( + typeof input === 'number' && Number.isFinite(input) ? Math.min(40, Math.max(2, input)) : fallback + ) + return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects } } function worldLayoutPath(workspaceRoot?: string | null): string { @@ -766,6 +786,17 @@ function resolveNav2RuntimeImage( return nav2Image || nav2DockerImage } +function requiredRuntimeImages( + runtimeImages: RuntimeImageArtifact[] | null | undefined, + runtimeImage: string, +): RuntimeImageArtifact[] { + const images = (runtimeImages ?? []).filter((image) => image?.image?.trim()) + if (runtimeImage !== nav2DockerImage || images.some((image) => image.image?.trim() === runtimeImage)) { + return images + } + return [...images, defaultNav2GazeboRuntimeImage] +} + async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise { try { const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown } @@ -1153,7 +1184,11 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null, const targetPath = artifactCachePath(artifactUrl, fileName) const expected = checksumSha256?.trim().toLowerCase() - if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath) + // Module artifact URLs are intentionally stable (for example, `latest`). + // Do not reuse a same-named local file, or a source update can run with an + // older install tree even though R2 already contains the current artifact. + await rm(targetPath, { force: true }) + await downloadArtifact(artifactUrl, targetPath) if (expected) { let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase() if (actual !== expected) { @@ -2507,7 +2542,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string { function normalizeVisualizationStack(value?: string | null): VisualizationStack { if (value === 'slam') return 'slam' if (value === 'slam3d') return 'slam3d' - return value === 'nav2' ? 'nav2' : 'autoware' + return value === 'nav2' || value === 'local' || value === 'platform' ? 'nav2' : 'autoware' } async function readContainerEnvironmentValue(containerName: string, key: string): Promise { @@ -2722,10 +2757,7 @@ function withRuntimeProfileDefaults( if (!profile) return profile const target = normalizeRuntimeTarget(profile.target) const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}` - const dedicatedReadOnlyBridge = profile.type?.toLowerCase() === 'real_slam_3d' - const bridgeImage = dedicatedReadOnlyBridge - ? profile.bridge?.image - : envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image + const bridgeImage = envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`) const agentUrl = target === 'remote-jetson' ? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url @@ -2790,7 +2822,7 @@ async function startVisualizationBridge( rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null, ): Promise { - const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput) + const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase()) const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack) const target = normalizeRuntimeTarget(resolvedProfile?.target) const bridge = resolvedProfile?.bridge @@ -2869,7 +2901,7 @@ async function startVisualizationBridge( '-e', `ROS_DOMAIN_ID=${requestedRosDomainId}`, '-e', - 'ROS_LOCALHOST_ONLY=0', + `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`, '-e', `AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`, '-e', @@ -3150,8 +3182,9 @@ async function installRemoteWorkspaceModule( ' if not url: continue', ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"', ' path = os.path.join(cache, name)', - ' if not os.path.exists(path):', - ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', + ' # Stable latest URLs must not reuse a same-named local artifact.', + ' if os.path.exists(path): os.unlink(path)', + ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', ' expected = (item.get("checksumSha256") or "").strip().lower()', ' if expected:', ' import hashlib', @@ -3531,6 +3564,7 @@ function buildNav2DockerRunScript(): string { 'GPU_ENABLED=${6:-false}', 'shift 6 || true', 'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}', + 'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}', 'LOCALIZATION_MODE=slam', 'filtered_args=()', 'for arg in "$@"; do', @@ -3591,6 +3625,7 @@ function buildNav2DockerRunScript(): string { ' --rm -d --network host --ipc=host', ' --name "$CONTAINER_NAME"', ' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"', + ' -e "ROS_LOCALHOST_ONLY=$ROS_LOCALHOST_ONLY_VALUE"', ' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"', ' -e "MODULE_ROOT=/modules"', ' -e "MODULE_FOLDER=$MODULE_FOLDER"', @@ -3603,7 +3638,7 @@ function buildNav2DockerRunScript(): string { ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', 'fi', 'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then', - ' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e ROS_LOCALHOST_ONLY=0)', + ' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml")', 'fi', 'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi', 'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then', @@ -3999,7 +4034,6 @@ async function startWorkspaceModule( const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') - await prepareRuntimeImages(runtimeImages) if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId) @@ -4009,6 +4043,7 @@ async function startWorkspaceModule( moduleRoot, resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), ) + await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage)) await runCommand( 'bash', [ @@ -4022,12 +4057,16 @@ async function startWorkspaceModule( ...splitRosParams(rosParams) ], undefined, - fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : undefined, + { + ROS_LOCALHOST_ONLY: rosNetworkEnabled(rosNetwork) ? '0' : '1', + ...(fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : {}), + }, 30000 ) return { ok: true, message: '실행 요청을 보냈습니다.' } } + await prepareRuntimeImages(runtimeImages) await ensureStudioRuntimeAssets() const scriptPath = resolveAutowareDockerRunScript() if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) @@ -4082,7 +4121,6 @@ async function buildWorkspaceModule( const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') - await prepareRuntimeImages(runtimeImages) if (isNav2ModuleFolder(targetFolderName)) { await ensureNav2RuntimeAssets() const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot) @@ -4090,6 +4128,7 @@ async function buildWorkspaceModule( moduleRoot, resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams), ) + await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage)) await runCommand( 'bash', [ @@ -4106,6 +4145,7 @@ async function buildWorkspaceModule( return { ok: true, message: '빌드가 완료되었습니다.' } } + await prepareRuntimeImages(runtimeImages) await runCommand( 'docker', [ @@ -4124,6 +4164,62 @@ async function buildWorkspaceModule( return { ok: true, message: '빌드가 완료되었습니다.' } } +async function prepareRemoteRuntimeImages( + target: RemoteWorkspaceTarget, + runtimeImages?: RuntimeImageArtifact[] | null, +): Promise { + const artifacts = (runtimeImages ?? []) + .filter((image) => image?.image?.trim()) + .map((image) => ({ + image: image.image?.trim() ?? '', + url: image.url?.trim() ?? '', + fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '', + checksumSha256: image.checksumSha256?.trim() ?? '', + required: Boolean(image.required), + })) + const artifactsJson = JSON.stringify(artifacts) + + await runSsh( + target, + [ + 'set -e', + `RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(artifactsJson))} | base64 -d)"`, + 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', + 'mkdir -p "$ARTIFACT_CACHE"', + 'LOCK_FILE="$ARTIFACT_CACHE/runtime-images.lock"', + 'flock "$LOCK_FILE" python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'', + 'import json, os, subprocess, sys', + 'images = json.loads(sys.argv[1] or "[]")', + 'cache = sys.argv[2]', + 'for item in images:', + ' image = (item.get("image") or "").strip()', + ' if not image: continue', + ' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0', + ' if exists: continue', + ' url = (item.get("url") or "").strip()', + ' if not url:', + ' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")', + ' continue', + ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"', + ' path = os.path.join(cache, name)', + ' if os.path.exists(path): os.unlink(path)', + ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', + ' expected = (item.get("checksumSha256") or "").strip().lower()', + ' if expected:', + ' import hashlib', + ' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', + ' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")', + ' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):', + ' import shlex', + ' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', + ' else:', + ' subprocess.run(["docker", "load", "-i", path], check=True)', + 'PY' + ].join('\n'), + 900000, + ) +} + async function startRemoteWorkspaceModule( targetInput: RemoteWorkspaceTarget, moduleName: string, @@ -4140,17 +4236,6 @@ async function startRemoteWorkspaceModule( const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') - const runtimeImageArtifacts = (runtimeImages ?? []) - .filter((image) => image?.image?.trim()) - .map((image) => ({ - image: image.image?.trim() ?? '', - url: image.url?.trim() ?? '', - fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '', - checksumSha256: image.checksumSha256?.trim() ?? '', - required: Boolean(image.required), - })) - const runtimeImageJson = JSON.stringify(runtimeImageArtifacts) - const existsOutput = await runSshOutput(target, [ `ROOT=${quoteShellSingle(target.workspaceRoot)}`, ...expandRemotePathVar('ROOT'), @@ -4172,43 +4257,7 @@ async function startRemoteWorkspaceModule( ? await ensureRemoteNav2RuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target) const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) - await runSsh( - target, - [ - 'set -e', - `RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(runtimeImageJson))} | base64 -d)"`, - 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', - 'mkdir -p "$ARTIFACT_CACHE"', - 'python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'', - 'import json, os, subprocess, sys', - 'images = json.loads(sys.argv[1] or "[]")', - 'cache = sys.argv[2]', - 'for item in images:', - ' image = (item.get("image") or "").strip()', - ' if not image: continue', - ' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0', - ' if exists: continue', - ' url = (item.get("url") or "").strip()', - ' if not url:', - ' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")', - ' continue', - ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"', - ' path = os.path.join(cache, name)', - ' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)', - ' expected = (item.get("checksumSha256") or "").strip().lower()', - ' if expected:', - ' import hashlib', - ' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', - ' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")', - ' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):', - ' import shlex', - ' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', - ' else:', - ' subprocess.run(["docker", "load", "-i", path], check=True)', - 'PY' - ].join('\n'), - 900000 - ) + await prepareRemoteRuntimeImages(target, runtimeImages) const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (currentStatus.trim()) { @@ -4257,9 +4306,11 @@ async function startRemoteWorkspaceModule( ...expandRemotePathVar('ROOT'), `PARAMS=${quoteShellSingle(remoteRosParams)}`, `FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`, + `ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`, ...expandRemotePathVar('FASTDDS_CONFIG_PATH'), 'read -r -a EXTRA_ARGS <<< "$PARAMS"', 'export FASTDDS_CONFIG_PATH', + 'export ROS_LOCALHOST_ONLY', `bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} "\${EXTRA_ARGS[@]}"` ].join('\n') : [ @@ -4298,6 +4349,7 @@ async function buildRemoteWorkspaceModule( targetFolderName, await resolveRemoteNav2RuntimeImage(runtimeImages), ) + await prepareRemoteRuntimeImages(target, runtimeImages) await runSsh(target, [ 'set -e', `RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`, diff --git a/src/renderer/public/slam-viewer/index.html b/src/renderer/public/slam-viewer/index.html index 43ddb73..e2ab27c 100644 --- a/src/renderer/public/slam-viewer/index.html +++ b/src/renderer/public/slam-viewer/index.html @@ -389,7 +389,7 @@ display:none; } #splatToggle { display:none; } - .nav2-slam #goalToggle, .nav2-slam #poseToggle, .nav2-slam #sensorToggle { display:none; } + .nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; } .nav2-navigation #downloadWrap { display:none; } .nav2-navigation #topLeftControls { display:block; } .nav2-navigation #keyGuide { display:none; } @@ -473,6 +473,7 @@ const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase(); // Keep the prior real-platform default when an older LMS URL has no platform. const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME); const SHOW_POINT_CLOUD = params.get('pointCloud') === '1'; +const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD; const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000; // Camera preview is kept for the RGB test module, but is paused for now so // regular visualization sessions never poll `/camera-frame`. @@ -480,8 +481,10 @@ const ENABLE_CAMERA_PREVIEW = false; const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom'; const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world'; const MKMINI_WORLD_SPACING_SCALE = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world' ? 1.6 : 1; -const MKMINI_RENDER_LENGTH = 0.855; -const MKMINI_WHEELBASE_METERS = 0.325; +const MKMINI_RENDER_LENGTH = 0.84; +const MKMINI_RENDER_WIDTH = 0.61; +const MKMINI_RENDER_HEIGHT = 0.6575; +const MKMINI_WHEELBASE_METERS = 0.6; const MKMINI_MAX_STEERING_RADIANS = 0.52; const DEFAULT_SIMULATION_SPAWN = USE_MKMINI_MODEL ? { x: -1.4, y: -0.5 } @@ -648,6 +651,8 @@ gazeboWorldRoot.name = 'gazebo-world'; sdfRoot.add(gazeboWorldRoot); const editorObjects = new THREE.Group(); sdfRoot.add(editorObjects); +const editorBoundaryObjects = new THREE.Group(); +sdfRoot.add(editorBoundaryObjects); const runtimeLayoutObjects = new THREE.Group(); gazeboWorldRoot.add(runtimeLayoutObjects); const navigationMapRoot = new THREE.Group(); @@ -665,9 +670,9 @@ const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.67 const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null; let pointCloudLayer = null; let hasGlobalMapPointCloud = false; -// A real platform can provide a 3D LiDAR stream even when this viewer was -// opened through a legacy NAV2 stack URL without pointCloud=1. -const sensorPointCloudGeometry = REAL_RUNTIME ? new THREE.BufferGeometry() : null; +// The live sensor point cloud belongs to the 3D view. 2D SLAM renders only +// LaserScan and must never poll the point-cloud endpoint. +const sensorPointCloudGeometry = SHOW_SENSOR_POINT_CLOUD ? new THREE.BufferGeometry() : null; let sensorPointCloudLayer = null; if (pointCloudGeometry) { pointCloudLayer = new THREE.Points( @@ -833,8 +838,9 @@ function createLayoutMesh(item, editor = false) { function renderEditorLayout(layout) { if (!EDITOR_MODE) return; - editorLayout = layout && Array.isArray(layout.objects) ? layout : { version:1, objects:[] }; + editorLayout = layout && Array.isArray(layout.objects) ? layout : { version:1, bounds:{ width:10, depth:10 }, objects:[] }; clearLayoutObjects(editorObjects); + clearLayoutObjects(editorBoundaryObjects); for (const item of editorLayout.objects) { editorObjects.add(createLayoutMesh(item, true)); } @@ -846,7 +852,7 @@ async function refreshRuntimeLayout() { if (EDITOR_MODE || REAL_RUNTIME || !CUSTOM_WORLD) return; try { const layout = await fetch(`${BACKEND_URL}/world-layout`).then(response => response.ok ? response.json() : { objects: [] }); - const signature = JSON.stringify(layout?.objects || []); + const signature = JSON.stringify({ bounds: layout?.bounds || null, objects: layout?.objects || [] }); if (signature === runtimeLayoutSignature) return; runtimeLayoutSignature = signature; clearLayoutObjects(runtimeLayoutObjects); @@ -884,6 +890,7 @@ let targetMkMiniSteeringAngle = 0; let currentMkMiniSteeringAngle = 0; let targetMkMiniSteeringAngles = { left: 0, right: 0 }; let currentMkMiniSteeringAngles = { left: 0, right: 0 }; +let lastMkMiniSteeringUpdateMs = null; let lastMkMiniMotionSample = null; let hasSimulatorMkMiniSteering = false; let laserGroup = null; @@ -910,10 +917,12 @@ let cameraFrameRequestInFlight = false; let cameraFrameRefreshTimer = null; let cameraFrameObjectUrl = null; let pathVisible = false; -// Sensor data is opt-in in every visualization mode. A saved 3D map is -// rendered independently, so it must not implicitly enable the live lidar. -let navigationSensorVisible = false; -if (sensorPointCloudLayer) sensorPointCloudLayer.visible = false; +// SLAM always renders its live scan, including the forward beam. Navigation +// keeps the sensor opt-in so a saved-map view starts cleanly. +let navigationSensorVisible = NAV2_VIEW_MODE !== 'navigation'; +if (sensorPointCloudLayer) sensorPointCloudLayer.visible = navigationSensorVisible; +sensorToggleButton.classList.toggle('active', navigationSensorVisible); +sensorToggleButton.setAttribute('aria-pressed', String(navigationSensorVisible)); const robotPathPoints = []; let robotPathLine = null; let navigationPlanLine = null; @@ -1183,7 +1192,7 @@ function addCylinder(parent, visual, geometry) { const cyl = directChild(geometry, 'cylinder'); const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1; const sourceLength = Number(getDirectText(cyl, 'length', '1')) || 1; - const length = isMkMiniWorldPillar(visual) && sourceLength === .5 ? .8 : sourceLength; + const length = isMkMiniWorldPillar(visual) && sourceLength === .5 ? 1.2 : sourceLength; const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), materialFromVisual(visual)); // Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다. mesh.rotation.x = Math.PI / 2; @@ -1231,7 +1240,7 @@ async function buildFromSdf() { const visualGroup = new THREE.Group(); visualGroup.name = visual.getAttribute('name') || 'visual'; applyWorldSdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0'))); - if (isMkMiniWorldPillar(visual)) visualGroup.position.z += 0.15; + if (isMkMiniWorldPillar(visual)) visualGroup.position.z = 0.6; linkGroup.add(visualGroup); const geometry = directChild(visual, 'geometry'); @@ -1366,15 +1375,18 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0 model.updateMatrixWorld(true); const initialBounds = new THREE.Box3().setFromObject(model); const initialSize = initialBounds.getSize(new THREE.Vector3()); - const largestDimension = Math.max(initialSize.x, initialSize.y, initialSize.z, 0.001); - // Match the Gazebo MKMini collision body's 1.05 m longest dimension. - model.scale.setScalar(MKMINI_RENDER_LENGTH / largestDimension); + // The GLB is rotated into the SDF coordinate system above. Its local axes + // then map to SDF as X=length, Z=width, Y=height. Normalize every axis to + // the same physical envelope used by the Gazebo collision model. + const scaleX = MKMINI_RENDER_LENGTH / Math.max(initialSize.x, 0.001); + const scaleY = MKMINI_RENDER_HEIGHT / Math.max(initialSize.z, 0.001); + const scaleZ = MKMINI_RENDER_WIDTH / Math.max(initialSize.y, 0.001); + model.scale.set(scaleX, scaleY, scaleZ); model.updateMatrixWorld(true); const normalizedBounds = new THREE.Box3().setFromObject(model); const center = normalizedBounds.getCenter(new THREE.Vector3()); - // This model lives under sdfRoot (Gazebo Z-up). Center it on base_link in - // the horizontal plane and place its base on SDF Z=0. + // Keep the wheels on SDF Z=0 after scaling the vehicle height. model.position.set(-center.x, -center.y, -normalizedBounds.min.z); model.traverse((child) => { if (!child.isMesh) return; @@ -1491,15 +1503,11 @@ async function setRemoteNavigationSplatVisible(visible) { } const LASER_HOLE_R = 0.14; -const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.1 : -0.032; +const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : -0.032; const LASER_OFFSET_Y = 0; -// Calibrated to the MKMini GLB at the current display scale. +// Fallback height when an explicit ROS scan pose is unavailable. const LASER_Z = USE_MKMINI_MODEL ? 0.5 : 0.181; -// The MKMini ROS frame reports os_lidar at Z=0.6 m while the bundled GLB -// places the visible LiDAR head at approximately Z=0.5 m. Keep ROS/map -// coordinates untouched and correct only the rendered scan origin. -const MKMINI_LASER_RENDER_Z_OFFSET = 0.1; -const MKMINI_LASER_RENDER_FORWARD_OFFSET = 0.12; +const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03; function setRobotPose(pose) { if (!pose) return; @@ -1556,7 +1564,6 @@ function applyCurrentRobotPose() { robotModel.visible = shouldRenderRobot(); robotModel.position.set(currentRobotPose.x, currentRobotPose.y, currentRobotPose.z); robotModel.rotation.set(currentRobotPose.roll, currentRobotPose.pitch, currentRobotPose.yaw, 'XYZ'); - updateMkMiniFrontWheelSteering(); } function updateMkMiniSteeringTarget(pose) { @@ -1647,7 +1654,7 @@ function mkMiniAckermannAngles(steeringAngle) { if (Math.abs(steeringAngle) < 0.0001) return { left: 0, right: 0 }; const direction = Math.sign(steeringAngle); const radius = MKMINI_GAZEBO_WHEELBASE_METERS / Math.tan(Math.abs(steeringAngle)); - const halfTrack = 0.293; + const halfTrack = 0.255; const inner = Math.atan(MKMINI_GAZEBO_WHEELBASE_METERS / Math.max(0.01, radius - halfTrack)); const outer = Math.atan(MKMINI_GAZEBO_WHEELBASE_METERS / (radius + halfTrack)); return direction > 0 ? { left: inner, right: outer } : { left: -outer, right: -inner }; @@ -1655,13 +1662,17 @@ function mkMiniAckermannAngles(steeringAngle) { function updateMkMiniFrontWheelSteering() { if (!mkMiniFrontWheelPivots.length) return; - currentMkMiniSteeringAngle += (targetMkMiniSteeringAngle - currentMkMiniSteeringAngle) - * (hasSimulatorMkMiniSteering ? 1 : 0.28); + const now = performance.now(); + const elapsedSeconds = lastMkMiniSteeringUpdateMs === null + ? 0 + : Math.min((now - lastMkMiniSteeringUpdateMs) / 1000, 0.1); + lastMkMiniSteeringUpdateMs = now; + const alpha = 1 - Math.exp(-8 * elapsedSeconds); + currentMkMiniSteeringAngle += (targetMkMiniSteeringAngle - currentMkMiniSteeringAngle) * alpha; for (const { pivot, side } of mkMiniFrontWheelPivots) { // The asset is Z-up before its model-level X rotation, so its local Y axis // becomes the viewer's vertical steering axis. - currentMkMiniSteeringAngles[side] += (targetMkMiniSteeringAngles[side] - currentMkMiniSteeringAngles[side]) - * (hasSimulatorMkMiniSteering ? 1 : 0.28); + currentMkMiniSteeringAngles[side] += (targetMkMiniSteeringAngles[side] - currentMkMiniSteeringAngles[side]) * alpha; pivot.rotation.y = currentMkMiniSteeringAngles[side]; } } @@ -1768,7 +1779,7 @@ function setNavigationSensorVisible(visible) { drawMiniMap(latestSlamState); return; } - if (REAL_RUNTIME) { + if (SHOW_SENSOR_POINT_CLOUD) { void refreshSensorPointCloud(); if (!sensorPointCloudRefreshTimer) { sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500); @@ -1798,9 +1809,8 @@ function drawLaserScan(scan, scanPose = ( const renderPose = USE_MKMINI_MODEL && scanPose ? { ...sourcePose, - x: sourcePose.x + Math.cos(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET, - y: sourcePose.y + Math.sin(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_OFFSET, - z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET, + x: sourcePose.x - Math.cos(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_BACKWARD_OFFSET, + y: sourcePose.y - Math.sin(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_BACKWARD_OFFSET, } : sourcePose; const origin = scanPose @@ -1813,18 +1823,25 @@ function drawLaserScan(scan, scanPose = ( const endpoints = []; const linePoints = []; + const openSpaceLinePoints = []; + const showOpenSpaceRays = NAV2_VIEW_MODE === 'slam'; for (let i = 0; i < scan.ranges.length; i++) { const raw = scan.ranges[i]; const valid = Number.isFinite(raw) && raw >= rangeMin && raw < rangeMax; const d = valid ? Math.min(raw, rangeMax) : rangeMax; const a = angleMin + i * angleInc; - const dir = laserDirectionFromPose(sourcePose, a); + const dir = laserDirectionFromPose(renderPose, a); const startD = Math.min(LASER_HOLE_R, d); const start = origin.clone().addScaledVector(dir, startD); const point = origin.clone().addScaledVector(dir, d); endpoints.push({ start, point, hit: valid }); - // A range-max value represents open space, not an actual reflected beam. - if (valid) linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); + if (valid) { + linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); + } else if (showOpenSpaceRays) { + // Match Gazebo's ray visualization: no-return rays remain visible, but + // deliberately subdued so detected obstacles stay the primary signal. + openSpaceLinePoints.push(start.x, start.y, start.z, point.x, point.y, point.z); + } } const fillPoints = []; @@ -1858,10 +1875,20 @@ function drawLaserScan(scan, scanPose = ( const lineGeo = new THREE.BufferGeometry(); lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePoints, 3)); grp.add(new THREE.LineSegments(lineGeo, lineMat)); + if (openSpaceLinePoints.length) { + const openSpaceGeo = new THREE.BufferGeometry(); + openSpaceGeo.setAttribute('position', new THREE.Float32BufferAttribute(openSpaceLinePoints, 3)); + grp.add(new THREE.LineSegments( + openSpaceGeo, + new THREE.LineBasicMaterial({ color: 0x6f9dff, transparent: true, opacity: 0.28 }), + )); + } const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc))); const front = endpoints[frontIndex]; - if (front?.hit) { + // Keep the forward direction beam even in open space. It is the visual + // heading reference used in the TurtleBot SLAM view, not only a hit marker. + if (front) { const delta = new THREE.Vector3( front.point.x - front.start.x, front.point.y - front.start.y, @@ -1980,7 +2007,7 @@ async function refreshPointCloud() { } async function refreshSensorPointCloud() { - if (!REAL_RUNTIME || !navigationSensorVisible || sensorPointCloudRequestInFlight) return; + if (!SHOW_SENSOR_POINT_CLOUD || !navigationSensorVisible || sensorPointCloudRequestInFlight) return; sensorPointCloudRequestInFlight = true; try { const response = await fetch(`${BACKEND_URL}/sensor-point-cloud`); @@ -3188,7 +3215,7 @@ if (SHOW_POINT_CLOUD) { void refreshPointCloud(); pointCloudRefreshTimer = window.setInterval(() => void refreshPointCloud(), 1000); } -if (REAL_RUNTIME && navigationSensorVisible) { +if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) { void refreshSensorPointCloud(); sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500); } @@ -3223,7 +3250,7 @@ let singleMoveStopTimer = null; const TELEOP_COMMAND_INTERVAL_MS = 120; const SINGLE_MOVE_DURATION_MS = 120; const LATCHED_ROTATION_SPEED_SCALE = 0.25; -const MKMINI_GAZEBO_WHEELBASE_METERS = 0.489; +const MKMINI_GAZEBO_WHEELBASE_METERS = 0.6; const MKMINI_GAZEBO_MAX_STEERING_RADIANS = 0.5; let cmdVelRequestInFlight = false; let queuedCmdVel = null; @@ -3446,6 +3473,7 @@ window.addEventListener('resize', () => { function animate(){ requestAnimationFrame(animate); updateSmoothedRobotPose(); + updateMkMiniFrontWheelSteering(); controls.update(); if (remoteNavigationSplatViewer && remoteNavigationSplatEnabled) { remoteNavigationSplatViewer.update(); diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 8861722..170a402 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -43,7 +43,7 @@ import WorkspaceLogView from './features/workspace/WorkspaceLogView' import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu' import ModuleListView from './features/workspace/ModuleListView' import WorldEditorModal from './features/workspace/WorldEditorModal' -import WorldMapSelectorModal, { WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' +import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal' import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel' import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView' import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView' @@ -85,6 +85,11 @@ type IdeActivity = WorkspaceActivity const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide' +function isRemoteEnvironmentProfile(profile: EnvironmentProfile): boolean { + const target = profile.config?.target + return target === 'remote-jetson' || target === 'platform' +} + function resolveModuleRuntimeImages( profiles: EnvironmentProfile[], remoteWorkspace: boolean, @@ -93,7 +98,7 @@ function resolveModuleRuntimeImages( if (!remoteWorkspace) return activeRuntimeImages const images = profiles - .filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase())) + .filter(isRemoteEnvironmentProfile) .flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : []) .filter((image): image is RuntimeImageArtifact => ( typeof image === 'object' @@ -806,39 +811,56 @@ function LocalPcWorkspaceMockup({ const sessionWorldMaps = useMemo(() => { const configuredMaps = (curriculumWeek.maps ?? []) .filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2') - const baseMaps = [...configuredMaps] - const usesThreeDimensionalSlam = (curriculumWeek.modules ?? []).some((module) => ( - /lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '') - )) - const platformSlamMap: WorkspaceMapOption = { - key: 'nav2-platform-slam', - platformKey: 'NAV2', - name: '새 SLAM', - description: '플랫폼 LiDAR로 새 지도를 생성합니다.', - worldFile: '', - viewerModel: 'turtlebot3_world', - remoteSlam: true, - runtimeKind: usesThreeDimensionalSlam ? 'PLATFORM_3D' : 'PLATFORM_2D', - } - const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps] + const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom') + const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap + ? [...configuredMaps] + : [...configuredMaps, customMap] + const platformSlamMaps: WorkspaceMapOption[] = [ + { + key: 'nav2-platform-slam-2d', + platformKey: 'NAV2', + name: '새 2D SLAM', + description: '2D LiDAR로 새 지도를 생성합니다.', + worldFile: '', + viewerModel: 'turtlebot3_world', + remoteSlam: true, + runtimeKind: 'PLATFORM_2D', + }, + { + key: 'nav2-platform-slam-3d', + platformKey: 'NAV2', + name: '새 3D SLAM', + description: '3D LiDAR 포인트 클라우드로 새 지도를 생성합니다.', + worldFile: '', + viewerModel: 'turtlebot3_world', + remoteSlam: true, + runtimeKind: 'PLATFORM_3D', + }, + ] + const maps = [...baseMaps, ...platformSlamMaps, ...savedSlamMaps] const keys = new Set() return maps.filter((map) => { if (keys.has(map.key)) return false keys.add(map.key) return true }) - }, [curriculumWeek.maps, curriculumWeek.modules, savedSlamMaps]) + }, [curriculumWeek.maps, savedSlamMaps]) useEffect(() => { const storedMapKey = window.localStorage.getItem(sessionMapStorageKey) ?? window.localStorage.getItem(simulationMapStorageKey) ?? window.localStorage.getItem(platformMapStorageKey) - const requestedMapKey = selectedWorldMapKey || storedMapKey + const normalizedStoredMapKey = storedMapKey === 'nav2-platform-slam' + ? 'nav2-platform-slam-2d' + : storedMapKey + const requestedMapKey = selectedWorldMapKey === 'nav2-platform-slam' + ? 'nav2-platform-slam-2d' + : selectedWorldMapKey || normalizedStoredMapKey if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return setSelectedWorldMapKey((current) => { const next = sessionWorldMaps.some((map) => map.key === current) ? current - : sessionWorldMaps.some((map) => map.key === storedMapKey) - ? storedMapKey! + : sessionWorldMaps.some((map) => map.key === normalizedStoredMapKey) + ? normalizedStoredMapKey! : sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world' window.localStorage.setItem(sessionMapStorageKey, next) return next @@ -1014,7 +1036,7 @@ function LocalPcWorkspaceMockup({ mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key, platform: visualizationPlatform, threeDimensional: Boolean( - selectedWorldMap.databasePath || selectedWorldMap.runtimeKind === 'PLATFORM_3D' + selectedWorldMap.runtimeKind === 'PLATFORM_3D' ), savedMapDatabasePath: selectedWorldMap.databasePath, prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {}, @@ -1037,9 +1059,7 @@ function LocalPcWorkspaceMockup({ if (initializedVisualizationMapRef.current === mapIdentity) return initializedVisualizationMapRef.current = mapIdentity const navigationMap = Boolean(selectedWorldMap?.saved) - const threeDimensionalMap = Boolean( - selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D' - ) + const threeDimensionalMap = selectedWorldMap?.runtimeKind === 'PLATFORM_3D' // Saved RTAB-Map databases are navigable maps. Keep the 3D viewer while // enabling the same pose and goal controls used by NAV2 navigation. const nextMode = navigationMap ? 'navigation' : 'slam' @@ -1050,7 +1070,7 @@ function LocalPcWorkspaceMockup({ : 'slam' if (visualizationStack !== nextStack) setVisualizationStack(nextStack) if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode) - }, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack]) + }, [nav2VisualizationMode, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack]) const [customModules, setCustomModules] = useState>(() => ( (curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM') )) @@ -1083,7 +1103,7 @@ function LocalPcWorkspaceMockup({ const resolvedModules = resolveRuntimeModules( curriculumModulesWithCustom, remoteWorkspace - ? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase())) + ? visualizationProfiles.filter(isRemoteEnvironmentProfile) : activeEnvironmentProfile, remoteWorkspace ? 'platform' : 'local' ) ?? [] @@ -1129,11 +1149,9 @@ function LocalPcWorkspaceMockup({ const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }> setSavedSlamMaps(maps.map((map) => { const isThreeDimensional = map.runtimeKind === 'PLATFORM_3D' - || Boolean(map.databasePath || map.databaseUrl) - // Older 3D map rows can omit databasePath even though the database - // artifact follows the YAML filename in the workspace maps directory. - const databasePath = map.databasePath - ?? (isThreeDimensional ? map.yamlPath.replace(/\.yaml$/i, '.db') : undefined) + const databasePath = isThreeDimensional + ? (map.databasePath ?? map.yamlPath.replace(/\.yaml$/i, '.db')) + : undefined return { key: `saved-slam-${map.id}`, platformKey: 'NAV2', @@ -1153,7 +1171,7 @@ function LocalPcWorkspaceMockup({ databasePath, databaseUrl: map.databaseUrl ?? undefined, runtimeKind: map.runtimeKind - ?? (databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'), + ?? (map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'), } })) } catch (error) { @@ -1266,9 +1284,8 @@ function LocalPcWorkspaceMockup({ form.set('sourceMapKey', selectedWorldMap.key) form.set('worldFile', selectedWorldMap.worldFile) form.set('viewerModel', selectedWorldMap.viewerModel) - form.set('runtimeKind', remoteWorkspace - ? (databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D') - : 'SIMULATION') + form.set('runtimeKind', selectedWorldMap.runtimeKind + ?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? 'PLATFORM_2D' : 'SIMULATION')) form.set('yamlPath', payload.map.yamlPath) form.set('pgmPath', payload.map.pgmPath) if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm) @@ -1310,7 +1327,7 @@ function LocalPcWorkspaceMockup({ const workspaceRestoreStartedAtRef = useRef(Date.now()) const moduleParameterScope = remoteWorkspace ? `platform:${platformId}` : 'local' const getModuleParameterStorageKey = (module: ModuleRow): string => ( - makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name, moduleParameterScope) + makeModuleParameterKey(courseKey, weekNumber, module.id ?? module.folderName ?? module.name, moduleParameterScope) ) const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => { const storedParameters = readStoredModuleParameters() @@ -2368,6 +2385,24 @@ function LocalPcWorkspaceMockup({ void applySourceSettings(normalizedSettings, false) } + const sourceSettingsDirty = (() => { + const normalizeForSave = (settings: StudioSourceSettings): StudioSourceSettings => normalizeStudioSourceSettings({ + workspaceRoot: remoteWorkspace + ? platformSourceSettingsDefaults.workspaceRoot + : settings.workspaceRoot.trim() || defaultStudioSourceSettings.workspaceRoot, + giteaBaseUrl: sourceSettings.giteaBaseUrl, + giteaOwner: sourceSettings.giteaOwner, + gpuEnabled: settings.gpuEnabled, + editorWordWrap: settings.editorWordWrap, + editorFontSize: settings.editorFontSize, + editorTabSize: settings.editorTabSize, + editorMinimap: settings.editorMinimap, + rosDomainId: settings.rosDomainId.trim() || defaultStudioSourceSettings.rosDomainId, + rosLinkedPlatformId: settings.rosLinkedPlatformId ?? null, + visualizationBridgeLocation: settings.visualizationBridgeLocation ?? 'local', + }) + return JSON.stringify(normalizeForSave(sourceSettingsDraft)) !== JSON.stringify(normalizeForSave(sourceSettings)) + })() const saveDirtyFilesForModule = async (module: ModuleRow): Promise => { const moduleRoot = getModuleFilePrefix(module.name) const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => ( @@ -2512,11 +2547,10 @@ function LocalPcWorkspaceMockup({ } if (map.saved) { setNav2VisualizationMode('navigation') - setVisualizationStack(map.databasePath ? 'slam3d' : 'nav2') + setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2') } else { setNav2VisualizationMode('slam') - if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d') - else if (visualizationStacks.includes('slam')) setVisualizationStack('slam') + setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'slam') } onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info') } @@ -3542,6 +3576,7 @@ function LocalPcWorkspaceMockup({ platformName={platformName} configuredDraftWorkspaceRoot={configuredDraftWorkspaceRoot} sourceSettingsDraft={sourceSettingsDraft} + saveDisabled={!sourceSettingsDirty} remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')} onUpdateSourceSetting={updateSourceSetting} onSaveSourceSettings={saveSourceSettings} @@ -3786,13 +3821,12 @@ function LocalPcWorkspaceMockup({ onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning') return } - setWorldMapSelectorOpen(false) setWorldEditorOpen(true) }} onDelete={(map) => void deleteSavedSlamMap(map)} onSelect={(map) => { - if (map.key === selectedWorldMapKey) return setWorldMapSelectorOpen(false) + if (map.key === selectedWorldMapKey) return if (availableModules.some((module) => module.state === 'running')) { void applyWorldMapSelection(map) setPendingWorldMapRestart(map) @@ -3812,7 +3846,14 @@ function LocalPcWorkspaceMockup({ {worldEditorOpen && ( setWorldLayoutVersion((version) => version + 1)} + onSaved={() => { + setWorldLayoutVersion((version) => version + 1) + const customMap = sessionWorldMaps.find((map) => map.key === 'nav2-custom') + if (customMap) void applyWorldMapSelection(customMap) + if (availableModules.some((module) => module.state === 'running')) { + onTitlebarNotice('커스텀 맵은 시뮬레이터를 다시 실행하면 적용됩니다.', 'warning') + } + }} onClose={() => setWorldEditorOpen(false)} /> )} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 42f2abf..edae508 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -348,6 +348,10 @@ type WorldLayoutObject = { type WorldLayout = { version: 1 + bounds: { + width: number + depth: number + } objects: WorldLayoutObject[] } diff --git a/src/renderer/src/features/workspace/ModuleListRow.tsx b/src/renderer/src/features/workspace/ModuleListRow.tsx index d4fe168..0041658 100644 --- a/src/renderer/src/features/workspace/ModuleListRow.tsx +++ b/src/renderer/src/features/workspace/ModuleListRow.tsx @@ -17,6 +17,8 @@ export type ModuleListRowItem = { type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral' +let dismissActiveTooltip: (() => void) | null = null + type ModuleListRowProps = { dark: boolean module: Module @@ -75,55 +77,65 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean) function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element { const anchorRef = useRef(null) + const dismissTimerRef = useRef(null) const [position, setPosition] = useState<{ left: number; top: number } | null>(null) useEffect(() => { const anchor = anchorRef.current?.parentElement if (!(anchor instanceof HTMLElement)) return + const clearDismissTimer = (): void => { + if (dismissTimerRef.current === null) return + window.clearTimeout(dismissTimerRef.current) + dismissTimerRef.current = null + } + const hideTooltip = (): void => { + clearDismissTimer() + setPosition(null) + if (dismissActiveTooltip === hideTooltip) dismissActiveTooltip = null + } const showTooltip = (): void => { + dismissActiveTooltip?.() + clearDismissTimer() const bounds = anchor.getBoundingClientRect() setPosition({ left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))), top: bounds.top - 8 }) + dismissActiveTooltip = hideTooltip + dismissTimerRef.current = window.setTimeout(() => { + hideTooltip() + }, 3000) + } + const hideWhenPointerLeaves = (event: PointerEvent): void => { + if (event.target instanceof Node && anchor.contains(event.target)) return + hideTooltip() } - const hideTooltip = (): void => setPosition(null) anchor.addEventListener('mouseenter', showTooltip) anchor.addEventListener('mouseleave', hideTooltip) anchor.addEventListener('focus', showTooltip) anchor.addEventListener('blur', hideTooltip) + document.addEventListener('pointermove', hideWhenPointerLeaves, true) + document.addEventListener('pointerdown', hideTooltip, true) + document.addEventListener('scroll', hideTooltip, true) + window.addEventListener('blur', hideTooltip) + window.addEventListener('resize', hideTooltip) return () => { anchor.removeEventListener('mouseenter', showTooltip) anchor.removeEventListener('mouseleave', hideTooltip) anchor.removeEventListener('focus', showTooltip) anchor.removeEventListener('blur', hideTooltip) + document.removeEventListener('pointermove', hideWhenPointerLeaves, true) + document.removeEventListener('pointerdown', hideTooltip, true) + document.removeEventListener('scroll', hideTooltip, true) + window.removeEventListener('blur', hideTooltip) + window.removeEventListener('resize', hideTooltip) + hideTooltip() } }, []) - useEffect(() => { - if (!position) return - - const updateTooltipPosition = (): void => { - const anchor = anchorRef.current?.parentElement - if (!(anchor instanceof HTMLElement)) return - const bounds = anchor.getBoundingClientRect() - setPosition({ - left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))), - top: bounds.top - 8 - }) - } - - window.addEventListener('resize', updateTooltipPosition) - window.addEventListener('scroll', updateTooltipPosition, true) - return () => { - window.removeEventListener('resize', updateTooltipPosition) - window.removeEventListener('scroll', updateTooltipPosition, true) - } - }, [position]) - return ( <>