add sim mode..

This commit is contained in:
2026-08-20 13:22:27 +09:00
parent f29b44da07
commit e1d7b7d532
20 changed files with 1079 additions and 14828 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

+135 -224
View File
@@ -239,7 +239,6 @@
#downloadMap,
#pathToggle,
#sensorToggle,
#splatToggle,
#poseToggle,
#goalToggle,
#obstacleToggle,
@@ -258,7 +257,6 @@
#downloadMap,
#pathToggle,
#sensorToggle,
#splatToggle,
#poseToggle,
#goalToggle,
#obstacleToggle {
@@ -269,25 +267,6 @@
align-items:center;
gap:6px;
}
#splatToggle {
display:inline-flex;
align-items:center;
justify-content:center;
position:absolute;
top:16px;
left:16px;
z-index:10;
width:14px;
min-width:14px;
min-height:14px;
height:14px;
padding:0;
border-color:rgba(203,213,225,.3);
border-radius:2px;
background:rgba(148,163,184,.3);
box-shadow:none;
}
#splatToggle:hover { background:rgba(148,163,184,.5); border-color:rgba(226,232,240,.45); }
#cameraPresetSelect {
min-width:86px;
padding:0 24px 0 9px;
@@ -301,7 +280,6 @@
#downloadMap:hover,
#pathToggle:hover,
#sensorToggle:hover,
#splatToggle:hover,
#poseToggle:hover,
#goalToggle:hover,
#obstacleToggle:hover,
@@ -332,10 +310,6 @@
border-color:rgba(103,232,249,.55);
color:#fff;
}
#splatToggle.active {
background:rgba(148,163,184,.62);
border-color:rgba(226,232,240,.58);
}
#goalToggle.active {
background:rgba(22,163,74,.62);
border-color:rgba(134,239,172,.55);
@@ -388,13 +362,11 @@
.editor-mode #rosTopicStatusWrap {
display:none;
}
#splatToggle { display:none; }
.nav2-slam #goalToggle, .nav2-slam #poseToggle { display:none; }
.nav2-navigation #downloadWrap { display:none; }
.nav2-navigation #topLeftControls { display:block; }
.nav2-navigation #keyGuide { display:none; }
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.nav2-navigation.real-runtime #splatToggle { display:inline-flex; }
.real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; }
</style>
@@ -403,7 +375,6 @@
<div id="topLeftControls">
<button id="obstacleToggle" title="벽 배치" aria-label="벽 배치" aria-pressed="false">Wall</button>
</div>
<button id="splatToggle" title="3D 맵 표시" aria-label="3D 맵 표시" aria-pressed="false"></button>
<div id="topRightControls">
<div id="downloadWrap">
<button id="downloadMap" title="맵 저장" aria-label="맵 저장" disabled><span>SAVE</span></button>
@@ -452,8 +423,7 @@
{
"imports": {
"three": "../vendor/three/build/three.module.js",
"three/addons/": "../vendor/three/examples/jsm/",
"@mkkellogg/gaussian-splats-3d": "./vendor/gaussian-splats-3d.module.js"
"three/addons/": "../vendor/three/examples/jsm/"
}
}
</script>
@@ -464,7 +434,6 @@ import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { ColladaLoader } from 'three/addons/loaders/ColladaLoader.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { STLLoader } from 'three/addons/loaders/STLLoader.js';
import * as GaussianSplats3D from '@mkkellogg/gaussian-splats-3d';
const viewerMapKey = new URLSearchParams(window.location.search).get('mapKey');
const params = new URLSearchParams(location.search);
@@ -479,6 +448,7 @@ const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
// regular visualization sessions never poll `/camera-frame`.
const ENABLE_CAMERA_PREVIEW = false;
const CUSTOM_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-custom';
const SAVED_GAZEBO_MAP_WORLD = !REAL_RUNTIME && viewerMapKey === 'nav2-saved-gazebo-map';
const activeWorldModel = viewerMapKey === 'nav2-turtlebot3-house' ? 'turtlebot3_house' : 'turtlebot3_world';
const MKMINI_WORLD_SINGLE_PILLAR = USE_MKMINI_MODEL && activeWorldModel === 'turtlebot3_world';
const MKMINI_RENDER_LENGTH = 0.84;
@@ -502,7 +472,6 @@ const mapSaveOverlay = document.getElementById('mapSaveOverlay');
const downloadMapButton = document.getElementById('downloadMap');
const pathToggleButton = document.getElementById('pathToggle');
const sensorToggleButton = document.getElementById('sensorToggle');
const splatToggleButton = document.getElementById('splatToggle');
const poseToggleButton = document.getElementById('poseToggle');
const goalToggleButton = document.getElementById('goalToggle');
const obstacleToggleButton = document.getElementById('obstacleToggle');
@@ -513,7 +482,6 @@ const rosTopicStatusToggle = document.getElementById('rosTopicStatusToggle');
const rosTopicStatusList = document.getElementById('rosTopicStatusList');
const EDITOR_MODE = params.get('editor') === '1';
const NAV2_VIEW_MODE = params.get('mode') === 'navigation' ? 'navigation' : 'slam';
const SHOW_REMOTE_NAVIGATION_ROOM = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
// A navigation session must keep the selected saved map as its fixed
// reference. The runtime /map topic may later emit a partial local update.
const LOCK_NAVIGATION_MAP = REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation';
@@ -601,10 +569,10 @@ const camera = new THREE.PerspectiveCamera(45, innerWidth / innerHeight, 0.05, 1
// Three.js OrbitControls 기본 조작이 자연스럽게 동작하도록 사선 시점에서 시작합니다.
camera.position.set(4, 6, 5);
camera.up.set(0, 1, 0);
const renderer = new THREE.WebGLRenderer({ antialias:!SHOW_REMOTE_NAVIGATION_ROOM });
const renderer = new THREE.WebGLRenderer({ antialias:true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(SHOW_REMOTE_NAVIGATION_ROOM ? 1 : Math.min(devicePixelRatio, 1.25));
renderer.shadowMap.enabled = !SHOW_REMOTE_NAVIGATION_ROOM;
renderer.setPixelRatio(Math.min(devicePixelRatio, 1.25));
renderer.shadowMap.enabled = true;
renderer.domElement.tabIndex = 0;
document.body.appendChild(renderer.domElement);
@@ -615,7 +583,7 @@ controls.mouseButtons = {
RIGHT: THREE.MOUSE.DOLLY,
};
renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
controls.enableDamping = !SHOW_REMOTE_NAVIGATION_ROOM;
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.target.set(0, 0, 0);
camera.lookAt(controls.target);
@@ -658,15 +626,6 @@ gazeboWorldRoot.add(runtimeLayoutObjects);
const navigationMapRoot = new THREE.Group();
navigationMapRoot.name = 'navigation-occupancy-map';
sdfRoot.add(navigationMapRoot);
let remoteNavigationSplatViewer = null;
let remoteNavigationSplatScene = null;
let remoteNavigationSplatTransform = null;
let remoteNavigationSplatEnabled = false;
let remoteNavigationSplatLoading = null;
// 화면의 빨간 X축과 평행하게 오른쪽으로 미세 보정합니다.
const remoteNavigationSplatBasePosition = new THREE.Vector3(-1.6822845855746674, 1.3107954323633903, -0.8021416967574936);
const remoteNavigationSplatBaseQuaternion = new THREE.Quaternion(-0.10999094822602469, 0.6984998148234114, 0.6984998148234114, 0.10999094822602469);
const remoteNavigationSplatBaseScale = new THREE.Vector3(3.672624762730334, 3.672624762730334, 3.5309628207501587);
const pointCloudGeometry = SHOW_POINT_CLOUD ? new THREE.BufferGeometry() : null;
let pointCloudLayer = null;
let hasGlobalMapPointCloud = false;
@@ -894,6 +853,10 @@ let lastMkMiniSteeringUpdateMs = null;
let lastMkMiniMotionSample = null;
let hasSimulatorMkMiniSteering = false;
let laserGroup = null;
let laserFillMesh = null;
let laserLineSegments = null;
let laserOpenSpaceLineSegments = null;
let laserFrontBeam = null;
let slamSocket = null;
let controlSocket = null;
let latestSlamState = null;
@@ -908,6 +871,10 @@ let navigationMapLocked = false;
let navigationMapFocused = false;
let latestScanMetadata = null;
let latestScanRanges = null;
let lastLaserRenderAtMs = 0;
let lastMiniMapRenderAtMs = 0;
let pendingMiniMapState = null;
let miniMapRenderTimer = null;
let pointCloudRequestInFlight = false;
let pointCloudRefreshTimer = null;
let savedMapPointCloudLoaded = false;
@@ -1432,70 +1399,10 @@ function createMkMiniFrontWheelPivots(model) {
return pivots;
}
async function loadRemoteNavigationRoom() {
const viewer = new GaussianSplats3D.Viewer({
selfDrivenMode: false,
threeScene: scene,
renderer,
camera,
useBuiltInControls: false,
ignoreDevicePixelRatio: true,
gpuAcceleratedSort: false,
sharedMemoryForWorkers: false,
integerBasedSort: true,
halfPrecisionCovariancesOnGPU: true,
dynamicScene: true,
renderMode: GaussianSplats3D.RenderMode.Always,
sceneRevealMode: GaussianSplats3D.SceneRevealMode.Instant,
antialiased: false,
sphericalHarmonicsDegree: 0,
enableOptionalEffects: false,
inMemoryCompressionLevel: 2,
});
await viewer.addSplatScene('./assets/room_0731_navigation.ply', {
position: remoteNavigationSplatBasePosition.toArray(),
rotation: remoteNavigationSplatBaseQuaternion.toArray(),
scale: remoteNavigationSplatBaseScale.toArray(),
splatAlphaRemovalThreshold: 0,
showLoadingUI: false,
});
remoteNavigationSplatViewer = viewer;
remoteNavigationSplatScene = viewer.splatMesh.getScene(0);
remoteNavigationSplatTransform = null;
updateRemoteNavigationRoomTransform();
}
async function setRemoteNavigationSplatVisible(visible) {
if (!SHOW_REMOTE_NAVIGATION_ROOM) return;
splatToggleButton.disabled = true;
try {
if (visible && !remoteNavigationSplatViewer) {
if (!remoteNavigationSplatLoading) {
remoteNavigationSplatLoading = loadRemoteNavigationRoom();
}
await remoteNavigationSplatLoading;
}
remoteNavigationSplatEnabled = visible;
if (remoteNavigationSplatScene) {
remoteNavigationSplatScene.visible = visible;
if (visible) {
remoteNavigationSplatTransform = null;
updateRemoteNavigationRoomTransform();
}
}
remoteNavigationSplatViewer?.forceRenderNextFrame();
} catch (error) {
console.warn('remote navigation room render failed', error);
remoteNavigationSplatEnabled = false;
} finally {
splatToggleButton.disabled = false;
splatToggleButton.classList.toggle('active', remoteNavigationSplatEnabled);
splatToggleButton.setAttribute('aria-pressed', String(remoteNavigationSplatEnabled));
}
}
const LASER_HOLE_R = 0.14;
const LASER_RENDER_INTERVAL_MS = 100;
const LASER_OPEN_SPACE_RAY_STRIDE = 4;
const MINI_MAP_RENDER_INTERVAL_MS = 100;
const LASER_OFFSET_X = USE_MKMINI_MODEL ? 0.15 : -0.032;
const LASER_OFFSET_Y = 0;
// Fallback height when an explicit ROS scan pose is unavailable.
@@ -1507,9 +1414,12 @@ function setRobotPose(pose) {
targetRobotPose = {
x: Number(pose.x ?? targetRobotPose.x),
y: Number(pose.y ?? targetRobotPose.y),
z: Number(pose.z ?? targetRobotPose.z),
roll: Number(pose.roll ?? targetRobotPose.roll),
pitch: Number(pose.pitch ?? targetRobotPose.pitch),
// Gazebo navigation is a ground-plane simulation. A saved 3D map can
// include an elevated RTAB-Map pose, but that must not lift the rendered
// vehicle after the learner supplies a 2D initial pose.
z: REAL_RUNTIME ? Number(pose.z ?? targetRobotPose.z) : 0,
roll: REAL_RUNTIME ? Number(pose.roll ?? targetRobotPose.roll) : 0,
pitch: REAL_RUNTIME ? Number(pose.pitch ?? targetRobotPose.pitch) : 0,
yaw: Number(pose.yaw ?? targetRobotPose.yaw),
};
updateMkMiniSteeringTarget(pose);
@@ -1750,11 +1660,72 @@ function laserDirectionFromPose(pose, angle) {
return new THREE.Vector3(Math.cos(angle), Math.sin(angle), 0).applyQuaternion(q).normalize();
}
function ensureLaserScanLayer() {
if (laserGroup) {
laserGroup.visible = true;
return;
}
laserGroup = new THREE.Group();
laserGroup.name = 'live-laser-scan';
const fillGeometry = new THREE.BufferGeometry();
laserFillMesh = new THREE.Mesh(
fillGeometry,
new THREE.MeshBasicMaterial({
color: 0x005cff,
transparent: true,
opacity: 0.6,
side: THREE.DoubleSide,
depthWrite: false,
}),
);
laserFillMesh.frustumCulled = false;
const lineGeometry = new THREE.BufferGeometry();
laserLineSegments = new THREE.LineSegments(
lineGeometry,
new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 }),
);
laserLineSegments.frustumCulled = false;
const openSpaceGeometry = new THREE.BufferGeometry();
laserOpenSpaceLineSegments = new THREE.LineSegments(
openSpaceGeometry,
new THREE.LineBasicMaterial({ color: 0x6f9dff, transparent: true, opacity: 0.28 }),
);
laserOpenSpaceLineSegments.frustumCulled = false;
laserFrontBeam = new THREE.Mesh(
new THREE.CylinderGeometry(0.006, 0.006, 1, 16),
new THREE.MeshBasicMaterial({ color: 0x005cff }),
);
laserFrontBeam.frustumCulled = false;
laserGroup.add(laserFillMesh, laserLineSegments, laserOpenSpaceLineSegments, laserFrontBeam);
sdfRoot.add(laserGroup);
}
function updateLaserGeometry(geometry, values) {
let position = geometry.getAttribute('position');
if (!position || position.array.length < values.length) {
const capacity = Math.max(1024, Math.ceil(values.length * 1.25));
position = new THREE.BufferAttribute(new Float32Array(capacity), 3);
position.setUsage(THREE.DynamicDrawUsage);
geometry.setAttribute('position', position);
}
position.array.set(values);
position.needsUpdate = true;
geometry.setDrawRange(0, values.length / 3);
}
function clearLaserScan() {
if (!laserGroup) return;
sdfRoot.remove(laserGroup);
disposeObject(laserGroup);
laserGroup = null;
laserGroup.visible = false;
laserFillMesh.visible = false;
laserLineSegments.visible = false;
laserOpenSpaceLineSegments.visible = false;
laserFrontBeam.visible = false;
}
function setNavigationSensorVisible(visible) {
@@ -1767,6 +1738,7 @@ function setNavigationSensorVisible(visible) {
window.clearInterval(sensorPointCloudRefreshTimer);
sensorPointCloudRefreshTimer = null;
}
lastLaserRenderAtMs = 0;
clearLaserScan();
clearSensorPointCloud();
drawMiniMap(latestSlamState);
@@ -1775,7 +1747,7 @@ function setNavigationSensorVisible(visible) {
if (SHOW_SENSOR_POINT_CLOUD) {
void refreshSensorPointCloud();
if (!sensorPointCloudRefreshTimer) {
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500);
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
}
}
if (latestScanMetadata && latestScanRanges) {
@@ -1796,6 +1768,11 @@ function drawLaserScan(scan, scanPose = (
return;
}
if (!scan?.ranges?.length) return;
// The state socket and scan binary socket can deliver the same scan nearly
// together. Rebuilding transparent scan geometry twice stalls the viewer.
const now = performance.now();
if (now - lastLaserRenderAtMs < LASER_RENDER_INTERVAL_MS) return;
lastLaserRenderAtMs = now;
clearLaserScan();
const sourcePose = scanPose || currentRobotPose;
@@ -1830,7 +1807,7 @@ function drawLaserScan(scan, scanPose = (
endpoints.push({ start, point, hit: valid });
if (valid) {
linePoints.push(start.x, start.y, start.z, point.x, point.y, point.z);
} else if (showOpenSpaceRays) {
} else if (showOpenSpaceRays && i % LASER_OPEN_SPACE_RAY_STRIDE === 0) {
// 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);
@@ -1850,32 +1827,13 @@ function drawLaserScan(scan, scanPose = (
fillPoints.push(next.start.x, next.start.y, next.start.z);
}
const grp = new THREE.Group();
const fillMat = new THREE.MeshBasicMaterial({
color: 0x005cff,
transparent: true,
opacity: 0.6,
side: THREE.DoubleSide,
depthWrite: false,
});
const lineMat = new THREE.LineBasicMaterial({ color: 0x3366ff, transparent: true, opacity: 0.5 });
if (fillPoints.length) {
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.Float32BufferAttribute(fillPoints, 3));
grp.add(new THREE.Mesh(geo, fillMat));
}
const lineGeo = new THREE.BufferGeometry();
lineGeo.setAttribute('position', new THREE.Float32BufferAttribute(linePoints, 3));
grp.add(new THREE.LineSegments(lineGeo, lineMat));
if (openSpaceLinePoints.length) {
const openSpaceGeo = new THREE.BufferGeometry();
openSpaceGeo.setAttribute('position', new THREE.Float32BufferAttribute(openSpaceLinePoints, 3));
grp.add(new THREE.LineSegments(
openSpaceGeo,
new THREE.LineBasicMaterial({ color: 0x6f9dff, transparent: true, opacity: 0.28 }),
));
}
ensureLaserScanLayer();
updateLaserGeometry(laserFillMesh.geometry, fillPoints);
laserFillMesh.visible = fillPoints.length > 0;
updateLaserGeometry(laserLineSegments.geometry, linePoints);
laserLineSegments.visible = linePoints.length > 0;
updateLaserGeometry(laserOpenSpaceLineSegments.geometry, openSpaceLinePoints);
laserOpenSpaceLineSegments.visible = openSpaceLinePoints.length > 0;
const frontIndex = Math.max(0, Math.min(scan.ranges.length - 1, Math.round((0 - angleMin) / angleInc)));
const front = endpoints[frontIndex];
@@ -1889,22 +1847,20 @@ function drawLaserScan(scan, scanPose = (
);
const len = delta.length();
if (len > 0.01) {
const beam = new THREE.Mesh(
new THREE.CylinderGeometry(0.006, 0.006, len, 16),
new THREE.MeshBasicMaterial({ color: 0x005cff })
);
beam.position.set(
laserFrontBeam.position.set(
(front.start.x + front.point.x) / 2,
(front.start.y + front.point.y) / 2,
(front.start.z + front.point.z) / 2
);
beam.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
grp.add(beam);
laserFrontBeam.scale.set(1, len, 1);
laserFrontBeam.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize());
laserFrontBeam.visible = true;
} else {
laserFrontBeam.visible = false;
}
} else {
laserFrontBeam.visible = false;
}
laserGroup = grp;
sdfRoot.add(grp);
}
function updatePointGeometry(geometry, points) {
@@ -1923,6 +1879,13 @@ function updatePointGeometry(geometry, points) {
positions[count++] = z;
}
if (count === 0) {
geometry.deleteAttribute('position');
geometry.boundingBox = null;
geometry.boundingSphere = null;
return;
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions.slice(0, count), 3));
geometry.computeBoundingSphere();
}
@@ -2061,6 +2024,19 @@ async function refreshCameraFrame() {
}
function drawMiniMap(state) {
pendingMiniMapState = state;
if (miniMapRenderTimer) return;
const elapsed = performance.now() - lastMiniMapRenderAtMs;
const wait = Math.max(0, MINI_MAP_RENDER_INTERVAL_MS - elapsed);
miniMapRenderTimer = window.setTimeout(() => {
miniMapRenderTimer = null;
lastMiniMapRenderAtMs = performance.now();
drawMiniMapNow(pendingMiniMapState);
}, wait);
}
function drawMiniMapNow(state) {
if (!miniCtx) return;
const w = miniMap.width;
const h = miniMap.height;
@@ -2247,7 +2223,6 @@ function updateNavigationMapTransform() {
// estimate and must not move the static world geometry.
gazeboWorldRoot.position.set(0, 0, 0);
gazeboWorldRoot.rotation.set(0, 0, 0);
updateRemoteNavigationRoomTransform();
return;
}
gazeboWorldRoot.position.set(0, 0, 0);
@@ -2263,61 +2238,6 @@ function updateNavigationMapTransform() {
0,
);
navigationMapRoot.rotation.set(0, 0, -yaw);
updateRemoteNavigationRoomTransform();
}
function updateRemoteNavigationRoomTransform() {
if (!remoteNavigationSplatEnabled || !remoteNavigationSplatScene || NAV2_VIEW_MODE !== 'navigation') return;
if (navigationSceneUsesMapFrame()) {
if (remoteNavigationSplatTransform?.frame === 'map') return;
remoteNavigationSplatScene.position.copy(remoteNavigationSplatBasePosition);
remoteNavigationSplatScene.quaternion.copy(remoteNavigationSplatBaseQuaternion);
remoteNavigationSplatScene.scale.copy(remoteNavigationSplatBaseScale);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = { frame: 'map' };
return;
}
const transform = navigationMapToOdom || { x:0, y:0, yaw:0 };
if (remoteNavigationSplatTransform
&& remoteNavigationSplatTransform.frame === 'odom'
&& Math.abs(remoteNavigationSplatTransform.x - (transform.x || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.y - (transform.y || 0)) < 0.03
&& Math.abs(remoteNavigationSplatTransform.yaw - (transform.yaw || 0)) < 0.01) {
return;
}
const yaw = transform.yaw || 0;
const c = Math.cos(yaw);
const s = Math.sin(yaw);
// The Gaussian scene is authored in map coordinates. Compose the same
// map <- odom inverse used by the occupancy-map root before each render.
const mapToOdomMatrix = new THREE.Matrix4().compose(
new THREE.Vector3(
-c * (transform.x || 0) - s * (transform.y || 0),
0,
-s * (transform.x || 0) + c * (transform.y || 0),
),
new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -yaw),
new THREE.Vector3(1, 1, 1),
);
const sceneMatrix = mapToOdomMatrix.multiply(new THREE.Matrix4().compose(
remoteNavigationSplatBasePosition,
remoteNavigationSplatBaseQuaternion,
remoteNavigationSplatBaseScale,
));
sceneMatrix.decompose(
remoteNavigationSplatScene.position,
remoteNavigationSplatScene.quaternion,
remoteNavigationSplatScene.scale,
);
remoteNavigationSplatViewer.splatMesh.updateTransforms();
remoteNavigationSplatTransform = {
frame: 'odom',
x: transform.x || 0,
y: transform.y || 0,
yaw,
};
}
function renderNavigationOccupancyMap() {
@@ -2615,11 +2535,6 @@ sensorToggleButton.addEventListener('click', () => {
focusViewer();
});
splatToggleButton.addEventListener('click', () => {
void setRemoteNavigationSplatVisible(!remoteNavigationSplatEnabled);
focusViewer();
});
cameraPreviewToggle?.addEventListener('click', () => {
const collapsed = cameraPreviewWrap?.classList.toggle('is-collapsed') ?? false;
cameraPreviewToggle.setAttribute('aria-expanded', String(!collapsed));
@@ -3161,7 +3076,7 @@ function applySlamState(state) {
updateNavigationPlan(state?.plan);
// Remote navigation is rendered in the map frame, matching the saved map,
// room model, AMCL pose, route and goal. Local Gazebo remains odom-based.
const visualizationPose = (SHOW_POINT_CLOUD || (REAL_RUNTIME && NAV2_VIEW_MODE === 'navigation' && state?.pose_3d))
const visualizationPose = (REAL_RUNTIME && (SHOW_POINT_CLOUD || (NAV2_VIEW_MODE === 'navigation' && state?.pose_3d)))
? (state?.pose_3d || state?.pose)
: (NAV2_VIEW_MODE === 'navigation'
? (navigationSceneUsesMapFrame() ? (state?.pose_map || state?.pose) : state?.pose)
@@ -3230,7 +3145,7 @@ if (SHOW_POINT_CLOUD) {
}
if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
void refreshSensorPointCloud();
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 500);
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
}
if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) {
void refreshCameraFrame();
@@ -3240,6 +3155,7 @@ if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) {
window.addEventListener('beforeunload', () => {
if (pointCloudRefreshTimer) window.clearInterval(pointCloudRefreshTimer);
if (sensorPointCloudRefreshTimer) window.clearInterval(sensorPointCloudRefreshTimer);
if (miniMapRenderTimer) window.clearTimeout(miniMapRenderTimer);
if (cameraFrameRefreshTimer) window.clearInterval(cameraFrameRefreshTimer);
if (cameraFrameObjectUrl) URL.revokeObjectURL(cameraFrameObjectUrl);
});
@@ -3459,7 +3375,7 @@ window.addEventListener('blur', () => {
// A static Gazebo model can contain assets that the browser cannot render.
// Keep the ROS stream available even when the decorative world mesh fails.
const worldSceneTasks = CUSTOM_WORLD || REAL_RUNTIME
const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
? []
: [buildFromSdf().catch((error) => console.warn('world model render failed', error))];
const remoteNavigationSceneTasks = [];
@@ -3488,12 +3404,7 @@ function animate(){
updateSmoothedRobotPose();
updateMkMiniFrontWheelSteering();
controls.update();
if (remoteNavigationSplatViewer && remoteNavigationSplatEnabled) {
remoteNavigationSplatViewer.update();
remoteNavigationSplatViewer.render();
} else {
renderer.render(scene, camera);
}
renderer.render(scene, camera);
}
animate();
</script>
File diff suppressed because it is too large Load Diff
+167 -78
View File
@@ -44,7 +44,7 @@ import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleT
import ModuleListView from './features/workspace/ModuleListView'
import WorkspaceRosConnectionsView from './features/workspace/WorkspaceRosConnectionsView'
import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, WorldMapRestartConfirmModal, type SavedMapRuntimeKind, type WorkspaceMapOption, type WorkspaceMapScope } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -407,7 +407,7 @@ type WorkspaceVisualizationHost = {
viewerUrl: string
onStart: () => Promise<void>
onTerminate: () => Promise<void>
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void
onToggleLarge: () => void
onMinimize: () => void
onClose: () => void
@@ -435,6 +435,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentConfigOverride,
platformOptions,
workspaceVisible,
visualizationContainerRef,
skipInitialLoading,
visualizationOpen,
onVisualizationOpenChange,
@@ -484,6 +485,7 @@ function LocalPcWorkspaceMockup({
platformEnvironmentConfigOverride?: Record<string, unknown> | null
platformOptions: PlatformOption[]
workspaceVisible: boolean
visualizationContainerRef: React.RefObject<HTMLDivElement | null>
skipInitialLoading?: boolean
visualizationOpen: boolean
onVisualizationOpenChange: WorkspaceVisualizationStateSetter<boolean>
@@ -801,12 +803,16 @@ function LocalPcWorkspaceMockup({
const sessionMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:selected`
const simulationMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:simulation`
const platformMapStorageKey = `atnmy:lms:map-session:${studentSlug}:${courseKey}:week-${weekNumber}:platform`
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => (
window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey)
const [selectedSimulationMapKey, setSelectedSimulationMapKey] = useState(() => (
window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(sessionMapStorageKey)
?? 'nav2-turtlebot3-world'
))
const [selectedPlatformMapKey, setSelectedPlatformMapKey] = useState(() => (
window.localStorage.getItem(platformMapStorageKey)
?? window.localStorage.getItem(sessionMapStorageKey)
?? 'nav2-platform-slam-2d'
))
const [savedSlamMaps, setSavedSlamMaps] = useState<WorkspaceMapOption[]>([])
const [savedSlamMapsLoaded, setSavedSlamMapsLoaded] = useState(false)
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
@@ -814,14 +820,14 @@ function LocalPcWorkspaceMockup({
const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
.map((map) => {
if (map.key === 'nav2-turtlebot3-world') return { ...map, description: '기본 장애물 환경' }
if (map.key === 'nav2-turtlebot3-house') return { ...map, description: '방과 복도로 구성된 실내 환경' }
return map
if (map.key === 'nav2-turtlebot3-world') return { ...map, scope: 'simulation' as const, description: '기본 장애물 환경' }
if (map.key === 'nav2-turtlebot3-house') return { ...map, scope: 'simulation' as const, description: '방과 복도로 구성된 실내 환경' }
return { ...map, scope: 'simulation' as const }
})
const customMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
const baseMaps = configuredMaps.some((map) => map.key === 'nav2-custom') || !customMap
? [...configuredMaps]
: [...configuredMaps, customMap]
: [...configuredMaps, { ...customMap, scope: 'simulation' as const }]
const platformSlamMaps: WorkspaceMapOption[] = [
{
key: 'nav2-platform-slam-2d',
@@ -832,6 +838,7 @@ function LocalPcWorkspaceMockup({
viewerModel: 'turtlebot3_world',
remoteSlam: true,
runtimeKind: 'PLATFORM_2D',
scope: 'platform',
},
{
key: 'nav2-platform-slam-3d',
@@ -842,9 +849,26 @@ function LocalPcWorkspaceMockup({
viewerModel: 'turtlebot3_world',
remoteSlam: true,
runtimeKind: 'PLATFORM_3D',
scope: 'platform',
},
]
const maps = [...baseMaps, ...platformSlamMaps, ...savedSlamMaps]
const persistedMaps = savedSlamMaps.map((map) => ({
...map,
scope: map.runtimeKind === 'SIMULATION' ? 'simulation' as const : 'platform' as const,
}))
const simulationCopiesOfPlatformMaps = savedSlamMaps
.filter((map) => map.runtimeKind === 'PLATFORM_2D' || map.runtimeKind === 'PLATFORM_3D')
.map((map) => ({
...map,
key: `${map.key}:simulation`,
description: map.runtimeKind === 'PLATFORM_3D'
? '저장된 3D SLAM 결과를 복셀 Gazebo 환경에서 사용합니다.'
: '저장된 2D SLAM 결과 맵을 Gazebo 환경에서 사용합니다.',
worldFile: 'empty_world.world',
scope: 'simulation' as const,
gazeboWorldFromMap: true,
}))
const maps = [...baseMaps, ...platformSlamMaps, ...persistedMaps, ...simulationCopiesOfPlatformMaps]
const keys = new Set<string>()
return maps.filter((map) => {
if (keys.has(map.key)) return false
@@ -852,48 +876,54 @@ function LocalPcWorkspaceMockup({
return true
})
}, [curriculumWeek.maps, savedSlamMaps])
const simulationWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => map.scope === 'simulation')
), [sessionWorldMaps])
const platformWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => map.scope === 'platform')
), [sessionWorldMaps])
useEffect(() => {
const storedMapKey = window.localStorage.getItem(sessionMapStorageKey)
?? window.localStorage.getItem(simulationMapStorageKey)
?? window.localStorage.getItem(platformMapStorageKey)
const normalizedStoredMapKey = storedMapKey === 'nav2-platform-slam'
? 'nav2-platform-slam-2d'
: storedMapKey
const requestedMapKey = selectedWorldMapKey === 'nav2-platform-slam'
? 'nav2-platform-slam-2d'
: selectedWorldMapKey || normalizedStoredMapKey
if (!savedSlamMapsLoaded && requestedMapKey?.startsWith('saved-slam-')) return
setSelectedWorldMapKey((current) => {
const next = sessionWorldMaps.some((map) => map.key === current)
? current
: sessionWorldMaps.some((map) => map.key === normalizedStoredMapKey)
? normalizedStoredMapKey!
: sessionWorldMaps[0]?.key ?? 'nav2-turtlebot3-world'
window.localStorage.setItem(sessionMapStorageKey, next)
const legacyMapKey = window.localStorage.getItem(sessionMapStorageKey)
const normalizeMapKey = (key: string | null, scope: WorkspaceMapScope): string | null => {
if (key === 'nav2-platform-slam') return scope === 'platform' ? 'nav2-platform-slam-2d' : null
return key
}
const resolveSelection = (
scope: WorkspaceMapScope,
current: string,
storageKey: string,
maps: WorkspaceMapOption[],
fallback: string,
): string => {
const stored = normalizeMapKey(window.localStorage.getItem(storageKey) ?? legacyMapKey, scope)
const requested = normalizeMapKey(current, scope) ?? stored
if (!savedSlamMapsLoaded && requested?.startsWith('saved-slam-')) return current
const next = maps.some((map) => map.key === requested)
? requested!
: maps.some((map) => map.key === stored)
? stored!
: maps.find((map) => map.key === fallback)?.key ?? maps[0]?.key ?? fallback
window.localStorage.setItem(storageKey, next)
return next
})
}, [platformMapStorageKey, savedSlamMapsLoaded, selectedWorldMapKey, sessionMapStorageKey, sessionWorldMaps, simulationMapStorageKey])
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? sessionWorldMaps[0]
}
setSelectedSimulationMapKey((current) => resolveSelection(
'simulation', current, simulationMapStorageKey, simulationWorldMaps, 'nav2-turtlebot3-world',
))
setSelectedPlatformMapKey((current) => resolveSelection(
'platform', current, platformMapStorageKey, platformWorldMaps, 'nav2-platform-slam-2d',
))
}, [platformMapStorageKey, platformWorldMaps, savedSlamMapsLoaded, sessionMapStorageKey, simulationMapStorageKey, simulationWorldMaps])
const selectedWorldMapKey = remoteWorkspace ? selectedPlatformMapKey : selectedSimulationMapKey
const selectedWorldMap = sessionWorldMaps.find((map) => map.key === selectedWorldMapKey)
?? (remoteWorkspace ? platformWorldMaps[0] : simulationWorldMaps[0])
const prepareVisualizationSavedMapRef = useRef<(
() => Promise<{ savedMapDatabasePath?: string }>
) | null>(null)
const isPlatformWorldMap = selectedWorldMap?.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap?.remoteSlam === true
const simulationWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => (
map.runtimeKind !== 'PLATFORM_2D'
&& map.runtimeKind !== 'PLATFORM_3D'
&& !map.remoteSlam
))
), [sessionWorldMaps])
const platformWorldMaps = useMemo(() => (
sessionWorldMaps.filter((map) => (
map.runtimeKind === 'PLATFORM_2D'
|| map.runtimeKind === 'PLATFORM_3D'
|| map.remoteSlam
))
), [sessionWorldMaps])
const savedMapRunsInSimulation = !remoteWorkspace
&& selectedWorldMap?.saved === true
&& selectedWorldMap.gazeboWorldFromMap === true
const saved3dMapUses2dSimulationNavigation = savedMapRunsInSimulation
&& selectedWorldMap?.runtimeKind === 'PLATFORM_3D'
const visualizationProfiles = useMemo(() => (
[
...(curriculumWeek.environmentProfiles ?? []),
@@ -1040,18 +1070,18 @@ function LocalPcWorkspaceMockup({
bridgeLocation: visualizationBridgeLocation,
bridgeWorkspaceRoot: remoteWorkspace ? localVisualizationWorkspaceRoot : clonePathPreview,
sessionKey: `${studentSlug}:${courseKey}:week-${weekNumber}`,
mapKey: selectedWorldMap.saved ? selectedWorldMap.sourceMapKey : selectedWorldMap.key,
mapKey: selectedWorldMap.gazeboWorldFromMap
? 'nav2-saved-gazebo-map'
: selectedWorldMap.saved
? selectedWorldMap.sourceMapKey
: selectedWorldMap.key,
platform: visualizationPlatform,
threeDimensional: Boolean(
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
),
savedMapDatabasePath: selectedWorldMap.databasePath,
prepareSavedMap: async () => prepareVisualizationSavedMapRef.current?.() ?? {},
visualizationRuntime: selectedWorldMap.runtimeKind === 'PLATFORM_2D'
|| selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| selectedWorldMap.remoteSlam
? 'real'
: 'simulation',
visualizationRuntime: remoteWorkspace ? 'real' : 'simulation',
worldLayoutVersion,
onTitlebarNotice
})
@@ -1178,7 +1208,7 @@ function LocalPcWorkspaceMockup({
databasePath,
databaseUrl: map.databaseUrl ?? undefined,
runtimeKind: map.runtimeKind
?? (map.sourceMapKey === 'nav2-platform-slam' ? 'PLATFORM_2D' : 'SIMULATION'),
?? (/^nav2-platform-slam(?:-2d)?$/i.test(map.sourceMapKey) ? 'PLATFORM_2D' : 'SIMULATION'),
}
}))
} catch (error) {
@@ -2001,7 +2031,7 @@ function LocalPcWorkspaceMockup({
}
}
const getPreviewContainerSize = (): { width: number; height: number } => {
const rect = workspaceRootRef.current?.getBoundingClientRect()
const rect = visualizationContainerRef.current?.getBoundingClientRect()
return {
width: rect?.width ?? window.innerWidth,
height: rect?.height ?? window.innerHeight
@@ -2105,24 +2135,39 @@ function LocalPcWorkspaceMockup({
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
}
const startPreviewDrag = (event: React.MouseEvent<HTMLDivElement>): void => {
if (previewLarge) return
const startPreviewDrag = (event: React.PointerEvent<HTMLDivElement>): void => {
if (previewLarge || event.button !== 0) return
event.preventDefault()
const dragTarget = event.currentTarget
const pointerId = event.pointerId
const startX = event.clientX
const startY = event.clientY
const startPosition = previewPosition
const onMove = (moveEvent: MouseEvent): void => {
const cleanup = (): void => {
dragTarget.removeEventListener('pointermove', onMove)
dragTarget.removeEventListener('pointerup', finishDrag)
dragTarget.removeEventListener('pointercancel', finishDrag)
dragTarget.removeEventListener('lostpointercapture', finishDrag)
}
const onMove = (moveEvent: PointerEvent): void => {
if (moveEvent.pointerId !== pointerId) return
setPreviewPosition(clampPreviewPosition({
x: Math.max(12, startPosition.x + moveEvent.clientX - startX),
y: Math.max(48, startPosition.y + moveEvent.clientY - startY)
}))
}
const onUp = (): void => {
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', onUp)
const finishDrag = (finishEvent: PointerEvent): void => {
if (finishEvent.pointerId !== pointerId) return
cleanup()
if (dragTarget.hasPointerCapture(pointerId)) {
dragTarget.releasePointerCapture(pointerId)
}
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', onUp)
dragTarget.setPointerCapture(pointerId)
dragTarget.addEventListener('pointermove', onMove)
dragTarget.addEventListener('pointerup', finishDrag)
dragTarget.addEventListener('pointercancel', finishDrag)
dragTarget.addEventListener('lostpointercapture', finishDrag)
}
const startPreviewResize = (event: React.MouseEvent<HTMLDivElement>, direction: VisualizationResizeDirection): void => {
if (previewLarge) return
@@ -2464,8 +2509,9 @@ function LocalPcWorkspaceMockup({
{ name: 'pgm' as const, fileName: pgmFileName, url: selectedWorldMap.pgmUrl },
]
let databasePath: string | undefined
let databaseFileName: string | undefined
if (selectedWorldMap.databasePath || selectedWorldMap.databaseUrl) {
const databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : ''
databaseFileName = selectedWorldMap.databasePath ? fileName(selectedWorldMap.databasePath) : ''
if (!databaseFileName.endsWith('.db') || !selectedWorldMap.databaseUrl) {
throw new Error('저장된 3D SLAM 데이터가 완전하지 않습니다.')
}
@@ -2483,12 +2529,29 @@ function LocalPcWorkspaceMockup({
artifacts,
)
}
if (!remoteWorkspace && selectedWorldMap.gazeboWorldFromMap) {
if (selectedWorldMap.runtimeKind === 'PLATFORM_3D') {
if (!databaseFileName) throw new Error('시뮬레이션할 3D SLAM 데이터베이스가 없습니다.')
await window.localWorkspace.maps.generatePointCloudCollisionLayout(clonePathPreview, databaseFileName)
} else {
await window.localWorkspace.maps.generateCollisionLayout(clonePathPreview, yamlFileName, pgmFileName)
}
}
return {
savedMapYamlPath: `/workspace/maps/${yamlFileName}`,
savedMapDatabasePath: databasePath,
}
}, [clonePathPreview, getRemoteWorkspaceTarget, localVisualizationWorkspaceRoot, remoteWorkspace, selectedWorldMap, visualizationBridgeLocation])
prepareVisualizationSavedMapRef.current = prepareSavedMap
const prepareModuleSavedMap = useCallback(async () => {
const preparedMap = await prepareSavedMap()
// The saved 3D database is retained for visualization and Gazebo-world
// generation. Local simulation itself localizes against the exported 2D
// occupancy map using the ordinary /scan pipeline.
return saved3dMapUses2dSimulationNavigation
? { ...preparedMap, savedMapDatabasePath: undefined }
: preparedMap
}, [prepareSavedMap, saved3dMapUses2dSimulationNavigation])
const vehicleRuntimeProfiles = useMemo(() => {
const profiles = new Map<string, NonNullable<PlatformOption['vehicleRuntimeProfile']>>()
platformOptions.forEach((platform) => {
@@ -2522,10 +2585,12 @@ function LocalPcWorkspaceMockup({
workspaceActive: workspaceVisible,
sourceSettings: runtimeSourceSettings,
rosNetwork: moduleRosNetworkConfig,
simulatorWorldFile: selectedWorldMap.worldFile,
simulatorWorldFile: savedMapRunsInSimulation ? 'empty_world.world' : selectedWorldMap.worldFile,
savedMapYamlPath: selectedWorldMap.saved ? selectedWorldMap.yamlPath : null,
savedMapDatabasePath: selectedWorldMap.saved ? selectedWorldMap.databasePath : null,
prepareSavedMap,
savedMapDatabasePath: saved3dMapUses2dSimulationNavigation
? null
: selectedWorldMap.saved ? selectedWorldMap.databasePath : null,
prepareSavedMap: prepareModuleSavedMap,
bulkExcludedModuleKeys: parkedModuleKeys,
activeRuntimeImages: moduleRuntimeImages,
vehicleRuntimeProfiles,
@@ -2548,12 +2613,23 @@ function LocalPcWorkspaceMockup({
return () => onRegisterRunningModuleNames?.(null)
}, [availableModules, onRegisterRunningModuleNames, onRunningModuleCountChange])
const applyWorldMapSelection = async (map: WorkspaceMapOption): Promise<void> => {
window.localStorage.setItem(sessionMapStorageKey, map.key)
setSelectedWorldMapKey(map.key)
if (map.custom && map.layout && !remoteWorkspace) {
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
const storageKey = scope === 'platform' ? platformMapStorageKey : simulationMapStorageKey
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
window.localStorage.setItem(storageKey, map.key)
if (scope === 'platform') {
setSelectedPlatformMapKey(map.key)
} else {
setSelectedSimulationMapKey(map.key)
}
if (map.custom && map.layout && scope === 'simulation' && !remoteWorkspace) {
await window.localWorkspace.worldLayout.write(clonePathPreview, map.layout as WorldLayout)
setWorldLayoutVersion((version) => version + 1)
}
if (!appliesToActiveWorkspace) {
onTitlebarNotice(`[${map.name}] 맵이 ${scope === 'platform' ? '플랫폼' : '시뮬레이션'} 환경에 선택되었습니다.`, 'info')
return
}
if (map.saved) {
setNav2VisualizationMode('navigation')
setVisualizationStack(map.runtimeKind === 'PLATFORM_3D' ? 'slam3d' : 'nav2')
@@ -2695,7 +2771,16 @@ function LocalPcWorkspaceMockup({
if (restoredActiveModule) {
setModuleWorkspaceSnapshots(restored.moduleSnapshots)
restoreModuleSnapshot(restoredActiveModule, restored.moduleSnapshots[restoredActiveModule.name])
setActiveIdeActivity(['search', 'logs', 'git', 'ros-connections'].includes(restored.activeIdeActivity) ? restored.activeIdeActivity : 'workspace')
}
if (restored.activeIdeActivity === 'examples') {
openExampleCode()
} else if (['modules', 'ros-connections', 'settings'].includes(restored.activeIdeActivity)) {
setActiveIdeActivity(restored.activeIdeActivity)
} else if (restoredActiveModule) {
setActiveIdeActivity(['workspace', 'search', 'logs', 'git'].includes(restored.activeIdeActivity) ? restored.activeIdeActivity : 'workspace')
} else {
setActiveIdeActivity('modules')
}
restoredWorkspaceUiRef.current = null
@@ -3581,7 +3666,6 @@ function LocalPcWorkspaceMockup({
dark={dark}
modules={availableModules}
remoteWorkspace={remoteWorkspace}
platformName={platformName}
rosDomainId={effectiveRosDomainId}
onRefreshModules={() => loadAvailableModules({ silent: true })}
loadRosGraph={() => (
@@ -3834,10 +3918,10 @@ function LocalPcWorkspaceMockup({
{worldMapSelectorOpen && (
<WorldMapSelectorModal
weekNumber={weekNumber}
initialScope={isPlatformWorldMap ? 'platform' : 'simulation'}
initialScope={remoteWorkspace ? 'platform' : 'simulation'}
simulationMaps={simulationWorldMaps}
platformMaps={platformWorldMaps}
selectedMapKey={selectedWorldMapKey}
selectedMapKeys={{ simulation: selectedSimulationMapKey, platform: selectedPlatformMapKey }}
onClose={() => setWorldMapSelectorOpen(false)}
onCreate={() => {
if (remoteWorkspace) {
@@ -3849,8 +3933,11 @@ function LocalPcWorkspaceMockup({
onDelete={(map) => void deleteSavedSlamMap(map)}
onSelect={(map) => {
setWorldMapSelectorOpen(false)
if (map.key === selectedWorldMapKey) return
if (availableModules.some((module) => module.state === 'running')) {
const scope = map.scope ?? (map.runtimeKind === 'SIMULATION' ? 'simulation' : 'platform')
const currentMapKey = scope === 'platform' ? selectedPlatformMapKey : selectedSimulationMapKey
const appliesToActiveWorkspace = (scope === 'platform') === remoteWorkspace
if (map.key === currentMapKey) return
if (appliesToActiveWorkspace && availableModules.some((module) => module.state === 'running')) {
void applyWorldMapSelection(map)
setPendingWorldMapRestart(map)
return
@@ -3948,6 +4035,7 @@ function LocalPcWorkspaceMockup({
}
export default function App(): React.JSX.Element {
const appRootRef = useRef<HTMLDivElement>(null)
const restoredStudioSessionRef = useRef<StudioRuntimeSessionState | null>(readStudioRuntimeSessionState())
const previousWeekStateRef = useRef<Record<number, CurriculumWeek['state']>>({})
const curriculumLoadRequestRef = useRef(0)
@@ -5606,7 +5694,7 @@ export default function App(): React.JSX.Element {
}, [bootLoadingMessage])
return (
<div className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]">
<div ref={appRootRef} className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]">
<div className="flex h-full w-full min-w-[1180px] flex-col overflow-hidden bg-[#0a0b0e]">
<TitleBar
user={authSession?.user ?? null}
@@ -5710,6 +5798,7 @@ export default function App(): React.JSX.Element {
platformEnvironmentConfigOverride={workspacePlatform?.environmentConfigOverride}
platformOptions={platformOptions}
workspaceVisible={localPcLayoutOpen}
visualizationContainerRef={appRootRef}
skipInitialLoading={skipWorkspaceInitialLoading}
visualizationOpen={workspaceVisualizationOpen}
onVisualizationOpenChange={setWorkspaceVisualizationOpen}
+2
View File
@@ -274,6 +274,8 @@ interface Window {
maps: {
readArtifacts: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
sync: (workspaceRoot: string | undefined, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
generateCollisionLayout: (workspaceRoot: string | undefined, yamlPath: string, pgmPath: string) => Promise<WorldLayout>
generatePointCloudCollisionLayout: (workspaceRoot: string | undefined, databasePath: string) => Promise<WorldLayout>
}
visualization: Window['visualization']
git: Window['git']
@@ -1,4 +1,4 @@
import { X } from 'lucide-react'
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
@@ -11,6 +11,8 @@ import { createParameterId, parseModuleParameters } from './moduleParameters'
import type { ModuleParameterPair, ModuleRow } from './types'
import { WorkspaceMapPreview, type WorkspaceMapOption } from './WorldMapSelectorModal'
const MODULE_GUIDE_DISMISSED_STORAGE_KEY = 'autonomy-studio.module-guide.dismissed'
type ModuleListViewProps = {
dark: boolean
modules: ModuleRow[]
@@ -84,6 +86,7 @@ export default function ModuleListView({
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
const [moduleGuideOpen, setModuleGuideOpen] = useState(false)
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null)
@@ -97,6 +100,14 @@ export default function ModuleListView({
const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => {
try {
setModuleGuideOpen(window.localStorage.getItem(MODULE_GUIDE_DISMISSED_STORAGE_KEY) !== 'true')
} catch {
setModuleGuideOpen(true)
}
}, [])
useEffect(() => {
if (!parkingHeightStorageKey) {
setParkingHeight(232)
@@ -200,6 +211,7 @@ export default function ModuleListView({
if (moduleAddDialogOpen) {
event.preventDefault()
setModuleAddDialogOpen(false)
return
}
}
@@ -278,15 +290,24 @@ export default function ModuleListView({
) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} />
) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
{!loading && allowCustomModuleCreation && (
<div className={['flex justify-end px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
{!loading && (
<div className={['flex items-center justify-between px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
<button
type="button"
onClick={() => setModuleAddDialogOpen(true)}
onClick={() => setModuleGuideOpen(true)}
className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
>
+ 모듈 추가
모듈 가이드
</button>
{allowCustomModuleCreation && (
<button
type="button"
onClick={() => setModuleAddDialogOpen(true)}
className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
>
+ 모듈 추가
</button>
)}
</div>
)}
</div>
@@ -362,10 +383,172 @@ export default function ModuleListView({
}}
/>
)}
{moduleGuideOpen && (
<ModuleGuideDialog
dark={dark}
onDismiss={() => {
try {
window.localStorage.setItem(MODULE_GUIDE_DISMISSED_STORAGE_KEY, 'true')
} catch {
// Keep the guide closed for the current session when storage is unavailable.
}
setModuleGuideOpen(false)
}}
/>
)}
</section>
)
}
const moduleGuidePages = [
{
environment: '로컬 환경',
imageAlt: '로컬 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-local.png',
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
['기본', 'sim_mode:=true, platform:=turtlebot3, robot:=turtlebot3, level:=2d'],
['Perception', 'sim_mode:=true'],
['Localization', 'localization_mode:=amcl']
]
},
{
environment: '원격 플랫폼',
imageAlt: '원격 플랫폼 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-remote-platform.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=2d'],
['Perception', 'sim_mode:=false'],
['Localization', 'localization_mode:=amcl']
]
},
{
environment: '원격 플랫폼 3D',
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-remote-3d.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
['기본', 'sim_mode:=false, platform:=mkmini, robot:=mkmini, level:=3d']
]
}
] as const
function ModuleGuideDialog({
dark,
onDismiss
}: {
dark: boolean
onDismiss: () => void
}): React.JSX.Element {
const [pageIndex, setPageIndex] = useState(0)
const page = moduleGuidePages[pageIndex]
const atFirstPage = pageIndex === 0
const atLastPage = pageIndex === moduleGuidePages.length - 1
return (
<div
className="fixed inset-0 z-[200] grid place-items-center bg-black/65 p-5"
role="dialog"
aria-modal="true"
aria-label="모듈 가이드"
>
<section
className={['relative w-full max-w-[780px] overflow-hidden rounded-lg px-7 pb-7 pt-[52px] shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'bg-[#353535] text-white' : 'bg-white text-[#111520]'].join(' ')}
>
<button
type="button"
onClick={onDismiss}
className={['absolute right-[14px] top-3 z-10 grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}
aria-label="닫기"
title="닫기"
>
<X size={16} aria-hidden="true" />
</button>
<div className={['aspect-[16/8] overflow-hidden rounded-md border', dark ? 'border-white/8 bg-[#1d1d1d]' : 'border-[#e2e6ee] bg-[#f5f7fa]'].join(' ')}>
{page.imageSrc && <img src={page.imageSrc} alt={page.imageAlt} className="h-full w-full object-cover" />}
</div>
<div className="mt-5 min-h-[266px] pb-3">
<div className={dark ? 'border-b border-white/10 pb-3' : 'border-b border-[#dfe4ec] pb-3'}>
<h2 className="text-[18px] font-bold">{page.environment}</h2>
</div>
<div className="mt-4 grid grid-cols-3 gap-4">
<section>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>SLAM</h3>
<ul className={dark ? 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-white/78' : 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-[#445065]'}>
{page.slamModules.map((module) => <li key={module}>{module}</li>)}
</ul>
</section>
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>자율주행</h3>
<ul className={dark ? 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-white/78' : 'mt-1.5 grid list-disc gap-0.5 pl-4 text-[11px] font-semibold leading-5 text-[#445065]'}>
{page.navigationModules.map((module) => <li key={module}>{module}</li>)}
</ul>
</section>
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>파라미터</h3>
<div className="mt-2 grid gap-2">
{page.parameters.map(([label, value]) => (
<fieldset key={label} className={dark ? 'min-w-0 rounded-md border border-white/12 px-2 pb-1.5' : 'min-w-0 rounded-md border border-[#d7dee9] px-2 pb-1.5'}>
<legend className={dark ? 'ml-1 px-1 font-sans text-[10px] font-bold text-white/56' : 'ml-1 px-1 font-sans text-[10px] font-bold text-[#6d7788]'}>{label}</legend>
<code className={dark ? 'block pt-1 break-all text-[10px] leading-4 text-white/82' : 'block pt-1 break-all text-[10px] leading-4 text-[#374151]'}>{value}</code>
</fieldset>
))}
</div>
</section>
</div>
</div>
<footer className="mt-4 grid grid-cols-[1fr_auto_1fr] items-center gap-3">
<button
type="button"
disabled={atFirstPage}
onClick={() => setPageIndex((current) => Math.max(0, current - 1))}
className={['inline-flex h-9 w-fit cursor-pointer items-center gap-1 rounded-md px-3 text-[12px] font-bold transition disabled:cursor-not-allowed disabled:opacity-35', dark ? 'bg-white/14 text-white hover:bg-white/22' : 'bg-[#eef1f6] text-[#445065] hover:bg-[#e1e6ee]'].join(' ')}
>
<ChevronLeft size={15} aria-hidden="true" />
이전
</button>
<div className="flex items-center justify-center gap-1.5" aria-label={`가이드 ${pageIndex + 1} / ${moduleGuidePages.length}`}>
{moduleGuidePages.map((guidePage, index) => (
<button
key={guidePage.title}
type="button"
onClick={() => setPageIndex(index)}
className={[
'h-1.5 w-1.5 cursor-pointer rounded-full transition',
index === pageIndex ? 'bg-[#2f8dff]' : (dark ? 'bg-white/18 hover:bg-white/35' : 'bg-[#c9ced9] hover:bg-[#9ea7b7]')
].join(' ')}
aria-label={`${index + 1}페이지`}
aria-current={index === pageIndex ? 'page' : undefined}
/>
))}
</div>
<button
type="button"
onClick={() => {
if (atLastPage) {
onDismiss()
return
}
setPageIndex((current) => Math.min(moduleGuidePages.length - 1, current + 1))
}}
className="inline-flex h-9 w-fit cursor-pointer justify-self-end items-center gap-1 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8]"
>
{atLastPage ? '닫기' : '다음'}
{atLastPage ? <X size={14} aria-hidden="true" /> : <ChevronRight size={15} aria-hidden="true" />}
</button>
</footer>
</section>
</div>
)
}
export type CustomModuleInput = {
name: string
category: string
@@ -12,7 +12,6 @@ type RosModule = {
module: ModuleRow
publishers: TopicRole[]
subscribers: TopicRole[]
runtime: 'local' | 'remote'
}
type RosConnection = {
@@ -41,7 +40,6 @@ type WorkspaceRosConnectionsViewProps = {
dark: boolean
modules: ModuleRow[]
remoteWorkspace: boolean
platformName: string
rosDomainId: string
onRefreshModules: () => Promise<void>
loadRosGraph: () => Promise<RosGraphSnapshot>
@@ -116,12 +114,6 @@ function moduleTopics(module: ModuleRow): Pick<RosModule, 'publishers' | 'subscr
return { publishers: [], subscribers: [] }
}
function resolveRuntime(module: ModuleRow, remoteWorkspace: boolean): RosModule['runtime'] {
if (module.runtimeTarget === 'LOCAL') return 'local'
if (module.runtimeTarget === 'PLATFORM') return 'remote'
return remoteWorkspace ? 'remote' : 'local'
}
function getModuleNodeName(module: ModuleRow): string {
return `/${module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_')}`
}
@@ -338,14 +330,13 @@ export default function WorkspaceRosConnectionsView({
dark,
modules,
remoteWorkspace,
platformName,
rosDomainId,
onRefreshModules,
loadRosGraph,
}: WorkspaceRosConnectionsViewProps): React.JSX.Element {
const runningRosModules = useMemo<RosModule[]>(() => modules
.filter((module) => module.state === 'running')
.map((module) => ({ module, runtime: resolveRuntime(module, remoteWorkspace), ...moduleTopics(module) })), [modules, remoteWorkspace])
.map((module) => ({ module, ...moduleTopics(module) })), [modules])
const [selectedModuleKeys, setSelectedModuleKeys] = useState<Record<string, boolean>>({})
const [rosGraph, setRosGraph] = useState<RosGraphSnapshot>({ topics: [] })
const [rosGraphLoading, setRosGraphLoading] = useState(false)
@@ -406,22 +397,16 @@ export default function WorkspaceRosConnectionsView({
}
}, [selectedConnection, selectedTopicName])
const localModules = runningRosModules.filter((module) => module.runtime === 'local')
const remoteModules = runningRosModules.filter((module) => module.runtime === 'remote')
const primaryRuntime = remoteWorkspace ? 'remote' : 'local'
const primaryModules = primaryRuntime === 'remote' ? remoteModules : localModules
const secondaryModules = primaryRuntime === 'remote' ? localModules : remoteModules
const runningModules = runningRosModules.length
const activeConnections = connections.filter((connection) => connection.active).length
const toggleModuleConnection = (module: ModuleRow): void => {
const key = module.folderName ?? module.name
setSelectedModuleKeys((current) => ({ ...current, [key]: current[key] === false }))
}
const setRuntimeModuleSelection = (runtime: RosModule['runtime'], selected: boolean): void => {
const runtimeModules = runtime === 'local' ? localModules : remoteModules
const setWorkspaceModuleSelection = (selected: boolean): void => {
setSelectedModuleKeys((current) => {
const next = { ...current }
runtimeModules.forEach(({ module }) => {
runningRosModules.forEach(({ module }) => {
next[module.folderName ?? module.name] = selected
})
return next
@@ -499,15 +484,15 @@ export default function WorkspaceRosConnectionsView({
style={{ gridTemplateRows: topicDetailOpen && selectedConnection ? `minmax(0, 1fr) ${topicDetailHeight}px` : 'minmax(0, 1fr)' }}
>
<div className="min-h-0 overflow-auto">
<div className="grid min-h-full min-w-[700px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)_minmax(200px,0.9fr)]">
<div className="grid min-h-full min-w-[520px] grid-cols-[minmax(200px,0.9fr)_minmax(300px,1.35fr)]">
<RuntimeLane
dark={dark}
title={primaryRuntime === 'remote' ? '원격 플랫폼' : '로컬 PC'}
modules={primaryModules}
title={remoteWorkspace ? '원격 플랫폼' : '로컬 PC'}
modules={runningRosModules}
selectedModuleKeys={selectedModuleKeys}
onToggleModule={toggleModuleConnection}
onSelectAll={() => setRuntimeModuleSelection(primaryRuntime, true)}
onClearSelection={() => setRuntimeModuleSelection(primaryRuntime, false)}
onSelectAll={() => setWorkspaceModuleSelection(true)}
onClearSelection={() => setWorkspaceModuleSelection(false)}
/>
<section className={['min-h-0 border-r p-3', dark ? 'border-white/10 bg-[#1f1f1f]' : 'border-[#dbe1eb] bg-[#f7f9fc]'].join(' ')}>
@@ -562,15 +547,6 @@ export default function WorkspaceRosConnectionsView({
</div>
</section>
<RuntimeLane
dark={dark}
title={primaryRuntime === 'remote' ? '로컬 PC' : '원격 플랫폼'}
modules={secondaryModules}
selectedModuleKeys={selectedModuleKeys}
onToggleModule={toggleModuleConnection}
onSelectAll={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', true)}
onClearSelection={() => setRuntimeModuleSelection(primaryRuntime === 'remote' ? 'local' : 'remote', false)}
/>
</div>
</div>
@@ -1,5 +1,6 @@
import type { StudioSourceSettings } from '../platforms/types'
import type { PlatformOption } from '../platforms/types'
import { useEffect, useRef, useState } from 'react'
import { StudioSettingsField, StudioSettingsSwitch } from './StudioSettingsControls'
type Props = {
@@ -29,6 +30,25 @@ function WorkspaceSettingsView({
onUpdateSourceSetting,
onSaveSourceSettings
}: Props): React.JSX.Element {
const [saveAcknowledged, setSaveAcknowledged] = useState(false)
const saveAcknowledgedTimerRef = useRef<number | null>(null)
useEffect(() => () => {
if (saveAcknowledgedTimerRef.current !== null) {
window.clearTimeout(saveAcknowledgedTimerRef.current)
}
}, [])
const handleSave = (): void => {
if (saveDisabled || saveAcknowledged) return
setSaveAcknowledged(true)
onSaveSourceSettings()
saveAcknowledgedTimerRef.current = window.setTimeout(() => {
setSaveAcknowledged(false)
saveAcknowledgedTimerRef.current = null
}, 1000)
}
return (
<section
className={['min-w-0 overflow-hidden', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}
@@ -84,6 +104,17 @@ function WorkspaceSettingsView({
onChange={(checked) => onUpdateSourceSetting('gpuEnabled', checked)}
dark={dark}
/>
{sourceSettingsDraft.gpuEnabled ? (
<div className={[
'relative ml-3 rounded-md border px-3 py-2 text-[11px] font-semibold leading-relaxed',
'before:absolute before:-top-1 before:left-4 before:size-2 before:rotate-45 before:border-l before:border-t',
dark
? 'border-amber-300/20 bg-amber-300/[0.08] text-amber-100/80 before:border-amber-300/20 before:bg-[#252525]'
: 'border-amber-300/50 bg-amber-50 text-amber-900/75 before:border-amber-300/50 before:bg-amber-50'
].join(' ')}>
NVIDIA Container Toolkit과 Docker GPU 런타임이 준비된 PC에서만 실행됩니다. 준비되지 않은 경우 GPU 가속을 끄세요.
</div>
) : null}
</div>
<div className={['mt-5 border-t pt-5', dark ? 'border-white/[0.03]' : 'border-[#f6f8fb]'].join(' ')}>
@@ -175,11 +206,11 @@ function WorkspaceSettingsView({
<div className="mt-8 flex justify-end">
<button
type="button"
disabled={saveDisabled}
onClick={onSaveSourceSettings}
disabled={saveDisabled || saveAcknowledged}
onClick={handleSave}
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]"
>
저장
{saveAcknowledged ? '저장됨' : '저장'}
</button>
</div>
</>
@@ -17,7 +17,7 @@ type WorkspaceVisualizationPanelProps = {
running: boolean
busy: boolean
viewerUrl: string
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onDragStart: (event: React.PointerEvent<HTMLDivElement>) => void
onToggleLarge: () => void
onMinimize: () => void
onClose: () => void
@@ -78,9 +78,9 @@ export default function WorkspaceVisualizationPanel({
}}
>
<div
onMouseDown={onDragStart}
onPointerDown={onDragStart}
className={[
'flex h-9 items-center justify-between border-b px-3',
'flex h-9 select-none touch-none items-center justify-between border-b px-3',
large ? 'cursor-default' : 'cursor-move',
dark ? 'border-white/10 bg-[#252525]' : 'border-[#d8dee9] bg-[#f3f5f9]'
].join(' ')}
@@ -93,6 +93,7 @@ export default function WorkspaceVisualizationPanel({
<button
type="button"
aria-label="최소화"
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={onMinimize}
className={[iconButtonClass, 'group relative'].join(' ')}
@@ -105,6 +106,7 @@ export default function WorkspaceVisualizationPanel({
<button
type="button"
aria-label={large ? '원래 크기' : '최대화'}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={onToggleLarge}
className={[iconButtonClass, 'group relative'].join(' ')}
@@ -117,6 +119,7 @@ export default function WorkspaceVisualizationPanel({
<button
type="button"
aria-label={running ? '시각화 종료' : '닫기'}
onPointerDown={(event) => event.stopPropagation()}
onMouseDown={(event) => event.stopPropagation()}
onClick={() => {
if (running) setTerminateConfirmOpen(true)
@@ -2,6 +2,7 @@ import { AlertTriangle, Check, House, Map, PencilRuler, Radio, Save, Trash2, X }
import { useEffect, useRef, useState } from 'react'
export type SavedMapRuntimeKind = 'SIMULATION' | 'PLATFORM_2D' | 'PLATFORM_3D'
export type WorkspaceMapScope = 'simulation' | 'platform'
export type WorkspaceMapOption = {
key: string
@@ -23,6 +24,8 @@ export type WorkspaceMapOption = {
databaseUrl?: string
previewPgm?: string
runtimeKind?: SavedMapRuntimeKind
scope?: WorkspaceMapScope
gazeboWorldFromMap?: boolean
layout?: unknown
remoteSlam?: boolean
}
@@ -67,7 +70,11 @@ export function WorkspaceMapPreview({
borderless?: boolean
}): React.JSX.Element {
const Icon = map.remoteSlam ? Radio : map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const saved = Boolean(map.saved || map.remoteSlam)
const slamLabel = map.saved
? null
: map.runtimeKind === 'PLATFORM_3D'
? '3D SLAM'
: '2D SLAM'
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
@@ -181,7 +188,7 @@ export function WorkspaceMapPreview({
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</>
))}
{saved && <span className={['absolute inset-x-0 bottom-0 bg-[#0f172a]/75 text-center font-extrabold tracking-[0.08em] text-white', compact ? 'py-px text-[6px]' : 'py-0.5 text-[8px]'].join(' ')}>{map.runtimeKind === 'PLATFORM_3D' ? '3D SLAM' : 'SLAM'}</span>}
{slamLabel && <span className={['absolute inset-x-0 bottom-0 bg-[#0f172a]/75 text-center font-extrabold tracking-[0.08em] text-white', compact ? 'py-px text-[6px]' : 'py-0.5 text-[8px]'].join(' ')}>{slamLabel}</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" />
</span>
@@ -194,16 +201,17 @@ type WorldMapSelectorModalProps = {
initialScope?: 'simulation' | 'platform'
simulationMaps: WorkspaceMapOption[]
platformMaps: WorkspaceMapOption[]
selectedMapKey: string
selectedMapKeys: Record<WorkspaceMapScope, string>
onSelect: (map: WorkspaceMapOption) => void
onCreate?: () => void
onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void
}
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simulation', simulationMaps, platformMaps, selectedMapKeys, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const [scope, setScope] = useState<'simulation' | 'platform'>(initialScope)
const maps = scope === 'simulation' ? simulationMaps : platformMaps
const selectedMapKey = selectedMapKeys[scope]
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
@@ -217,7 +225,7 @@ export default function WorldMapSelectorModal({ weekNumber, initialScope = 'simu
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<section className="flex w-full max-w-[560px] max-h-[82vh] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
@@ -231,7 +239,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('platform')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', scope === 'platform' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>플랫폼</button>
</div>
<div className="thin-y-scrollbar min-h-[320px] max-h-[320px] space-y-2 overflow-y-auto p-5">
<div className="thin-y-scrollbar min-h-[400px] max-h-[52vh] space-y-2 overflow-y-auto p-5">
{maps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
@@ -244,11 +244,12 @@ export function useWorkspaceVisualization({
if (visualizationBusy) return
if (mode !== nav2VisualizationMode) setNav2VisualizationMode(mode)
setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중')
setVisualizationStatusMessage(prepareSavedMap ? '맵 환경 준비 중' : '시작 중')
try {
// Download the selected map artifacts before starting the bridge. The
// bridge only receives workspace-local paths, never an R2 URL.
const preparedMap = await prepareSavedMap?.()
setVisualizationStatusMessage('시작 중')
const bridgeSavedMapDatabasePath = preparedMap?.savedMapDatabasePath ?? savedMapDatabasePath
const status = bridgeRunsOnPlatform
? bridgeTarget?.host