add commits..
This commit is contained in:
+2
-2
@@ -4,9 +4,9 @@ VITE_LAUNCHER_API_BASE_URL=http://localhost:4200/api
|
|||||||
# Electron main process runtime overrides
|
# Electron main process runtime overrides
|
||||||
LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge
|
LOCAL_AUTOWARE_BRIDGE_IMAGE=autoware-ros-bridge
|
||||||
LOCAL_NAV2_BRIDGE_IMAGE=nav2-ros-bridge
|
LOCAL_NAV2_BRIDGE_IMAGE=nav2-ros-bridge
|
||||||
LOCAL_NAV2_RUNTIME_IMAGE=nav2-slam:humble
|
LOCAL_NAV2_RUNTIME_IMAGE=future-nav2-gazebo:humble
|
||||||
LOCAL_SLAM_BRIDGE_IMAGE=slam-ros-bridge
|
LOCAL_SLAM_BRIDGE_IMAGE=slam-ros-bridge
|
||||||
LOCAL_SLAM_RUNTIME_IMAGE=nav2-slam:humble
|
LOCAL_SLAM_RUNTIME_IMAGE=future-nav2-gazebo:humble
|
||||||
|
|
||||||
# Remote Jetson runtime overrides
|
# Remote Jetson runtime overrides
|
||||||
JETSON_AGENT_URL=http://100.64.0.2:8787
|
JETSON_AGENT_URL=http://100.64.0.2:8787
|
||||||
|
|||||||
@@ -1,41 +0,0 @@
|
|||||||
FROM future-nav2:humble
|
|
||||||
|
|
||||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
|
||||||
|
|
||||||
ENV DEBIAN_FRONTEND=noninteractive \
|
|
||||||
ROS_DISTRO=humble \
|
|
||||||
TURTLEBOT3_MODEL=burger \
|
|
||||||
NAV2_WORKSPACE=/workspace/nav2-slam_ws
|
|
||||||
|
|
||||||
# Nav2 and SLAM Toolbox are provided by the base image. Module workspaces may
|
|
||||||
# override those packages with local sources, while Gazebo and TurtleBot3 are
|
|
||||||
# added here for the source-based simulator modules.
|
|
||||||
RUN apt-get update \
|
|
||||||
&& apt-get install -y --no-install-recommends \
|
|
||||||
build-essential \
|
|
||||||
cmake \
|
|
||||||
git \
|
|
||||||
python3-argcomplete \
|
|
||||||
python3-colcon-common-extensions \
|
|
||||||
python3-rosdep \
|
|
||||||
python3-vcstool \
|
|
||||||
python3-pip \
|
|
||||||
ros-humble-gazebo-plugins \
|
|
||||||
ros-humble-gazebo-ros-pkgs \
|
|
||||||
ros-humble-turtlebot3-description \
|
|
||||||
ros-humble-turtlebot3-gazebo \
|
|
||||||
ros-humble-turtlebot3-msgs \
|
|
||||||
ros-humble-xacro \
|
|
||||||
ros-humble-tf-transformations \
|
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
|
||||||
&& rosdep init 2>/dev/null || true
|
|
||||||
|
|
||||||
RUN rosdep update
|
|
||||||
|
|
||||||
COPY docker-entrypoint.sh /usr/local/bin/studio-nav2-entrypoint
|
|
||||||
RUN chmod +x /usr/local/bin/studio-nav2-entrypoint \
|
|
||||||
&& mkdir -p "${NAV2_WORKSPACE}/src"
|
|
||||||
|
|
||||||
WORKDIR /workspace
|
|
||||||
ENTRYPOINT ["/usr/local/bin/studio-nav2-entrypoint"]
|
|
||||||
CMD ["bash"]
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
# Nav2 Development Image
|
|
||||||
|
|
||||||
`nav2-dev:humble` is the source-build runtime for Nav2 learning modules. It
|
|
||||||
contains ROS 2 Humble, Gazebo, colcon, and rosdep, but intentionally does not
|
|
||||||
contain Nav2, TurtleBot3, or SLAM Toolbox source code.
|
|
||||||
|
|
||||||
Build it from this directory:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./build.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Mount a composed module workspace and build its sources:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run --rm -it --network host \
|
|
||||||
-e DISPLAY="$DISPLAY" \
|
|
||||||
-e TURTLEBOT3_MODEL=burger \
|
|
||||||
-v /tmp/.X11-unix:/tmp/.X11-unix \
|
|
||||||
-v "$HOME/autonomy-studio/workspaces/nav2-slam_ws:/workspace/nav2-slam_ws" \
|
|
||||||
nav2-dev:humble bash
|
|
||||||
|
|
||||||
cd "$NAV2_WORKSPACE"
|
|
||||||
rosdep install --from-paths src --ignore-src -r -y
|
|
||||||
colcon build
|
|
||||||
```
|
|
||||||
|
|
||||||
The composed workspace must contain all direct source dependencies. For Nav2,
|
|
||||||
include its shared packages such as `nav2_common`, `nav2_util`, and `nav2_msgs`
|
|
||||||
alongside the module packages being built.
|
|
||||||
|
|
||||||
On later container starts, the entrypoint automatically sources
|
|
||||||
`/workspace/nav2-slam_ws/install/setup.bash`, so source-built packages override
|
|
||||||
the base ROS installation.
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
docker build -t nav2-dev:humble "$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -e
|
|
||||||
|
|
||||||
source "/opt/ros/${ROS_DISTRO}/setup.bash"
|
|
||||||
|
|
||||||
# A prior colcon build takes precedence over apt packages when the workspace
|
|
||||||
# is mounted. This makes source edits immediately available to ROS commands.
|
|
||||||
if [[ -f "${NAV2_WORKSPACE}/install/setup.bash" ]]; then
|
|
||||||
source "${NAV2_WORKSPACE}/install/setup.bash"
|
|
||||||
fi
|
|
||||||
|
|
||||||
exec "$@"
|
|
||||||
+117
-65
@@ -26,10 +26,10 @@ const moduleCacheRoot = resolve(homedir(), '.caches')
|
|||||||
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
|
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
|
||||||
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
|
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
|
||||||
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
|
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
|
||||||
const nav2DockerImage = 'nav2-dev:humble'
|
const nav2DockerImage = 'future-nav2-gazebo:humble'
|
||||||
const autowareRosBridgeImage = 'autoware-ros-bridge'
|
const autowareRosBridgeImage = 'autoware-ros-bridge'
|
||||||
const slamRosBridgeImage = 'slam-ros-bridge'
|
const slamRosBridgeImage = 'slam-ros-bridge'
|
||||||
const slamSimulationRuntimeImage = 'nav2-slam:humble'
|
const slamSimulationRuntimeImage = 'future-nav2-gazebo:humble'
|
||||||
const slamSimulationRuntimeContainerName = 'nav2-slam'
|
const slamSimulationRuntimeContainerName = 'nav2-slam'
|
||||||
const rosBridgePort = 8765
|
const rosBridgePort = 8765
|
||||||
const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code')
|
const exampleCodeRoot = resolve(homedir(), '다운로드', 'example-code')
|
||||||
@@ -199,6 +199,14 @@ type RuntimeImageArtifact = {
|
|||||||
required?: boolean | null
|
required?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const defaultNav2GazeboRuntimeImage: RuntimeImageArtifact = {
|
||||||
|
image: 'future-nav2-gazebo:humble',
|
||||||
|
url: 'https://pub-ca72c40797144462bdb7d200c215d361.r2.dev/studio-artifacts/runtime-images/nav2/gazebo/latest/future-nav2-gazebo-humble.tar.zst',
|
||||||
|
fileName: 'future-nav2-gazebo-humble.tar.zst',
|
||||||
|
checksumSha256: '54230e039c1fa5dc82615cef3828c5acae7b8bea8e921b70cfdc5351d8301644',
|
||||||
|
required: true,
|
||||||
|
}
|
||||||
|
|
||||||
type ModuleInstallOptions = {
|
type ModuleInstallOptions = {
|
||||||
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
|
||||||
runtimeImages?: RuntimeImageArtifact[] | null
|
runtimeImages?: RuntimeImageArtifact[] | null
|
||||||
@@ -236,6 +244,10 @@ type WorldLayoutObject = {
|
|||||||
|
|
||||||
type WorldLayout = {
|
type WorldLayout = {
|
||||||
version: 1
|
version: 1
|
||||||
|
bounds: {
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
objects: WorldLayoutObject[]
|
objects: WorldLayoutObject[]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,6 +267,7 @@ type SavedMapArtifactDownload = {
|
|||||||
function defaultWorldLayout(): WorldLayout {
|
function defaultWorldLayout(): WorldLayout {
|
||||||
return {
|
return {
|
||||||
version: 1,
|
version: 1,
|
||||||
|
bounds: { width: 10, depth: 10 },
|
||||||
objects: []
|
objects: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,7 +291,14 @@ function normalizeWorldLayout(value: unknown): WorldLayout {
|
|||||||
size: tuple(object.size, [1, 1, 1])
|
size: tuple(object.size, [1, 1, 1])
|
||||||
}]
|
}]
|
||||||
}).filter((object) => !['wall-1', 'pillar-1'].includes(object.id))
|
}).filter((object) => !['wall-1', 'pillar-1'].includes(object.id))
|
||||||
return { version: 1, objects }
|
const boundsInput = (value as { bounds?: unknown }).bounds
|
||||||
|
const bounds = boundsInput && typeof boundsInput === 'object'
|
||||||
|
? boundsInput as { width?: unknown; depth?: unknown }
|
||||||
|
: {}
|
||||||
|
const size = (input: unknown, fallback: number): number => (
|
||||||
|
typeof input === 'number' && Number.isFinite(input) ? Math.min(40, Math.max(2, input)) : fallback
|
||||||
|
)
|
||||||
|
return { version: 1, bounds: { width: size(bounds.width, 10), depth: size(bounds.depth, 10) }, objects }
|
||||||
}
|
}
|
||||||
|
|
||||||
function worldLayoutPath(workspaceRoot?: string | null): string {
|
function worldLayoutPath(workspaceRoot?: string | null): string {
|
||||||
@@ -766,6 +786,17 @@ function resolveNav2RuntimeImage(
|
|||||||
return nav2Image || nav2DockerImage
|
return nav2Image || nav2DockerImage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requiredRuntimeImages(
|
||||||
|
runtimeImages: RuntimeImageArtifact[] | null | undefined,
|
||||||
|
runtimeImage: string,
|
||||||
|
): RuntimeImageArtifact[] {
|
||||||
|
const images = (runtimeImages ?? []).filter((image) => image?.image?.trim())
|
||||||
|
if (runtimeImage !== nav2DockerImage || images.some((image) => image.image?.trim() === runtimeImage)) {
|
||||||
|
return images
|
||||||
|
}
|
||||||
|
return [...images, defaultNav2GazeboRuntimeImage]
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise<string> {
|
async function resolveCustomModuleRuntimeImage(moduleRoot: string, fallbackImage: string): Promise<string> {
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown }
|
const config = JSON.parse(await readFile(resolve(moduleRoot, '.autonomy-studio-module.json'), 'utf8')) as { dockerImage?: unknown }
|
||||||
@@ -1153,7 +1184,11 @@ async function ensureArtifactFile(url?: string | null, fileName?: string | null,
|
|||||||
|
|
||||||
const targetPath = artifactCachePath(artifactUrl, fileName)
|
const targetPath = artifactCachePath(artifactUrl, fileName)
|
||||||
const expected = checksumSha256?.trim().toLowerCase()
|
const expected = checksumSha256?.trim().toLowerCase()
|
||||||
if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath)
|
// Module artifact URLs are intentionally stable (for example, `latest`).
|
||||||
|
// Do not reuse a same-named local file, or a source update can run with an
|
||||||
|
// older install tree even though R2 already contains the current artifact.
|
||||||
|
await rm(targetPath, { force: true })
|
||||||
|
await downloadArtifact(artifactUrl, targetPath)
|
||||||
if (expected) {
|
if (expected) {
|
||||||
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
|
let actual = createHash('sha256').update(await readFile(targetPath)).digest('hex').toLowerCase()
|
||||||
if (actual !== expected) {
|
if (actual !== expected) {
|
||||||
@@ -2507,7 +2542,7 @@ function bridgeContainerCommand(stack: VisualizationStack): string {
|
|||||||
function normalizeVisualizationStack(value?: string | null): VisualizationStack {
|
function normalizeVisualizationStack(value?: string | null): VisualizationStack {
|
||||||
if (value === 'slam') return 'slam'
|
if (value === 'slam') return 'slam'
|
||||||
if (value === 'slam3d') return 'slam3d'
|
if (value === 'slam3d') return 'slam3d'
|
||||||
return value === 'nav2' ? 'nav2' : 'autoware'
|
return value === 'nav2' || value === 'local' || value === 'platform' ? 'nav2' : 'autoware'
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readContainerEnvironmentValue(containerName: string, key: string): Promise<string | null> {
|
async function readContainerEnvironmentValue(containerName: string, key: string): Promise<string | null> {
|
||||||
@@ -2722,10 +2757,7 @@ function withRuntimeProfileDefaults(
|
|||||||
if (!profile) return profile
|
if (!profile) return profile
|
||||||
const target = normalizeRuntimeTarget(profile.target)
|
const target = normalizeRuntimeTarget(profile.target)
|
||||||
const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}`
|
const prefix = target === 'remote-jetson' ? `JETSON_${stackEnvPrefix(stack)}` : `LOCAL_${stackEnvPrefix(stack)}`
|
||||||
const dedicatedReadOnlyBridge = profile.type?.toLowerCase() === 'real_slam_3d'
|
const bridgeImage = envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image
|
||||||
const bridgeImage = dedicatedReadOnlyBridge
|
|
||||||
? profile.bridge?.image
|
|
||||||
: envValue(`${prefix}_BRIDGE_IMAGE`) ?? profile.bridge?.image
|
|
||||||
const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`)
|
const runtimeImage = envValue(`${prefix}_RUNTIME_IMAGE`)
|
||||||
const agentUrl = target === 'remote-jetson'
|
const agentUrl = target === 'remote-jetson'
|
||||||
? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url
|
? envValue(`${prefix}_AGENT_URL`) ?? envValue('JETSON_AGENT_URL') ?? profile.agent?.url
|
||||||
@@ -2790,7 +2822,7 @@ async function startVisualizationBridge(
|
|||||||
rosNetwork?: RosNetworkConfig | null,
|
rosNetwork?: RosNetworkConfig | null,
|
||||||
savedMapDatabasePath?: string | null,
|
savedMapDatabasePath?: string | null,
|
||||||
): Promise<VisualizationBridgeStatus> {
|
): Promise<VisualizationBridgeStatus> {
|
||||||
const stack = normalizeVisualizationStack(environmentProfile?.type?.toLowerCase() ?? stackInput)
|
const stack = normalizeVisualizationStack(stackInput ?? environmentProfile?.type?.toLowerCase())
|
||||||
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
|
const resolvedProfile = withRuntimeProfileDefaults(environmentProfile, stack)
|
||||||
const target = normalizeRuntimeTarget(resolvedProfile?.target)
|
const target = normalizeRuntimeTarget(resolvedProfile?.target)
|
||||||
const bridge = resolvedProfile?.bridge
|
const bridge = resolvedProfile?.bridge
|
||||||
@@ -2869,7 +2901,7 @@ async function startVisualizationBridge(
|
|||||||
'-e',
|
'-e',
|
||||||
`ROS_DOMAIN_ID=${requestedRosDomainId}`,
|
`ROS_DOMAIN_ID=${requestedRosDomainId}`,
|
||||||
'-e',
|
'-e',
|
||||||
'ROS_LOCALHOST_ONLY=0',
|
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
|
||||||
'-e',
|
'-e',
|
||||||
`AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`,
|
`AUTONOMY_STUDIO_ROS_NETWORK=${requestedRosNetworkKey}`,
|
||||||
'-e',
|
'-e',
|
||||||
@@ -3150,8 +3182,9 @@ async function installRemoteWorkspaceModule(
|
|||||||
' if not url: continue',
|
' if not url: continue',
|
||||||
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"',
|
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"',
|
||||||
' path = os.path.join(cache, name)',
|
' path = os.path.join(cache, name)',
|
||||||
' if not os.path.exists(path):',
|
' # Stable latest URLs must not reuse a same-named local artifact.',
|
||||||
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
' if os.path.exists(path): os.unlink(path)',
|
||||||
|
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
||||||
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
||||||
' if expected:',
|
' if expected:',
|
||||||
' import hashlib',
|
' import hashlib',
|
||||||
@@ -3531,6 +3564,7 @@ function buildNav2DockerRunScript(): string {
|
|||||||
'GPU_ENABLED=${6:-false}',
|
'GPU_ENABLED=${6:-false}',
|
||||||
'shift 6 || true',
|
'shift 6 || true',
|
||||||
'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}',
|
'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}',
|
||||||
|
'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}',
|
||||||
'LOCALIZATION_MODE=slam',
|
'LOCALIZATION_MODE=slam',
|
||||||
'filtered_args=()',
|
'filtered_args=()',
|
||||||
'for arg in "$@"; do',
|
'for arg in "$@"; do',
|
||||||
@@ -3591,6 +3625,7 @@ function buildNav2DockerRunScript(): string {
|
|||||||
' --rm -d --network host --ipc=host',
|
' --rm -d --network host --ipc=host',
|
||||||
' --name "$CONTAINER_NAME"',
|
' --name "$CONTAINER_NAME"',
|
||||||
' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"',
|
' -e "ROS_DOMAIN_ID=$ROS_DOMAIN_ID_VALUE"',
|
||||||
|
' -e "ROS_LOCALHOST_ONLY=$ROS_LOCALHOST_ONLY_VALUE"',
|
||||||
' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"',
|
' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"',
|
||||||
' -e "MODULE_ROOT=/modules"',
|
' -e "MODULE_ROOT=/modules"',
|
||||||
' -e "MODULE_FOLDER=$MODULE_FOLDER"',
|
' -e "MODULE_FOLDER=$MODULE_FOLDER"',
|
||||||
@@ -3603,7 +3638,7 @@ function buildNav2DockerRunScript(): string {
|
|||||||
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
|
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
|
||||||
'fi',
|
'fi',
|
||||||
'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then',
|
'if [ -n "$FASTDDS_CONFIG_PATH" ] && [ -f "$FASTDDS_CONFIG_PATH" ]; then',
|
||||||
' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e ROS_LOCALHOST_ONLY=0)',
|
' args+=(-v "$FASTDDS_CONFIG_PATH:/fastdds_profiles.xml:ro" -e "FASTDDS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml" -e "FASTRTPS_DEFAULT_PROFILES_FILE=/fastdds_profiles.xml")',
|
||||||
'fi',
|
'fi',
|
||||||
'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi',
|
'if [ -d /dev/dri ]; then args+=(--device /dev/dri); fi',
|
||||||
'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then',
|
'if [ -n "${DISPLAY:-}" ] && [ -d /tmp/.X11-unix ]; then',
|
||||||
@@ -3999,7 +4034,6 @@ async function startWorkspaceModule(
|
|||||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
|
|
||||||
await prepareRuntimeImages(runtimeImages)
|
|
||||||
if (isNav2ModuleFolder(targetFolderName)) {
|
if (isNav2ModuleFolder(targetFolderName)) {
|
||||||
await ensureNav2RuntimeAssets()
|
await ensureNav2RuntimeAssets()
|
||||||
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId)
|
const fastDdsConfigPath = await prepareLocalRosNetworkConfig(rosNetwork, rosDomainId)
|
||||||
@@ -4009,6 +4043,7 @@ async function startWorkspaceModule(
|
|||||||
moduleRoot,
|
moduleRoot,
|
||||||
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
|
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
|
||||||
)
|
)
|
||||||
|
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
|
||||||
await runCommand(
|
await runCommand(
|
||||||
'bash',
|
'bash',
|
||||||
[
|
[
|
||||||
@@ -4022,12 +4057,16 @@ async function startWorkspaceModule(
|
|||||||
...splitRosParams(rosParams)
|
...splitRosParams(rosParams)
|
||||||
],
|
],
|
||||||
undefined,
|
undefined,
|
||||||
fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : undefined,
|
{
|
||||||
|
ROS_LOCALHOST_ONLY: rosNetworkEnabled(rosNetwork) ? '0' : '1',
|
||||||
|
...(fastDdsConfigPath ? { FASTDDS_CONFIG_PATH: fastDdsConfigPath } : {}),
|
||||||
|
},
|
||||||
30000
|
30000
|
||||||
)
|
)
|
||||||
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
return { ok: true, message: '실행 요청을 보냈습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prepareRuntimeImages(runtimeImages)
|
||||||
await ensureStudioRuntimeAssets()
|
await ensureStudioRuntimeAssets()
|
||||||
const scriptPath = resolveAutowareDockerRunScript()
|
const scriptPath = resolveAutowareDockerRunScript()
|
||||||
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
|
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
|
||||||
@@ -4082,7 +4121,6 @@ async function buildWorkspaceModule(
|
|||||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
|
|
||||||
await prepareRuntimeImages(runtimeImages)
|
|
||||||
if (isNav2ModuleFolder(targetFolderName)) {
|
if (isNav2ModuleFolder(targetFolderName)) {
|
||||||
await ensureNav2RuntimeAssets()
|
await ensureNav2RuntimeAssets()
|
||||||
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
|
const rosParams = typeof rosParamsOverride === 'string' ? rosParamsOverride.trim() : await readModuleRosParams(moduleRoot)
|
||||||
@@ -4090,6 +4128,7 @@ async function buildWorkspaceModule(
|
|||||||
moduleRoot,
|
moduleRoot,
|
||||||
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
|
resolveNav2RuntimeImage(runtimeImages, targetFolderName, rosParams),
|
||||||
)
|
)
|
||||||
|
await prepareRuntimeImages(requiredRuntimeImages(runtimeImages, runtimeImage))
|
||||||
await runCommand(
|
await runCommand(
|
||||||
'bash',
|
'bash',
|
||||||
[
|
[
|
||||||
@@ -4106,6 +4145,7 @@ async function buildWorkspaceModule(
|
|||||||
return { ok: true, message: '빌드가 완료되었습니다.' }
|
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await prepareRuntimeImages(runtimeImages)
|
||||||
await runCommand(
|
await runCommand(
|
||||||
'docker',
|
'docker',
|
||||||
[
|
[
|
||||||
@@ -4124,6 +4164,62 @@ async function buildWorkspaceModule(
|
|||||||
return { ok: true, message: '빌드가 완료되었습니다.' }
|
return { ok: true, message: '빌드가 완료되었습니다.' }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function prepareRemoteRuntimeImages(
|
||||||
|
target: RemoteWorkspaceTarget,
|
||||||
|
runtimeImages?: RuntimeImageArtifact[] | null,
|
||||||
|
): Promise<void> {
|
||||||
|
const artifacts = (runtimeImages ?? [])
|
||||||
|
.filter((image) => image?.image?.trim())
|
||||||
|
.map((image) => ({
|
||||||
|
image: image.image?.trim() ?? '',
|
||||||
|
url: image.url?.trim() ?? '',
|
||||||
|
fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '',
|
||||||
|
checksumSha256: image.checksumSha256?.trim() ?? '',
|
||||||
|
required: Boolean(image.required),
|
||||||
|
}))
|
||||||
|
const artifactsJson = JSON.stringify(artifacts)
|
||||||
|
|
||||||
|
await runSsh(
|
||||||
|
target,
|
||||||
|
[
|
||||||
|
'set -e',
|
||||||
|
`RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(artifactsJson))} | base64 -d)"`,
|
||||||
|
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
|
||||||
|
'mkdir -p "$ARTIFACT_CACHE"',
|
||||||
|
'LOCK_FILE="$ARTIFACT_CACHE/runtime-images.lock"',
|
||||||
|
'flock "$LOCK_FILE" python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'',
|
||||||
|
'import json, os, subprocess, sys',
|
||||||
|
'images = json.loads(sys.argv[1] or "[]")',
|
||||||
|
'cache = sys.argv[2]',
|
||||||
|
'for item in images:',
|
||||||
|
' image = (item.get("image") or "").strip()',
|
||||||
|
' if not image: continue',
|
||||||
|
' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0',
|
||||||
|
' if exists: continue',
|
||||||
|
' url = (item.get("url") or "").strip()',
|
||||||
|
' if not url:',
|
||||||
|
' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")',
|
||||||
|
' continue',
|
||||||
|
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
|
||||||
|
' path = os.path.join(cache, name)',
|
||||||
|
' if os.path.exists(path): os.unlink(path)',
|
||||||
|
' subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
||||||
|
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
||||||
|
' if expected:',
|
||||||
|
' import hashlib',
|
||||||
|
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
|
||||||
|
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
|
||||||
|
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
|
||||||
|
' import shlex',
|
||||||
|
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
|
||||||
|
' else:',
|
||||||
|
' subprocess.run(["docker", "load", "-i", path], check=True)',
|
||||||
|
'PY'
|
||||||
|
].join('\n'),
|
||||||
|
900000,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
async function startRemoteWorkspaceModule(
|
async function startRemoteWorkspaceModule(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
moduleName: string,
|
moduleName: string,
|
||||||
@@ -4140,17 +4236,6 @@ async function startRemoteWorkspaceModule(
|
|||||||
|
|
||||||
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
const dockerInfo = parseModuleDockerInfo(targetFolderName)
|
||||||
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
|
||||||
const runtimeImageArtifacts = (runtimeImages ?? [])
|
|
||||||
.filter((image) => image?.image?.trim())
|
|
||||||
.map((image) => ({
|
|
||||||
image: image.image?.trim() ?? '',
|
|
||||||
url: image.url?.trim() ?? '',
|
|
||||||
fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '',
|
|
||||||
checksumSha256: image.checksumSha256?.trim() ?? '',
|
|
||||||
required: Boolean(image.required),
|
|
||||||
}))
|
|
||||||
const runtimeImageJson = JSON.stringify(runtimeImageArtifacts)
|
|
||||||
|
|
||||||
const existsOutput = await runSshOutput(target, [
|
const existsOutput = await runSshOutput(target, [
|
||||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
||||||
...expandRemotePathVar('ROOT'),
|
...expandRemotePathVar('ROOT'),
|
||||||
@@ -4172,43 +4257,7 @@ async function startRemoteWorkspaceModule(
|
|||||||
? await ensureRemoteNav2RuntimeAssets(target)
|
? await ensureRemoteNav2RuntimeAssets(target)
|
||||||
: await ensureRemoteStudioRuntimeAssets(target)
|
: await ensureRemoteStudioRuntimeAssets(target)
|
||||||
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
|
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
|
||||||
await runSsh(
|
await prepareRemoteRuntimeImages(target, runtimeImages)
|
||||||
target,
|
|
||||||
[
|
|
||||||
'set -e',
|
|
||||||
`RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(runtimeImageJson))} | base64 -d)"`,
|
|
||||||
'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"',
|
|
||||||
'mkdir -p "$ARTIFACT_CACHE"',
|
|
||||||
'python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'',
|
|
||||||
'import json, os, subprocess, sys',
|
|
||||||
'images = json.loads(sys.argv[1] or "[]")',
|
|
||||||
'cache = sys.argv[2]',
|
|
||||||
'for item in images:',
|
|
||||||
' image = (item.get("image") or "").strip()',
|
|
||||||
' if not image: continue',
|
|
||||||
' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0',
|
|
||||||
' if exists: continue',
|
|
||||||
' url = (item.get("url") or "").strip()',
|
|
||||||
' if not url:',
|
|
||||||
' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")',
|
|
||||||
' continue',
|
|
||||||
' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"',
|
|
||||||
' path = os.path.join(cache, name)',
|
|
||||||
' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)',
|
|
||||||
' expected = (item.get("checksumSha256") or "").strip().lower()',
|
|
||||||
' if expected:',
|
|
||||||
' import hashlib',
|
|
||||||
' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()',
|
|
||||||
' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")',
|
|
||||||
' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):',
|
|
||||||
' import shlex',
|
|
||||||
' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)',
|
|
||||||
' else:',
|
|
||||||
' subprocess.run(["docker", "load", "-i", path], check=True)',
|
|
||||||
'PY'
|
|
||||||
].join('\n'),
|
|
||||||
900000
|
|
||||||
)
|
|
||||||
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
|
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
|
||||||
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
|
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
|
||||||
if (currentStatus.trim()) {
|
if (currentStatus.trim()) {
|
||||||
@@ -4257,9 +4306,11 @@ async function startRemoteWorkspaceModule(
|
|||||||
...expandRemotePathVar('ROOT'),
|
...expandRemotePathVar('ROOT'),
|
||||||
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
|
`PARAMS=${quoteShellSingle(remoteRosParams)}`,
|
||||||
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
|
`FASTDDS_CONFIG_PATH=${quoteShellSingle(fastDdsConfigPath ?? '')}`,
|
||||||
|
`ROS_LOCALHOST_ONLY=${rosNetworkEnabled(rosNetwork) ? '0' : '1'}`,
|
||||||
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
|
...expandRemotePathVar('FASTDDS_CONFIG_PATH'),
|
||||||
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
|
'read -r -a EXTRA_ARGS <<< "$PARAMS"',
|
||||||
'export FASTDDS_CONFIG_PATH',
|
'export FASTDDS_CONFIG_PATH',
|
||||||
|
'export ROS_LOCALHOST_ONLY',
|
||||||
`bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} "\${EXTRA_ARGS[@]}"`
|
`bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" ${quoteShellSingle(dockerInfo.containerKey)} "$ROOT" ${quoteShellSingle(targetFolderName)} ${quoteShellSingle(runtimeImage!)} ${quoteShellSingle(String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0')} ${gpuEnabled ? 'true' : 'false'} "\${EXTRA_ARGS[@]}"`
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: [
|
: [
|
||||||
@@ -4298,6 +4349,7 @@ async function buildRemoteWorkspaceModule(
|
|||||||
targetFolderName,
|
targetFolderName,
|
||||||
await resolveRemoteNav2RuntimeImage(runtimeImages),
|
await resolveRemoteNav2RuntimeImage(runtimeImages),
|
||||||
)
|
)
|
||||||
|
await prepareRemoteRuntimeImages(target, runtimeImages)
|
||||||
await runSsh(target, [
|
await runSsh(target, [
|
||||||
'set -e',
|
'set -e',
|
||||||
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
|
`RUNTIME_ROOT=${quoteShellSingle(runtimeRoot)}`,
|
||||||
|
|||||||
@@ -389,7 +389,7 @@
|
|||||||
display:none;
|
display:none;
|
||||||
}
|
}
|
||||||
#splatToggle { 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 #downloadWrap { display:none; }
|
||||||
.nav2-navigation #topLeftControls { display:block; }
|
.nav2-navigation #topLeftControls { display:block; }
|
||||||
.nav2-navigation #keyGuide { display:none; }
|
.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.
|
// 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 USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
|
||||||
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
|
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;
|
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
|
||||||
// Camera preview is kept for the RGB test module, but is paused for now so
|
// Camera preview is kept for the RGB test module, but is paused for now so
|
||||||
// regular visualization sessions never poll `/camera-frame`.
|
// 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 CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
|
||||||
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
|
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_WORLD_SPACING_SCALE = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world' ? 1.6 : 1;
|
||||||
const MKMINI_RENDER_LENGTH = 0.855;
|
const MKMINI_RENDER_LENGTH = 0.84;
|
||||||
const MKMINI_WHEELBASE_METERS = 0.325;
|
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 MKMINI_MAX_STEERING_RADIANS = 0.52;
|
||||||
const DEFAULT_SIMULATION_SPAWN = USE_MKMINI_MODEL
|
const DEFAULT_SIMULATION_SPAWN = USE_MKMINI_MODEL
|
||||||
? { x: -1.4, y: -0.5 }
|
? { x: -1.4, y: -0.5 }
|
||||||
@@ -648,6 +651,8 @@ gazeboWorldRoot.name = 'gazebo-world';
|
|||||||
sdfRoot.add(gazeboWorldRoot);
|
sdfRoot.add(gazeboWorldRoot);
|
||||||
const editorObjects = new THREE.Group();
|
const editorObjects = new THREE.Group();
|
||||||
sdfRoot.add(editorObjects);
|
sdfRoot.add(editorObjects);
|
||||||
|
const editorBoundaryObjects = new THREE.Group();
|
||||||
|
sdfRoot.add(editorBoundaryObjects);
|
||||||
const runtimeLayoutObjects = new THREE.Group();
|
const runtimeLayoutObjects = new THREE.Group();
|
||||||
gazeboWorldRoot.add(runtimeLayoutObjects);
|
gazeboWorldRoot.add(runtimeLayoutObjects);
|
||||||
const navigationMapRoot = new THREE.Group();
|
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;
|
const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
|
||||||
let pointCloudLayer = null;
|
let pointCloudLayer = null;
|
||||||
let hasGlobalMapPointCloud = false;
|
let hasGlobalMapPointCloud = false;
|
||||||
// A real platform can provide a 3D LiDAR stream even when this viewer was
|
// The live sensor point cloud belongs to the 3D view. 2D SLAM renders only
|
||||||
// opened through a legacy NAV2 stack URL without pointCloud=1.
|
// LaserScan and must never poll the point-cloud endpoint.
|
||||||
const sensorPointCloudGeometry = REAL_RUNTIME ? new THREE.BufferGeometry() : null;
|
const sensorPointCloudGeometry = SHOW_SENSOR_POINT_CLOUD ? new THREE.BufferGeometry() : null;
|
||||||
let sensorPointCloudLayer = null;
|
let sensorPointCloudLayer = null;
|
||||||
if (pointCloudGeometry) {
|
if (pointCloudGeometry) {
|
||||||
pointCloudLayer = new THREE.Points(
|
pointCloudLayer = new THREE.Points(
|
||||||
@@ -833,8 +838,9 @@ function createLayoutMesh(item, editor = false) {
|
|||||||
|
|
||||||
function renderEditorLayout(layout) {
|
function renderEditorLayout(layout) {
|
||||||
if (!EDITOR_MODE) return;
|
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(editorObjects);
|
||||||
|
clearLayoutObjects(editorBoundaryObjects);
|
||||||
for (const item of editorLayout.objects) {
|
for (const item of editorLayout.objects) {
|
||||||
editorObjects.add(createLayoutMesh(item, true));
|
editorObjects.add(createLayoutMesh(item, true));
|
||||||
}
|
}
|
||||||
@@ -846,7 +852,7 @@ async function refreshRuntimeLayout() {
|
|||||||
if (EDITOR_MODE || REAL_RUNTIME || !CUSTOM_WORLD) return;
|
if (EDITOR_MODE || REAL_RUNTIME || !CUSTOM_WORLD) return;
|
||||||
try {
|
try {
|
||||||
const layout = await fetch(`${BACKEND_URL}/world-layout`).then(response => response.ok ? response.json() : { objects: [] });
|
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;
|
if (signature === runtimeLayoutSignature) return;
|
||||||
runtimeLayoutSignature = signature;
|
runtimeLayoutSignature = signature;
|
||||||
clearLayoutObjects(runtimeLayoutObjects);
|
clearLayoutObjects(runtimeLayoutObjects);
|
||||||
@@ -884,6 +890,7 @@ let targetMkMiniSteeringAngle = 0;
|
|||||||
let currentMkMiniSteeringAngle = 0;
|
let currentMkMiniSteeringAngle = 0;
|
||||||
let targetMkMiniSteeringAngles = { left: 0, right: 0 };
|
let targetMkMiniSteeringAngles = { left: 0, right: 0 };
|
||||||
let currentMkMiniSteeringAngles = { left: 0, right: 0 };
|
let currentMkMiniSteeringAngles = { left: 0, right: 0 };
|
||||||
|
let lastMkMiniSteeringUpdateMs = null;
|
||||||
let lastMkMiniMotionSample = null;
|
let lastMkMiniMotionSample = null;
|
||||||
let hasSimulatorMkMiniSteering = false;
|
let hasSimulatorMkMiniSteering = false;
|
||||||
let laserGroup = null;
|
let laserGroup = null;
|
||||||
@@ -910,10 +917,12 @@ let cameraFrameRequestInFlight = false;
|
|||||||
let cameraFrameRefreshTimer = null;
|
let cameraFrameRefreshTimer = null;
|
||||||
let cameraFrameObjectUrl = null;
|
let cameraFrameObjectUrl = null;
|
||||||
let pathVisible = false;
|
let pathVisible = false;
|
||||||
// Sensor data is opt-in in every visualization mode. A saved 3D map is
|
// SLAM always renders its live scan, including the forward beam. Navigation
|
||||||
// rendered independently, so it must not implicitly enable the live lidar.
|
// keeps the sensor opt-in so a saved-map view starts cleanly.
|
||||||
let navigationSensorVisible = false;
|
let navigationSensorVisible = NAV2_VIEW_MODE !== 'navigation';
|
||||||
if (sensorPointCloudLayer) sensorPointCloudLayer.visible = false;
|
if (sensorPointCloudLayer) sensorPointCloudLayer.visible = navigationSensorVisible;
|
||||||
|
sensorToggleButton.classList.toggle('active', navigationSensorVisible);
|
||||||
|
sensorToggleButton.setAttribute('aria-pressed', String(navigationSensorVisible));
|
||||||
const robotPathPoints = [];
|
const robotPathPoints = [];
|
||||||
let robotPathLine = null;
|
let robotPathLine = null;
|
||||||
let navigationPlanLine = null;
|
let navigationPlanLine = null;
|
||||||
@@ -1183,7 +1192,7 @@ function addCylinder(parent, visual, geometry) {
|
|||||||
const cyl = directChild(geometry, 'cylinder');
|
const cyl = directChild(geometry, 'cylinder');
|
||||||
const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1;
|
const radius = Number(getDirectText(cyl, 'radius', '0.1')) || .1;
|
||||||
const sourceLength = Number(getDirectText(cyl, 'length', '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));
|
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), materialFromVisual(visual));
|
||||||
// Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다.
|
// Three cylinder 기본 축은 Y, SDF cylinder 기본 축은 Z입니다.
|
||||||
mesh.rotation.x = Math.PI / 2;
|
mesh.rotation.x = Math.PI / 2;
|
||||||
@@ -1231,7 +1240,7 @@ async function buildFromSdf() {
|
|||||||
const visualGroup = new THREE.Group();
|
const visualGroup = new THREE.Group();
|
||||||
visualGroup.name = visual.getAttribute('name') || 'visual';
|
visualGroup.name = visual.getAttribute('name') || 'visual';
|
||||||
applyWorldSdfPose(visualGroup, parsePose(getDirectText(visual, 'pose', '0 0 0 0 0 0')));
|
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);
|
linkGroup.add(visualGroup);
|
||||||
|
|
||||||
const geometry = directChild(visual, 'geometry');
|
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);
|
model.updateMatrixWorld(true);
|
||||||
const initialBounds = new THREE.Box3().setFromObject(model);
|
const initialBounds = new THREE.Box3().setFromObject(model);
|
||||||
const initialSize = initialBounds.getSize(new THREE.Vector3());
|
const initialSize = initialBounds.getSize(new THREE.Vector3());
|
||||||
const largestDimension = Math.max(initialSize.x, initialSize.y, initialSize.z, 0.001);
|
// The GLB is rotated into the SDF coordinate system above. Its local axes
|
||||||
// Match the Gazebo MKMini collision body's 1.05 m longest dimension.
|
// then map to SDF as X=length, Z=width, Y=height. Normalize every axis to
|
||||||
model.scale.setScalar(MKMINI_RENDER_LENGTH / largestDimension);
|
// 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);
|
model.updateMatrixWorld(true);
|
||||||
|
|
||||||
const normalizedBounds = new THREE.Box3().setFromObject(model);
|
const normalizedBounds = new THREE.Box3().setFromObject(model);
|
||||||
const center = normalizedBounds.getCenter(new THREE.Vector3());
|
const center = normalizedBounds.getCenter(new THREE.Vector3());
|
||||||
// This model lives under sdfRoot (Gazebo Z-up). Center it on base_link in
|
// Keep the wheels on SDF Z=0 after scaling the vehicle height.
|
||||||
// the horizontal plane and place its base on SDF Z=0.
|
|
||||||
model.position.set(-center.x, -center.y, -normalizedBounds.min.z);
|
model.position.set(-center.x, -center.y, -normalizedBounds.min.z);
|
||||||
model.traverse((child) => {
|
model.traverse((child) => {
|
||||||
if (!child.isMesh) return;
|
if (!child.isMesh) return;
|
||||||
@@ -1491,15 +1503,11 @@ async function setRemoteNavigationSplatVisible(visible) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const LASER_HOLE_R = 0.14;
|
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;
|
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;
|
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
|
const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03;
|
||||||
// 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;
|
|
||||||
|
|
||||||
function setRobotPose(pose) {
|
function setRobotPose(pose) {
|
||||||
if (!pose) return;
|
if (!pose) return;
|
||||||
@@ -1556,7 +1564,6 @@ function applyCurrentRobotPose() {
|
|||||||
robotModel.visible = shouldRenderRobot();
|
robotModel.visible = shouldRenderRobot();
|
||||||
robotModel.position.set(currentRobotPose.x, currentRobotPose.y, currentRobotPose.z);
|
robotModel.position.set(currentRobotPose.x, currentRobotPose.y, currentRobotPose.z);
|
||||||
robotModel.rotation.set(currentRobotPose.roll, currentRobotPose.pitch, currentRobotPose.yaw, 'XYZ');
|
robotModel.rotation.set(currentRobotPose.roll, currentRobotPose.pitch, currentRobotPose.yaw, 'XYZ');
|
||||||
updateMkMiniFrontWheelSteering();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateMkMiniSteeringTarget(pose) {
|
function updateMkMiniSteeringTarget(pose) {
|
||||||
@@ -1647,7 +1654,7 @@ function mkMiniAckermannAngles(steeringAngle) {
|
|||||||
if (Math.abs(steeringAngle) < 0.0001) return { left: 0, right: 0 };
|
if (Math.abs(steeringAngle) < 0.0001) return { left: 0, right: 0 };
|
||||||
const direction = Math.sign(steeringAngle);
|
const direction = Math.sign(steeringAngle);
|
||||||
const radius = MKMINI_GAZEBO_WHEELBASE_METERS / Math.tan(Math.abs(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 inner = Math.atan(MKMINI_GAZEBO_WHEELBASE_METERS / Math.max(0.01, radius - halfTrack));
|
||||||
const outer = Math.atan(MKMINI_GAZEBO_WHEELBASE_METERS / (radius + halfTrack));
|
const outer = Math.atan(MKMINI_GAZEBO_WHEELBASE_METERS / (radius + halfTrack));
|
||||||
return direction > 0 ? { left: inner, right: outer } : { left: -outer, right: -inner };
|
return direction > 0 ? { left: inner, right: outer } : { left: -outer, right: -inner };
|
||||||
@@ -1655,13 +1662,17 @@ function mkMiniAckermannAngles(steeringAngle) {
|
|||||||
|
|
||||||
function updateMkMiniFrontWheelSteering() {
|
function updateMkMiniFrontWheelSteering() {
|
||||||
if (!mkMiniFrontWheelPivots.length) return;
|
if (!mkMiniFrontWheelPivots.length) return;
|
||||||
currentMkMiniSteeringAngle += (targetMkMiniSteeringAngle - currentMkMiniSteeringAngle)
|
const now = performance.now();
|
||||||
* (hasSimulatorMkMiniSteering ? 1 : 0.28);
|
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) {
|
for (const { pivot, side } of mkMiniFrontWheelPivots) {
|
||||||
// The asset is Z-up before its model-level X rotation, so its local Y axis
|
// The asset is Z-up before its model-level X rotation, so its local Y axis
|
||||||
// becomes the viewer's vertical steering axis.
|
// becomes the viewer's vertical steering axis.
|
||||||
currentMkMiniSteeringAngles[side] += (targetMkMiniSteeringAngles[side] - currentMkMiniSteeringAngles[side])
|
currentMkMiniSteeringAngles[side] += (targetMkMiniSteeringAngles[side] - currentMkMiniSteeringAngles[side]) * alpha;
|
||||||
* (hasSimulatorMkMiniSteering ? 1 : 0.28);
|
|
||||||
pivot.rotation.y = currentMkMiniSteeringAngles[side];
|
pivot.rotation.y = currentMkMiniSteeringAngles[side];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1768,7 +1779,7 @@ function setNavigationSensorVisible(visible) {
|
|||||||
drawMiniMap(latestSlamState);
|
drawMiniMap(latestSlamState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (REAL_RUNTIME) {
|
if (SHOW_SENSOR_POINT_CLOUD) {
|
||||||
void refreshSensorPointCloud();
|
void refreshSensorPointCloud();
|
||||||
if (!sensorPointCloudRefreshTimer) {
|
if (!sensorPointCloudRefreshTimer) {
|
||||||
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500);
|
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500);
|
||||||
@@ -1798,9 +1809,8 @@ function drawLaserScan(scan, scanPose = (
|
|||||||
const renderPose = USE_MKMINI_MODEL && scanPose
|
const renderPose = USE_MKMINI_MODEL && scanPose
|
||||||
? {
|
? {
|
||||||
...sourcePose,
|
...sourcePose,
|
||||||
x: sourcePose.x + Math.cos(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_FORWARD_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_FORWARD_OFFSET,
|
y: sourcePose.y - Math.sin(sourcePose.yaw || 0) * MKMINI_LASER_RENDER_BACKWARD_OFFSET,
|
||||||
z: (sourcePose.z || 0) - MKMINI_LASER_RENDER_Z_OFFSET,
|
|
||||||
}
|
}
|
||||||
: sourcePose;
|
: sourcePose;
|
||||||
const origin = scanPose
|
const origin = scanPose
|
||||||
@@ -1813,18 +1823,25 @@ function drawLaserScan(scan, scanPose = (
|
|||||||
|
|
||||||
const endpoints = [];
|
const endpoints = [];
|
||||||
const linePoints = [];
|
const linePoints = [];
|
||||||
|
const openSpaceLinePoints = [];
|
||||||
|
const showOpenSpaceRays = NAV2_VIEW_MODE === 'slam';
|
||||||
for (let i = 0; i < scan.ranges.length; i++) {
|
for (let i = 0; i < scan.ranges.length; i++) {
|
||||||
const raw = scan.ranges[i];
|
const raw = scan.ranges[i];
|
||||||
const valid = Number.isFinite(raw) && raw >= rangeMin && raw < rangeMax;
|
const valid = Number.isFinite(raw) && raw >= rangeMin && raw < rangeMax;
|
||||||
const d = valid ? Math.min(raw, rangeMax) : rangeMax;
|
const d = valid ? Math.min(raw, rangeMax) : rangeMax;
|
||||||
const a = angleMin + i * angleInc;
|
const a = angleMin + i * angleInc;
|
||||||
const dir = laserDirectionFromPose(sourcePose, a);
|
const dir = laserDirectionFromPose(renderPose, a);
|
||||||
const startD = Math.min(LASER_HOLE_R, d);
|
const startD = Math.min(LASER_HOLE_R, d);
|
||||||
const start = origin.clone().addScaledVector(dir, startD);
|
const start = origin.clone().addScaledVector(dir, startD);
|
||||||
const point = origin.clone().addScaledVector(dir, d);
|
const point = origin.clone().addScaledVector(dir, d);
|
||||||
endpoints.push({ start, point, hit: valid });
|
endpoints.push({ start, point, hit: valid });
|
||||||
// A range-max value represents open space, not an actual reflected beam.
|
if (valid) {
|
||||||
if (valid) linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
|
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 = [];
|
const fillPoints = [];
|
||||||
@@ -1858,10 +1875,20 @@ function drawLaserScan(scan, scanPose = (
|
|||||||
const lineGeo = new THREE.BufferGeometry();
|
const lineGeo = new THREE.BufferGeometry();
|
||||||
lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePoints, 3));
|
lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePoints, 3));
|
||||||
grp.add(new THREE.LineSegments(lineGeo, lineMat));
|
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 frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc)));
|
||||||
const front = endpoints[frontIndex];
|
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(
|
const delta = new THREE.Vector3(
|
||||||
front.point.x - front.start.x,
|
front.point.x - front.start.x,
|
||||||
front.point.y - front.start.y,
|
front.point.y - front.start.y,
|
||||||
@@ -1980,7 +2007,7 @@ async function refreshPointCloud() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSensorPointCloud() {
|
async function refreshSensorPointCloud() {
|
||||||
if (!REAL_RUNTIME || !navigationSensorVisible || sensorPointCloudRequestInFlight) return;
|
if (!SHOW_SENSOR_POINT_CLOUD || !navigationSensorVisible || sensorPointCloudRequestInFlight) return;
|
||||||
sensorPointCloudRequestInFlight = true;
|
sensorPointCloudRequestInFlight = true;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${BACKEND_URL}/sensor-point-cloud`);
|
const response = await fetch(`${BACKEND_URL}/sensor-point-cloud`);
|
||||||
@@ -3188,7 +3215,7 @@ if (SHOW_POINT_CLOUD) {
|
|||||||
void refreshPointCloud();
|
void refreshPointCloud();
|
||||||
pointCloudRefreshTimer = window.setInterval(() => void refreshPointCloud(), 1000);
|
pointCloudRefreshTimer = window.setInterval(() => void refreshPointCloud(), 1000);
|
||||||
}
|
}
|
||||||
if (REAL_RUNTIME && navigationSensorVisible) {
|
if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
|
||||||
void refreshSensorPointCloud();
|
void refreshSensorPointCloud();
|
||||||
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500);
|
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500);
|
||||||
}
|
}
|
||||||
@@ -3223,7 +3250,7 @@ let singleMoveStopTimer = null;
|
|||||||
const TELEOP_COMMAND_INTERVAL_MS = 120;
|
const TELEOP_COMMAND_INTERVAL_MS = 120;
|
||||||
const SINGLE_MOVE_DURATION_MS = 120;
|
const SINGLE_MOVE_DURATION_MS = 120;
|
||||||
const LATCHED_ROTATION_SPEED_SCALE = 0.25;
|
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;
|
const MKMINI_GAZEBO_MAX_STEERING_RADIANS = 0.5;
|
||||||
let cmdVelRequestInFlight = false;
|
let cmdVelRequestInFlight = false;
|
||||||
let queuedCmdVel = null;
|
let queuedCmdVel = null;
|
||||||
@@ -3446,6 +3473,7 @@ window.addEventListener('resize', () => {
|
|||||||
function animate(){
|
function animate(){
|
||||||
requestAnimationFrame(animate);
|
requestAnimationFrame(animate);
|
||||||
updateSmoothedRobotPose();
|
updateSmoothedRobotPose();
|
||||||
|
updateMkMiniFrontWheelSteering();
|
||||||
controls.update();
|
controls.update();
|
||||||
if (remoteNavigationSplatViewer && remoteNavigationSplatEnabled) {
|
if (remoteNavigationSplatViewer && remoteNavigationSplatEnabled) {
|
||||||
remoteNavigationSplatViewer.update();
|
remoteNavigationSplatViewer.update();
|
||||||
|
|||||||
+84
-43
@@ -43,7 +43,7 @@ import WorkspaceLogView from './features/workspace/WorkspaceLogView'
|
|||||||
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
|
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
|
||||||
import ModuleListView from './features/workspace/ModuleListView'
|
import ModuleListView from './features/workspace/ModuleListView'
|
||||||
import WorldEditorModal from './features/workspace/WorldEditorModal'
|
import WorldEditorModal from './features/workspace/WorldEditorModal'
|
||||||
import WorldMapSelectorModal, { WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
|
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
|
||||||
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
|
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
|
||||||
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
|
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
|
||||||
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
|
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
|
||||||
@@ -85,6 +85,11 @@ type IdeActivity = WorkspaceActivity
|
|||||||
|
|
||||||
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
|
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
|
||||||
|
|
||||||
|
function isRemoteEnvironmentProfile(profile: EnvironmentProfile): boolean {
|
||||||
|
const target = profile.config?.target
|
||||||
|
return target === 'remote-jetson' || target === 'platform'
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModuleRuntimeImages(
|
function resolveModuleRuntimeImages(
|
||||||
profiles: EnvironmentProfile[],
|
profiles: EnvironmentProfile[],
|
||||||
remoteWorkspace: boolean,
|
remoteWorkspace: boolean,
|
||||||
@@ -93,7 +98,7 @@ function resolveModuleRuntimeImages(
|
|||||||
if (!remoteWorkspace) return activeRuntimeImages
|
if (!remoteWorkspace) return activeRuntimeImages
|
||||||
|
|
||||||
const images = profiles
|
const images = profiles
|
||||||
.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
|
.filter(isRemoteEnvironmentProfile)
|
||||||
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
|
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
|
||||||
.filter((image): image is RuntimeImageArtifact => (
|
.filter((image): image is RuntimeImageArtifact => (
|
||||||
typeof image === 'object'
|
typeof image === 'object'
|
||||||
@@ -806,39 +811,56 @@ function LocalPcWorkspaceMockup({
|
|||||||
const sessionWorldMaps = useMemo(() => {
|
const sessionWorldMaps = useMemo(() => {
|
||||||
const configuredMaps = (curriculumWeek.maps ?? [])
|
const configuredMaps = (curriculumWeek.maps ?? [])
|
||||||
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
|
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
|
||||||
const baseMaps = [...configuredMaps]
|
const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
|
||||||
const usesThreeDimensionalSlam = (curriculumWeek.modules ?? []).some((module) => (
|
const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap
|
||||||
/lms-nav2-gazebo-slam3d/i.test(module.folderName ?? '')
|
? [...configuredMaps]
|
||||||
))
|
: [...configuredMaps, customMap]
|
||||||
const platformSlamMap: WorkspaceMapOption = {
|
const platformSlamMaps: WorkspaceMapOption[] = [
|
||||||
key: 'nav2-platform-slam',
|
{
|
||||||
platformKey: 'NAV2',
|
key: 'nav2-platform-slam-2d',
|
||||||
name: '새 SLAM',
|
platformKey: 'NAV2',
|
||||||
description: '플랫폼 LiDAR로 새 지도를 생성합니다.',
|
name: '새 2D SLAM',
|
||||||
worldFile: '',
|
description: '2D LiDAR로 새 지도를 생성합니다.',
|
||||||
viewerModel: 'turtlebot3_world',
|
worldFile: '',
|
||||||
remoteSlam: true,
|
viewerModel: 'turtlebot3_world',
|
||||||
runtimeKind: usesThreeDimensionalSlam ? 'PLATFORM_3D' : 'PLATFORM_2D',
|
remoteSlam: true,
|
||||||
}
|
runtimeKind: 'PLATFORM_2D',
|
||||||
const maps = [...baseMaps, platformSlamMap, ...savedSlamMaps]
|
},
|
||||||
|
{
|
||||||
|
key: 'nav2-platform-slam-3d',
|
||||||
|
platformKey: 'NAV2',
|
||||||
|
name: '새 3D SLAM',
|
||||||
|
description: '3D LiDAR 포인트 클라우드로 새 지도를 생성합니다.',
|
||||||
|
worldFile: '',
|
||||||
|
viewerModel: 'turtlebot3_world',
|
||||||
|
remoteSlam: true,
|
||||||
|
runtimeKind: 'PLATFORM_3D',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const maps = [...baseMaps, ...platformSlamMaps, ...savedSlamMaps]
|
||||||
const keys = new Set<string>()
|
const keys = new Set<string>()
|
||||||
return maps.filter((map) => {
|
return maps.filter((map) => {
|
||||||
if (keys.has(map.key)) return false
|
if (keys.has(map.key)) return false
|
||||||
keys.add(map.key)
|
keys.add(map.key)
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
}, [curriculumWeek.maps, curriculumWeek.modules, savedSlamMaps])
|
}, [curriculumWeek.maps, savedSlamMaps])
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
|
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
|
||||||
?? window.localStorage.getItem(simulationMapStorageKey)
|
?? window.localStorage.getItem(simulationMapStorageKey)
|
||||||
?? window.localStorage.getItem(platformMapStorageKey)
|
?? window.localStorage.getItem(platformMapStorageKey)
|
||||||
const requestedMapKey = selectedWorldMapKey || storedMapKey
|
const normalizedStoredMapKey = storedMapKey === 'nav2-platform-slam'
|
||||||
|
? 'nav2-platform-slam-2d'
|
||||||
|
: storedMapKey
|
||||||
|
const requestedMapKey = selectedWorldMapKey === 'nav2-platform-slam'
|
||||||
|
? 'nav2-platform-slam-2d'
|
||||||
|
: selectedWorldMapKey || normalizedStoredMapKey
|
||||||
if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return
|
if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return
|
||||||
setSelectedWorldMapKey((current) => {
|
setSelectedWorldMapKey((current) => {
|
||||||
const next = sessionWorldMaps.some((map) => map.key === current)
|
const next = sessionWorldMaps.some((map) => map.key === current)
|
||||||
? current
|
? current
|
||||||
: sessionWorldMaps.some((map) => map.key === storedMapKey)
|
: sessionWorldMaps.some((map) => map.key === normalizedStoredMapKey)
|
||||||
? storedMapKey!
|
? normalizedStoredMapKey!
|
||||||
: sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world'
|
: sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world'
|
||||||
window.localStorage.setItem(sessionMapStorageKey, next)
|
window.localStorage.setItem(sessionMapStorageKey, next)
|
||||||
return next
|
return next
|
||||||
@@ -1014,7 +1036,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
|
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
|
||||||
platform: visualizationPlatform,
|
platform: visualizationPlatform,
|
||||||
threeDimensional: Boolean(
|
threeDimensional: Boolean(
|
||||||
selectedWorldMap.databasePath || selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||||
),
|
),
|
||||||
savedMapDatabasePath: selectedWorldMap.databasePath,
|
savedMapDatabasePath: selectedWorldMap.databasePath,
|
||||||
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
|
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
|
||||||
@@ -1037,9 +1059,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
if (initializedVisualizationMapRef.current === mapIdentity) return
|
if (initializedVisualizationMapRef.current === mapIdentity) return
|
||||||
initializedVisualizationMapRef.current = mapIdentity
|
initializedVisualizationMapRef.current = mapIdentity
|
||||||
const navigationMap = Boolean(selectedWorldMap?.saved)
|
const navigationMap = Boolean(selectedWorldMap?.saved)
|
||||||
const threeDimensionalMap = Boolean(
|
const threeDimensionalMap = selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
||||||
selectedWorldMap?.databasePath || selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|
|
||||||
)
|
|
||||||
// Saved RTAB-Map databases are navigable maps. Keep the 3D viewer while
|
// Saved RTAB-Map databases are navigable maps. Keep the 3D viewer while
|
||||||
// enabling the same pose and goal controls used by NAV2 navigation.
|
// enabling the same pose and goal controls used by NAV2 navigation.
|
||||||
const nextMode = navigationMap ? 'navigation' : 'slam'
|
const nextMode = navigationMap ? 'navigation' : 'slam'
|
||||||
@@ -1050,7 +1070,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
: 'slam'
|
: 'slam'
|
||||||
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
|
if (visualizationStack !== nextStack) setVisualizationStack(nextStack)
|
||||||
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
|
if (nav2VisualizationMode !== nextMode) setNav2VisualizationMode(nextMode)
|
||||||
}, [nav2VisualizationMode, selectedWorldMap?.databasePath, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack])
|
}, [nav2VisualizationMode, selectedWorldMap?.key, selectedWorldMap?.runtimeKind, selectedWorldMap?.saved, setNav2VisualizationMode, setVisualizationStack, visualizationStack])
|
||||||
const [customModules, setCustomModules] = useState<NonNullable<CurriculumWeek['modules']>>(() => (
|
const [customModules, setCustomModules] = useState<NonNullable<CurriculumWeek['modules']>>(() => (
|
||||||
(curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM')
|
(curriculumWeek.modules ?? []).filter((module) => module.sourceType === 'CUSTOM')
|
||||||
))
|
))
|
||||||
@@ -1083,7 +1103,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
const resolvedModules = resolveRuntimeModules(
|
const resolvedModules = resolveRuntimeModules(
|
||||||
curriculumModulesWithCustom,
|
curriculumModulesWithCustom,
|
||||||
remoteWorkspace
|
remoteWorkspace
|
||||||
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
|
? visualizationProfiles.filter(isRemoteEnvironmentProfile)
|
||||||
: activeEnvironmentProfile,
|
: activeEnvironmentProfile,
|
||||||
remoteWorkspace ? 'platform' : 'local'
|
remoteWorkspace ? 'platform' : 'local'
|
||||||
) ?? []
|
) ?? []
|
||||||
@@ -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 }>
|
const maps = await response.json() as Array<{ id: string; name: string; yamlPath: string; pgmPath: string; yamlUrl?: string | null; pgmUrl?: string | null; previewPgm?: string | null; databasePath?: string | null; databaseUrl?: string | null; runtimeKind?: SavedMapRuntimeKind; sourceMapKey: string; worldFile: string; viewerModel: 'turtlebot3_world' | 'turtlebot3_house'; createdAt: string }>
|
||||||
setSavedSlamMaps(maps.map((map) => {
|
setSavedSlamMaps(maps.map((map) => {
|
||||||
const isThreeDimensional = map.runtimeKind === 'PLATFORM_3D'
|
const isThreeDimensional = map.runtimeKind === 'PLATFORM_3D'
|
||||||
|| Boolean(map.databasePath || map.databaseUrl)
|
const databasePath = isThreeDimensional
|
||||||
// Older 3D map rows can omit databasePath even though the database
|
? (map.databasePath ?? map.yamlPath.replace(/\.yaml$/i, '.db'))
|
||||||
// artifact follows the YAML filename in the workspace maps directory.
|
: undefined
|
||||||
const databasePath = map.databasePath
|
|
||||||
?? (isThreeDimensional ? map.yamlPath.replace(/\.yaml$/i, '.db') : undefined)
|
|
||||||
return {
|
return {
|
||||||
key: `saved-slam-${map.id}`,
|
key: `saved-slam-${map.id}`,
|
||||||
platformKey: 'NAV2',
|
platformKey: 'NAV2',
|
||||||
@@ -1153,7 +1171,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
databasePath,
|
databasePath,
|
||||||
databaseUrl: map.databaseUrl ?? undefined,
|
databaseUrl: map.databaseUrl ?? undefined,
|
||||||
runtimeKind: map.runtimeKind
|
runtimeKind: map.runtimeKind
|
||||||
?? (databasePath ? 'PLATFORM_3D' : map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
|
?? (map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1266,9 +1284,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
form.set('sourceMapKey', selectedWorldMap.key)
|
form.set('sourceMapKey', selectedWorldMap.key)
|
||||||
form.set('worldFile', selectedWorldMap.worldFile)
|
form.set('worldFile', selectedWorldMap.worldFile)
|
||||||
form.set('viewerModel', selectedWorldMap.viewerModel)
|
form.set('viewerModel', selectedWorldMap.viewerModel)
|
||||||
form.set('runtimeKind', remoteWorkspace
|
form.set('runtimeKind', selectedWorldMap.runtimeKind
|
||||||
? (databasePath ? 'PLATFORM_3D' : 'PLATFORM_2D')
|
?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? 'PLATFORM_2D' : 'SIMULATION'))
|
||||||
: 'SIMULATION')
|
|
||||||
form.set('yamlPath', payload.map.yamlPath)
|
form.set('yamlPath', payload.map.yamlPath)
|
||||||
form.set('pgmPath', payload.map.pgmPath)
|
form.set('pgmPath', payload.map.pgmPath)
|
||||||
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
|
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
|
||||||
@@ -1310,7 +1327,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
const workspaceRestoreStartedAtRef = useRef(Date.now())
|
const workspaceRestoreStartedAtRef = useRef(Date.now())
|
||||||
const moduleParameterScope = remoteWorkspace ? `platform:${platformId}` : 'local'
|
const moduleParameterScope = remoteWorkspace ? `platform:${platformId}` : 'local'
|
||||||
const getModuleParameterStorageKey = (module: ModuleRow): string => (
|
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 applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => {
|
||||||
const storedParameters = readStoredModuleParameters()
|
const storedParameters = readStoredModuleParameters()
|
||||||
@@ -2368,6 +2385,24 @@ function LocalPcWorkspaceMockup({
|
|||||||
|
|
||||||
void applySourceSettings(normalizedSettings, false)
|
void applySourceSettings(normalizedSettings, false)
|
||||||
}
|
}
|
||||||
|
const sourceSettingsDirty = (() => {
|
||||||
|
const normalizeForSave = (settings: StudioSourceSettings): StudioSourceSettings => normalizeStudioSourceSettings({
|
||||||
|
workspaceRoot: remoteWorkspace
|
||||||
|
? platformSourceSettingsDefaults.workspaceRoot
|
||||||
|
: settings.workspaceRoot.trim() || defaultStudioSourceSettings.workspaceRoot,
|
||||||
|
giteaBaseUrl: sourceSettings.giteaBaseUrl,
|
||||||
|
giteaOwner: sourceSettings.giteaOwner,
|
||||||
|
gpuEnabled: settings.gpuEnabled,
|
||||||
|
editorWordWrap: settings.editorWordWrap,
|
||||||
|
editorFontSize: settings.editorFontSize,
|
||||||
|
editorTabSize: settings.editorTabSize,
|
||||||
|
editorMinimap: settings.editorMinimap,
|
||||||
|
rosDomainId: settings.rosDomainId.trim() || defaultStudioSourceSettings.rosDomainId,
|
||||||
|
rosLinkedPlatformId: settings.rosLinkedPlatformId ?? null,
|
||||||
|
visualizationBridgeLocation: settings.visualizationBridgeLocation ?? 'local',
|
||||||
|
})
|
||||||
|
return JSON.stringify(normalizeForSave(sourceSettingsDraft)) !== JSON.stringify(normalizeForSave(sourceSettings))
|
||||||
|
})()
|
||||||
const saveDirtyFilesForModule = async (module: ModuleRow): Promise<void> => {
|
const saveDirtyFilesForModule = async (module: ModuleRow): Promise<void> => {
|
||||||
const moduleRoot = getModuleFilePrefix(module.name)
|
const moduleRoot = getModuleFilePrefix(module.name)
|
||||||
const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => (
|
const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => (
|
||||||
@@ -2512,11 +2547,10 @@ function LocalPcWorkspaceMockup({
|
|||||||
}
|
}
|
||||||
if (map.saved) {
|
if (map.saved) {
|
||||||
setNav2VisualizationMode('navigation')
|
setNav2VisualizationMode('navigation')
|
||||||
setVisualizationStack(map.databasePath ? 'slam3d' : 'nav2')
|
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
|
||||||
} else {
|
} else {
|
||||||
setNav2VisualizationMode('slam')
|
setNav2VisualizationMode('slam')
|
||||||
if (map.remoteSlam && visualizationStacks.includes('slam3d')) setVisualizationStack('slam3d')
|
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'slam')
|
||||||
else if (visualizationStacks.includes('slam')) setVisualizationStack('slam')
|
|
||||||
}
|
}
|
||||||
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
|
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
|
||||||
}
|
}
|
||||||
@@ -3542,6 +3576,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
platformName={platformName}
|
platformName={platformName}
|
||||||
configuredDraftWorkspaceRoot={configuredDraftWorkspaceRoot}
|
configuredDraftWorkspaceRoot={configuredDraftWorkspaceRoot}
|
||||||
sourceSettingsDraft={sourceSettingsDraft}
|
sourceSettingsDraft={sourceSettingsDraft}
|
||||||
|
saveDisabled={!sourceSettingsDirty}
|
||||||
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
|
remotePlatforms={platformOptions.filter((platform) => platform.platformKey !== 'futuredrive-local-pc')}
|
||||||
onUpdateSourceSetting={updateSourceSetting}
|
onUpdateSourceSetting={updateSourceSetting}
|
||||||
onSaveSourceSettings={saveSourceSettings}
|
onSaveSourceSettings={saveSourceSettings}
|
||||||
@@ -3786,13 +3821,12 @@ function LocalPcWorkspaceMockup({
|
|||||||
onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning')
|
onTitlebarNotice('맵 편집은 로컬 시뮬레이션 환경에서 사용할 수 있습니다.', 'warning')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setWorldMapSelectorOpen(false)
|
|
||||||
setWorldEditorOpen(true)
|
setWorldEditorOpen(true)
|
||||||
}}
|
}}
|
||||||
onDelete={(map) => void deleteSavedSlamMap(map)}
|
onDelete={(map) => void deleteSavedSlamMap(map)}
|
||||||
onSelect={(map) => {
|
onSelect={(map) => {
|
||||||
if (map.key === selectedWorldMapKey) return
|
|
||||||
setWorldMapSelectorOpen(false)
|
setWorldMapSelectorOpen(false)
|
||||||
|
if (map.key === selectedWorldMapKey) return
|
||||||
if (availableModules.some((module) => module.state === 'running')) {
|
if (availableModules.some((module) => module.state === 'running')) {
|
||||||
void applyWorldMapSelection(map)
|
void applyWorldMapSelection(map)
|
||||||
setPendingWorldMapRestart(map)
|
setPendingWorldMapRestart(map)
|
||||||
@@ -3812,7 +3846,14 @@ function LocalPcWorkspaceMockup({
|
|||||||
{worldEditorOpen && (
|
{worldEditorOpen && (
|
||||||
<WorldEditorModal
|
<WorldEditorModal
|
||||||
workspaceRoot={clonePathPreview}
|
workspaceRoot={clonePathPreview}
|
||||||
onSaved={() => setWorldLayoutVersion((version) => version + 1)}
|
onSaved={() => {
|
||||||
|
setWorldLayoutVersion((version) => version + 1)
|
||||||
|
const customMap = sessionWorldMaps.find((map) => map.key === 'nav2-custom')
|
||||||
|
if (customMap) void applyWorldMapSelection(customMap)
|
||||||
|
if (availableModules.some((module) => module.state === 'running')) {
|
||||||
|
onTitlebarNotice('커스텀 맵은 시뮬레이터를 다시 실행하면 적용됩니다.', 'warning')
|
||||||
|
}
|
||||||
|
}}
|
||||||
onClose={() => setWorldEditorOpen(false)}
|
onClose={() => setWorldEditorOpen(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Vendored
+4
@@ -348,6 +348,10 @@ type WorldLayoutObject = {
|
|||||||
|
|
||||||
type WorldLayout = {
|
type WorldLayout = {
|
||||||
version: 1
|
version: 1
|
||||||
|
bounds: {
|
||||||
|
width: number
|
||||||
|
depth: number
|
||||||
|
}
|
||||||
objects: WorldLayoutObject[]
|
objects: WorldLayoutObject[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type ModuleListRowItem = {
|
|||||||
|
|
||||||
type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
|
type ModuleActionTone = 'install' | 'start' | 'warning' | 'delete' | 'neutral'
|
||||||
|
|
||||||
|
let dismissActiveTooltip: (() => void) | null = null
|
||||||
|
|
||||||
type ModuleListRowProps<Module extends ModuleListRowItem> = {
|
type ModuleListRowProps<Module extends ModuleListRowItem> = {
|
||||||
dark: boolean
|
dark: boolean
|
||||||
module: Module
|
module: Module
|
||||||
@@ -75,55 +77,65 @@ function actionButtonClass(dark: boolean, tone: ModuleActionTone, busy: boolean)
|
|||||||
|
|
||||||
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
|
function TooltipLabel({ dark, children }: { dark: boolean; children: React.ReactNode }): React.JSX.Element {
|
||||||
const anchorRef = useRef<HTMLSpanElement>(null)
|
const anchorRef = useRef<HTMLSpanElement>(null)
|
||||||
|
const dismissTimerRef = useRef<number | null>(null)
|
||||||
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const anchor = anchorRef.current?.parentElement
|
const anchor = anchorRef.current?.parentElement
|
||||||
if (!(anchor instanceof HTMLElement)) return
|
if (!(anchor instanceof HTMLElement)) return
|
||||||
|
|
||||||
|
const clearDismissTimer = (): void => {
|
||||||
|
if (dismissTimerRef.current === null) return
|
||||||
|
window.clearTimeout(dismissTimerRef.current)
|
||||||
|
dismissTimerRef.current = null
|
||||||
|
}
|
||||||
|
const hideTooltip = (): void => {
|
||||||
|
clearDismissTimer()
|
||||||
|
setPosition(null)
|
||||||
|
if (dismissActiveTooltip === hideTooltip) dismissActiveTooltip = null
|
||||||
|
}
|
||||||
const showTooltip = (): void => {
|
const showTooltip = (): void => {
|
||||||
|
dismissActiveTooltip?.()
|
||||||
|
clearDismissTimer()
|
||||||
const bounds = anchor.getBoundingClientRect()
|
const bounds = anchor.getBoundingClientRect()
|
||||||
setPosition({
|
setPosition({
|
||||||
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
|
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
|
||||||
top: bounds.top - 8
|
top: bounds.top - 8
|
||||||
})
|
})
|
||||||
|
dismissActiveTooltip = hideTooltip
|
||||||
|
dismissTimerRef.current = window.setTimeout(() => {
|
||||||
|
hideTooltip()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
const hideWhenPointerLeaves = (event: PointerEvent): void => {
|
||||||
|
if (event.target instanceof Node && anchor.contains(event.target)) return
|
||||||
|
hideTooltip()
|
||||||
}
|
}
|
||||||
const hideTooltip = (): void => setPosition(null)
|
|
||||||
|
|
||||||
anchor.addEventListener('mouseenter', showTooltip)
|
anchor.addEventListener('mouseenter', showTooltip)
|
||||||
anchor.addEventListener('mouseleave', hideTooltip)
|
anchor.addEventListener('mouseleave', hideTooltip)
|
||||||
anchor.addEventListener('focus', showTooltip)
|
anchor.addEventListener('focus', showTooltip)
|
||||||
anchor.addEventListener('blur', hideTooltip)
|
anchor.addEventListener('blur', hideTooltip)
|
||||||
|
document.addEventListener('pointermove', hideWhenPointerLeaves, true)
|
||||||
|
document.addEventListener('pointerdown', hideTooltip, true)
|
||||||
|
document.addEventListener('scroll', hideTooltip, true)
|
||||||
|
window.addEventListener('blur', hideTooltip)
|
||||||
|
window.addEventListener('resize', hideTooltip)
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
anchor.removeEventListener('mouseenter', showTooltip)
|
anchor.removeEventListener('mouseenter', showTooltip)
|
||||||
anchor.removeEventListener('mouseleave', hideTooltip)
|
anchor.removeEventListener('mouseleave', hideTooltip)
|
||||||
anchor.removeEventListener('focus', showTooltip)
|
anchor.removeEventListener('focus', showTooltip)
|
||||||
anchor.removeEventListener('blur', hideTooltip)
|
anchor.removeEventListener('blur', hideTooltip)
|
||||||
|
document.removeEventListener('pointermove', hideWhenPointerLeaves, true)
|
||||||
|
document.removeEventListener('pointerdown', hideTooltip, true)
|
||||||
|
document.removeEventListener('scroll', hideTooltip, true)
|
||||||
|
window.removeEventListener('blur', hideTooltip)
|
||||||
|
window.removeEventListener('resize', hideTooltip)
|
||||||
|
hideTooltip()
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!position) return
|
|
||||||
|
|
||||||
const updateTooltipPosition = (): void => {
|
|
||||||
const anchor = anchorRef.current?.parentElement
|
|
||||||
if (!(anchor instanceof HTMLElement)) return
|
|
||||||
const bounds = anchor.getBoundingClientRect()
|
|
||||||
setPosition({
|
|
||||||
left: Math.min(window.innerWidth - 8, Math.max(8, bounds.left + (bounds.width / 2))),
|
|
||||||
top: bounds.top - 8
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', updateTooltipPosition)
|
|
||||||
window.addEventListener('scroll', updateTooltipPosition, true)
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener('resize', updateTooltipPosition)
|
|
||||||
window.removeEventListener('scroll', updateTooltipPosition, true)
|
|
||||||
}
|
|
||||||
}, [position])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
|
<span ref={anchorRef} aria-hidden="true" className="absolute h-0 w-0 overflow-hidden" />
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ type Props = {
|
|||||||
platformName: string
|
platformName: string
|
||||||
configuredDraftWorkspaceRoot: string
|
configuredDraftWorkspaceRoot: string
|
||||||
sourceSettingsDraft: StudioSourceSettings
|
sourceSettingsDraft: StudioSourceSettings
|
||||||
|
saveDisabled: boolean
|
||||||
remotePlatforms: PlatformOption[]
|
remotePlatforms: PlatformOption[]
|
||||||
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
onUpdateSourceSetting: <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]) => void
|
||||||
onSaveSourceSettings: () => void
|
onSaveSourceSettings: () => void
|
||||||
@@ -23,6 +24,7 @@ function WorkspaceSettingsView({
|
|||||||
platformName,
|
platformName,
|
||||||
configuredDraftWorkspaceRoot,
|
configuredDraftWorkspaceRoot,
|
||||||
sourceSettingsDraft,
|
sourceSettingsDraft,
|
||||||
|
saveDisabled,
|
||||||
remotePlatforms,
|
remotePlatforms,
|
||||||
onUpdateSourceSetting,
|
onUpdateSourceSetting,
|
||||||
onSaveSourceSettings
|
onSaveSourceSettings
|
||||||
@@ -173,8 +175,9 @@ function WorkspaceSettingsView({
|
|||||||
<div className="mt-8 flex justify-end">
|
<div className="mt-8 flex justify-end">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={saveDisabled}
|
||||||
onClick={onSaveSourceSettings}
|
onClick={onSaveSourceSettings}
|
||||||
className="h-9 cursor-pointer rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
|
className="h-9 rounded-md border border-[#2563eb] bg-[#2563eb] px-4 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-default disabled:border-[#64748b] disabled:bg-[#64748b] disabled:text-white/70 disabled:hover:bg-[#64748b]"
|
||||||
>
|
>
|
||||||
저장
|
저장
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ type WorldObjectType = WorldLayoutObject['type']
|
|||||||
type WorldEditorModalProps = {
|
type WorldEditorModalProps = {
|
||||||
workspaceRoot: string
|
workspaceRoot: string
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSaved?: () => void
|
onSaved?: (layout: WorldLayout) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
type TransformMode = 'translate' | 'rotate' | 'scale'
|
type TransformMode = 'translate' | 'rotate' | 'scale'
|
||||||
@@ -175,10 +175,10 @@ async function loadWorldSdf(parent: THREE.Group): Promise<void> {
|
|||||||
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
|
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
|
||||||
const canvasHostRef = useRef<HTMLDivElement>(null)
|
const canvasHostRef = useRef<HTMLDivElement>(null)
|
||||||
const viewerRef = useRef<HTMLIFrameElement>(null)
|
const viewerRef = useRef<HTMLIFrameElement>(null)
|
||||||
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
|
const worldRef = useRef<WorldLayout>({ version: 1, bounds: { width: 10, depth: 10 }, objects: [] })
|
||||||
const selectedIdRef = useRef<string | null>(null)
|
const selectedIdRef = useRef<string | null>(null)
|
||||||
const renderSceneRef = useRef<(() => void) | null>(null)
|
const renderSceneRef = useRef<(() => void) | null>(null)
|
||||||
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
|
const [layout, setLayout] = useState<WorldLayout>({ version: 1, bounds: { width: 10, depth: 10 }, objects: [] })
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
@@ -403,8 +403,16 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
|||||||
setSelectedId(remaining[0]?.id ?? null)
|
setSelectedId(remaining[0]?.id ?? null)
|
||||||
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
window.requestAnimationFrame(() => renderSceneRef.current?.())
|
||||||
}
|
}
|
||||||
|
const updateBounds = (key: 'width' | 'depth', value: string): void => {
|
||||||
|
const numeric = Number(value)
|
||||||
|
if (!Number.isFinite(numeric)) return
|
||||||
|
setLayout((current) => ({
|
||||||
|
...current,
|
||||||
|
bounds: { ...current.bounds, [key]: Math.min(40, Math.max(2, numeric)) },
|
||||||
|
}))
|
||||||
|
}
|
||||||
const resetLayout = (): void => {
|
const resetLayout = (): void => {
|
||||||
const next: WorldLayout = { version: 1, objects: [] }
|
const next: WorldLayout = { version: 1, bounds: layout.bounds, objects: [] }
|
||||||
worldRef.current = next
|
worldRef.current = next
|
||||||
setLayout(next)
|
setLayout(next)
|
||||||
setSelectedId(null)
|
setSelectedId(null)
|
||||||
@@ -416,7 +424,7 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
|||||||
try {
|
try {
|
||||||
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
|
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
|
||||||
setLayout(saved)
|
setLayout(saved)
|
||||||
onSaved?.()
|
onSaved?.(saved)
|
||||||
onClose()
|
onClose()
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
|
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
|
||||||
@@ -426,7 +434,7 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
|
<div className="absolute inset-0 z-[170] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
|
||||||
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
|
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
|
||||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
|
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -442,6 +450,19 @@ export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: Wo
|
|||||||
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
|
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
|
||||||
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
|
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-b border-white/10 px-3 py-3">
|
||||||
|
<p className="mb-2 text-[10px] font-bold tracking-[0.08em] text-white/38">맵 크기</p>
|
||||||
|
<div className="grid grid-cols-2 gap-1.5">
|
||||||
|
<label className="min-w-0">
|
||||||
|
<span className="mb-1 block text-[10px] font-bold text-white/38">가로 (m)</span>
|
||||||
|
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.width} onChange={(event) => updateBounds('width', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
|
||||||
|
</label>
|
||||||
|
<label className="min-w-0">
|
||||||
|
<span className="mb-1 block text-[10px] font-bold text-white/38">세로 (m)</span>
|
||||||
|
<input type="number" min="2" max="40" step="0.5" value={layout.bounds.depth} onChange={(event) => updateBounds('depth', event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
{layout.objects.length ? layout.objects.map((item) => {
|
{layout.objects.length ? layout.objects.map((item) => {
|
||||||
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
|
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
|
||||||
|
|||||||
@@ -137,7 +137,30 @@ export function WorkspaceMapPreview({
|
|||||||
>
|
>
|
||||||
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
|
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
|
||||||
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
|
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
|
||||||
{!map.previewPgm && (map.custom ? (
|
{!map.previewPgm && (map.remoteSlam && map.runtimeKind === 'PLATFORM_3D' ? (
|
||||||
|
<>
|
||||||
|
<span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />
|
||||||
|
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
|
||||||
|
{[
|
||||||
|
['left-[27%]', 'top-[31%]'], ['left-[31%]', 'top-[36%]'], ['left-[35%]', 'top-[28%]'],
|
||||||
|
['left-[38%]', 'top-[40%]'], ['left-[44%]', 'top-[33%]'], ['left-[48%]', 'top-[43%]'],
|
||||||
|
['left-[62%]', 'top-[27%]'], ['left-[68%]', 'top-[33%]'], ['left-[72%]', 'top-[40%]'],
|
||||||
|
['left-[58%]', 'top-[57%]'], ['left-[64%]', 'top-[63%]'], ['left-[70%]', 'top-[55%]'],
|
||||||
|
].map(([left, top], index) => (
|
||||||
|
<span
|
||||||
|
key={`${left}-${top}`}
|
||||||
|
className={[
|
||||||
|
'absolute rounded-full bg-[#536273]',
|
||||||
|
index % 4 === 0 ? 'size-1.5 opacity-90' : index % 3 === 0 ? 'size-1 opacity-75' : 'size-[3px] opacity-60',
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
].join(' ')}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<span className="absolute left-[28%] top-[51%] h-px w-[25%] rotate-[-20deg] bg-[#536273]/70" />
|
||||||
|
<span className="absolute left-[55%] top-[48%] h-px w-[23%] rotate-[28deg] bg-[#536273]/70" />
|
||||||
|
</>
|
||||||
|
) : map.custom ? (
|
||||||
<>
|
<>
|
||||||
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
|
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
|
||||||
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
|
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
|
||||||
@@ -208,7 +231,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
|||||||
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button>
|
<button type="button" onClick={() => setScope('simulation')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'simulation' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>시뮬레이션</button>
|
||||||
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
|
<button type="button" onClick={() => setScope('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
|
<div className="thin-y-scrollbar min-h-[320px] max-h-[320px] space-y-2 overflow-y-auto p-5">
|
||||||
{maps.map((map) => {
|
{maps.map((map) => {
|
||||||
const pending = map.key === pendingMap?.key
|
const pending = map.key === pendingMap?.key
|
||||||
const selected = map.key === selectedMapKey
|
const selected = map.key === selectedMapKey
|
||||||
@@ -216,7 +239,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
|||||||
// runtime is chosen by the active SLAM stack, so only persisted
|
// runtime is chosen by the active SLAM stack, so only persisted
|
||||||
// map results receive a dimensional badge.
|
// map results receive a dimensional badge.
|
||||||
const mapDimension = map.saved
|
const mapDimension = map.saved
|
||||||
? (map.runtimeKind === 'PLATFORM_3D' || Boolean(map.databasePath) ? '3D' : '2D')
|
? (map.runtimeKind === 'PLATFORM_3D' ? '3D' : '2D')
|
||||||
: null
|
: null
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -257,10 +280,10 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
|
|||||||
{scope === 'simulation' && onCreate && (
|
{scope === 'simulation' && onCreate && (
|
||||||
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
|
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
|
||||||
<PencilRuler className="size-3.5" />
|
<PencilRuler className="size-3.5" />
|
||||||
맵 편집
|
커스텀 맵
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
|
<button type="button" disabled={!pendingMap} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
|
||||||
확인
|
확인
|
||||||
</button>
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -31,6 +31,24 @@ function waitForSimulatorStartup(): Promise<void> {
|
|||||||
return new Promise((resolve) => window.setTimeout(resolve, 4000))
|
return new Promise((resolve) => window.setTimeout(resolve, 4000))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function duplicateCategorySummary(modules: ModuleRow[]): string | null {
|
||||||
|
const categories = new Map<string, { label: string; modules: ModuleRow[] }>()
|
||||||
|
modules.forEach((module) => {
|
||||||
|
const label = module.category.trim()
|
||||||
|
if (!label) return
|
||||||
|
const key = label.toLowerCase()
|
||||||
|
const entry = categories.get(key) ?? { label, modules: [] }
|
||||||
|
entry.modules.push(module)
|
||||||
|
categories.set(key, entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
const conflicts = Array.from(categories.values()).filter((entry) => entry.modules.length > 1)
|
||||||
|
if (!conflicts.length) return null
|
||||||
|
return conflicts
|
||||||
|
.map((entry) => `${entry.label}: ${entry.modules.map((module) => module.name).join(', ')}`)
|
||||||
|
.join(' / ')
|
||||||
|
}
|
||||||
|
|
||||||
type UseWorkspaceModuleControlOptions = {
|
type UseWorkspaceModuleControlOptions = {
|
||||||
availableModules: ModuleRow[]
|
availableModules: ModuleRow[]
|
||||||
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
|
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
|
||||||
@@ -284,14 +302,17 @@ export function useWorkspaceModuleControl({
|
|||||||
try {
|
try {
|
||||||
const requestAccessToken = await getFreshAccessToken()
|
const requestAccessToken = await getFreshAccessToken()
|
||||||
await installModuleOnce(module, requestAccessToken)
|
await installModuleOnce(module, requestAccessToken)
|
||||||
await loadAvailableModules({ silent: true })
|
// The install request completed successfully, so update only this row.
|
||||||
|
// A full module probe can overlap the ongoing background probe and leave
|
||||||
|
// the install action visible until the user refreshes the workspace.
|
||||||
|
markModuleState(module, 'stopped')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[modules] install failed', error)
|
console.error('[modules] install failed', error)
|
||||||
setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패')
|
setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패')
|
||||||
} finally {
|
} finally {
|
||||||
setInstallingModuleName(null)
|
setInstallingModuleName(null)
|
||||||
}
|
}
|
||||||
}, [getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
|
}, [getFreshAccessToken, installModuleOnce, installingModuleName, markModuleState])
|
||||||
|
|
||||||
const installAllModules = useCallback(async (): Promise<void> => {
|
const installAllModules = useCallback(async (): Promise<void> => {
|
||||||
if (installingModuleName) return
|
if (installingModuleName) return
|
||||||
@@ -304,16 +325,15 @@ export function useWorkspaceModuleControl({
|
|||||||
const requestAccessToken = await getFreshAccessToken()
|
const requestAccessToken = await getFreshAccessToken()
|
||||||
for (const module of targets) {
|
for (const module of targets) {
|
||||||
await installModuleOnce(module, requestAccessToken)
|
await installModuleOnce(module, requestAccessToken)
|
||||||
|
markModuleState(module, 'stopped')
|
||||||
}
|
}
|
||||||
await loadAvailableModules({ silent: true })
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[modules] install all failed', error)
|
console.error('[modules] install all failed', error)
|
||||||
setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패')
|
setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패')
|
||||||
await loadAvailableModules({ silent: true })
|
|
||||||
} finally {
|
} finally {
|
||||||
setInstallingModuleName(null)
|
setInstallingModuleName(null)
|
||||||
}
|
}
|
||||||
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, loadAvailableModules])
|
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, isExcludedFromBulkControl, markModuleState])
|
||||||
|
|
||||||
const startAllModules = useCallback(async (): Promise<void> => {
|
const startAllModules = useCallback(async (): Promise<void> => {
|
||||||
if (installingModuleName) return
|
if (installingModuleName) return
|
||||||
@@ -323,6 +343,18 @@ export function useWorkspaceModuleControl({
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const activeModules = availableModules.filter((module) => (
|
||||||
|
!isExcludedFromBulkControl(module)
|
||||||
|
&& (module.state === 'stopped' || module.state === 'running')
|
||||||
|
))
|
||||||
|
const duplicateCategories = duplicateCategorySummary(activeModules)
|
||||||
|
if (duplicateCategories) {
|
||||||
|
const message = `동일 카테고리 모듈이 중복되어 전체 실행을 중단했습니다. ${duplicateCategories}`
|
||||||
|
setModuleInstallError(message)
|
||||||
|
onTitlebarNotice('중복 카테고리 모듈을 확인하세요', 'warning')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setInstallingModuleName('__start_all__')
|
setInstallingModuleName('__start_all__')
|
||||||
setModuleInstallError(null)
|
setModuleInstallError(null)
|
||||||
try {
|
try {
|
||||||
@@ -386,6 +418,7 @@ export function useWorkspaceModuleControl({
|
|||||||
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
|
||||||
)
|
)
|
||||||
if (!result.ok) throw new Error(result.message)
|
if (!result.ok) throw new Error(result.message)
|
||||||
|
targets.forEach((module) => markModuleState(module, 'stopped'))
|
||||||
} else {
|
} else {
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
const result = await window.localWorkspace.modules.stopMany(
|
const result = await window.localWorkspace.modules.stopMany(
|
||||||
@@ -393,6 +426,7 @@ export function useWorkspaceModuleControl({
|
|||||||
clonePathPreview
|
clonePathPreview
|
||||||
)
|
)
|
||||||
if (!result.ok) throw new Error(result.message)
|
if (!result.ok) throw new Error(result.message)
|
||||||
|
targets.forEach((module) => markModuleState(module, 'stopped'))
|
||||||
}
|
}
|
||||||
await stopLocalVisualization()
|
await stopLocalVisualization()
|
||||||
}
|
}
|
||||||
@@ -406,7 +440,7 @@ export function useWorkspaceModuleControl({
|
|||||||
} finally {
|
} finally {
|
||||||
setInstallingModuleName(null)
|
setInstallingModuleName(null)
|
||||||
}
|
}
|
||||||
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, isExcludedFromBulkControl, loadAvailableModules, onStopVisualization, onTitlebarNotice, remoteWorkspace])
|
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, isExcludedFromBulkControl, loadAvailableModules, markModuleState, onStopVisualization, onTitlebarNotice, remoteWorkspace])
|
||||||
|
|
||||||
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
|
const restartRunningModules = useCallback(async (overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null }): Promise<boolean> => {
|
||||||
if (installingModuleName) return false
|
if (installingModuleName) return false
|
||||||
@@ -588,7 +622,7 @@ export function useWorkspaceModuleControl({
|
|||||||
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
|
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
|
||||||
.filter((parameter) => parameter.name)
|
.filter((parameter) => parameter.name)
|
||||||
storeModuleParameters(
|
storeModuleParameters(
|
||||||
makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name, moduleParameterScope),
|
makeModuleParameterKey(courseKey, weekNumber, module.id ?? module.folderName ?? module.name, moduleParameterScope),
|
||||||
normalizedParameters
|
normalizedParameters
|
||||||
)
|
)
|
||||||
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
|
const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean)
|
||||||
|
|||||||
@@ -28,20 +28,10 @@ const DEFAULT_NAV2_VISUALIZATION_PROFILE: EnvironmentProfile = {
|
|||||||
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
|
||||||
const type = profileType.toLowerCase()
|
const type = profileType.toLowerCase()
|
||||||
if (type === 'local' || type === 'platform') return 'nav2'
|
if (type === 'local' || type === 'platform') return 'nav2'
|
||||||
if (type === 'real_slam') return 'slam'
|
|
||||||
if (type === 'real_slam_3d') return 'slam3d'
|
|
||||||
if (type === 'real_navigation') return 'nav2'
|
|
||||||
return type === 'autoware' || type === 'nav2' || type === 'slam' || type === 'slam3d' ? type : null
|
return type === 'autoware' || type === 'nav2' || type === 'slam' || type === 'slam3d' ? type : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
|
function isRemoteEnvironment(profile: EnvironmentProfile | null): boolean {
|
||||||
const type = profile?.profileType.toLowerCase()
|
|
||||||
if (type === 'real_navigation') return 'navigation'
|
|
||||||
return 'slam'
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
|
|
||||||
if (['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile?.profileType.toLowerCase() ?? '')) return true
|
|
||||||
const config = profile?.config
|
const config = profile?.config
|
||||||
return Boolean(
|
return Boolean(
|
||||||
config
|
config
|
||||||
@@ -53,7 +43,7 @@ function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
|
|||||||
|
|
||||||
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
|
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
|
||||||
const matchingProfiles = profiles.filter((profile) => (
|
const matchingProfiles = profiles.filter((profile) => (
|
||||||
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
|
remoteWorkspace ? isRemoteEnvironment(profile) : !isRemoteEnvironment(profile)
|
||||||
))
|
))
|
||||||
return matchingProfiles.length ? matchingProfiles : profiles
|
return matchingProfiles.length ? matchingProfiles : profiles
|
||||||
}
|
}
|
||||||
@@ -175,7 +165,7 @@ export function useWorkspaceVisualization({
|
|||||||
// resolves to the generic NAV2 viewer. Keep the viewer mode tied to the map,
|
// resolves to the generic NAV2 viewer. Keep the viewer mode tied to the map,
|
||||||
// not only to the profile label, so 3D state is never rendered as /scan.
|
// not only to the profile label, so 3D state is never rendered as /scan.
|
||||||
const pointCloudQuery = visualizationStack === 'slam3d' || threeDimensional ? '&pointCloud=1' : ''
|
const pointCloudQuery = visualizationStack === 'slam3d' || threeDimensional ? '&pointCloud=1' : ''
|
||||||
const viewerRuntime = visualizationRuntime ?? (isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation')
|
const viewerRuntime = visualizationRuntime ?? (isRemoteEnvironment(activeEnvironmentProfile) ? 'real' : 'simulation')
|
||||||
const localBridgeProfile = useMemo(() => {
|
const localBridgeProfile = useMemo(() => {
|
||||||
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
|
const configuredBridge = typeof activeEnvironmentConfig.bridge === 'object' && activeEnvironmentConfig.bridge !== null
|
||||||
? activeEnvironmentConfig.bridge as Record<string, unknown>
|
? activeEnvironmentConfig.bridge as Record<string, unknown>
|
||||||
@@ -205,7 +195,6 @@ export function useWorkspaceVisualization({
|
|||||||
? savedStack as VisualizationStack
|
? savedStack as VisualizationStack
|
||||||
: fallback ?? fallbackStack
|
: fallback ?? fallbackStack
|
||||||
setVisualizationStack(stack)
|
setVisualizationStack(stack)
|
||||||
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
|
|
||||||
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
|
}, [fallbackStack, runtimeProfiles, visualizationStackStorageKey, visualizationStacks])
|
||||||
|
|
||||||
const refreshVisualizationStatus = useCallback(async (): Promise<void> => {
|
const refreshVisualizationStatus = useCallback(async (): Promise<void> => {
|
||||||
@@ -227,9 +216,13 @@ export function useWorkspaceVisualization({
|
|||||||
const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam'
|
const rawStatusStack = status.stack === 'autoware' || status.stack === 'nav2' || status.stack === 'slam'
|
||||||
? status.stack
|
? status.stack
|
||||||
: null
|
: null
|
||||||
const statusStack = rawStatusStack === 'nav2' && visualizationStack === 'slam3d' ? 'slam3d' : rawStatusStack
|
// The runtime bridge reports Nav2 for both SLAM modes. Preserve the
|
||||||
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack && visualizationStacks.includes(statusStack)))
|
// map-selected SLAM stack instead of treating it as an unavailable profile.
|
||||||
if (statusStack && visualizationStacks.includes(statusStack)) setVisualizationStack(statusStack)
|
const statusStack = rawStatusStack === 'nav2' && (visualizationStack === 'slam' || visualizationStack === 'slam3d')
|
||||||
|
? visualizationStack
|
||||||
|
: rawStatusStack
|
||||||
|
setVisualizationRunning(Boolean(status.running) && Boolean(statusStack))
|
||||||
|
if (statusStack) setVisualizationStack(statusStack)
|
||||||
setVisualizationStatusMessage(status.running
|
setVisualizationStatusMessage(status.running
|
||||||
? formatVisualizationStackLabel(status.stack)
|
? formatVisualizationStackLabel(status.stack)
|
||||||
: status.message
|
: status.message
|
||||||
|
|||||||
Reference in New Issue
Block a user