fix: enable added week deletion and preserve learning concepts when cloning courses

This commit is contained in:
2026-09-22 15:32:22 +09:00
parent 1d959e2c51
commit 33aac68f16
18 changed files with 633 additions and 61 deletions
+118 -21
View File
@@ -48,11 +48,11 @@
}
#cameraPreviewWrap {
position:absolute;
right:16px;
bottom:256px;
left:16px;
top:48px;
z-index:10;
display:none;
width:260px;
width:min(260px, calc(100vw - 32px));
border:1px solid rgba(0,0,0,.45);
background:#111520;
box-shadow:0 10px 26px rgba(0,0,0,.16);
@@ -61,16 +61,16 @@
#cameraPreviewWrap.is-collapsed { background:transparent; border-color:transparent; box-shadow:none; }
#cameraPreview {
display:block;
width:260px;
width:100%;
aspect-ratio:16 / 9;
object-fit:cover;
object-fit:contain;
background:#111520;
}
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
#cameraPreviewToggle {
position:absolute;
right:0;
top:-28px;
top:100%;
display:grid;
width:30px;
height:28px;
@@ -86,8 +86,8 @@
box-shadow:0 10px 28px rgba(0,0,0,.16);
}
#cameraPreviewToggle:hover { color:#fff; background:rgba(255,255,255,.06); }
#cameraPreviewChevron { font-size:13px; line-height:1; }
#cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(180deg); }
#cameraPreviewChevron { font-size:13px; line-height:1; transform:rotate(180deg); }
#cameraPreviewWrap.is-collapsed #cameraPreviewChevron { transform:rotate(0deg); }
#topRightControls {
position:absolute;
right:16px;
@@ -369,6 +369,8 @@
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; }
.real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; }
</style>
</head>
<body>
@@ -441,12 +443,12 @@ const REAL_RUNTIME = params.get('runtime') === 'real';
const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform.
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom';
const REAL_YAHBOOM_TELEOP = REAL_RUNTIME && USE_YAHBOOM_MODEL;
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
// Camera preview is kept for the RGB test module, but is paused for now so
// regular visualization sessions never poll `/camera-frame`.
const ENABLE_CAMERA_PREVIEW = false;
const ENABLE_CAMERA_PREVIEW = params.get('editor') !== '1';
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';
@@ -493,6 +495,12 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (EDITOR_MODE) document.body.classList.add('editor-mode');
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
if (REAL_RUNTIME) document.body.classList.add('real-runtime');
if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-yahboom-teleop');
const hint = document.createElement('div');
hint.textContent = '키를 누르는 동안 이동 · 놓으면 정지';
document.getElementById('keyGuide')?.appendChild(hint);
}
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
let visualizationResponseReceived = EDITOR_MODE;
@@ -868,6 +876,7 @@ let laserFrontBeam = null;
let slamSocket = null;
let controlSocket = null;
let latestSlamState = null;
let latestSlamStateReceivedAt = 0;
let currentRobotPose = { x:-2.0, y:-0.5, z:0.01, roll:0, pitch:0, yaw:0 };
let targetRobotPose = { ...currentRobotPose };
let hasRobotPose = false;
@@ -1385,6 +1394,42 @@ async function loadMkMiniModel(sdfPose = { x:0, y:0, z:0, roll:0, pitch:0, yaw:0
sdfRoot.add(posePreviewModel);
}
async function loadYahboomModel(sdfPose) {
const gltf = await new GLTFLoader().loadAsync('./assets/yahboom-pi5.glb');
const robotGroup = new THREE.Group();
robotGroup.name = 'yahboom';
applySdfPose(robotGroup, sdfPose);
// GLB is meter-scaled Y-up; the viewer's SDF root expects ROS Z-up.
// The exported origin is already base_footprint, so do not recenter it.
gltf.scene.rotation.x = Math.PI / 2;
gltf.scene.traverse((child) => {
if (!child.isMesh) return;
child.castShadow = true;
// The world-scale shadow map causes self-shadow acne on small CAD parts.
child.receiveShadow = false;
});
robotGroup.add(gltf.scene);
sdfRoot.add(robotGroup);
robotModel = robotGroup;
robotModel.visible = shouldRenderRobot();
posePreviewModel = robotGroup.clone(true);
posePreviewModel.name = 'yahboom_pose_preview';
posePreviewModel.traverse((child) => {
if (!child.isMesh) return;
child.castShadow = false;
child.material = Array.isArray(child.material)
? child.material.map((material) => material.clone()) : child.material.clone();
const materials = Array.isArray(child.material) ? child.material : [child.material];
for (const material of materials) {
material.transparent = true;
material.opacity = 0.38;
material.depthWrite = false;
}
});
posePreviewModel.visible = false;
sdfRoot.add(posePreviewModel);
}
function createMkMiniFrontWheelPivots(model) {
const pivots = [];
model.updateMatrixWorld(true);
@@ -1411,10 +1456,10 @@ 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_X = USE_MKMINI_MODEL ? 0.15 : USE_YAHBOOM_MODEL ? 0 : -0.032;
const LASER_OFFSET_Y = 0;
// 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 : USE_YAHBOOM_MODEL ? 0.124183557 : 0.181;
const MKMINI_LASER_RENDER_BACKWARD_OFFSET = 0.03;
function setRobotPose(pose) {
@@ -2010,10 +2055,10 @@ function setCameraPreviewAvailable(available) {
}
async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || !REAL_RUNTIME || cameraFrameRequestInFlight) return;
if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return;
cameraFrameRequestInFlight = true;
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store' });
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) });
if (!response.ok) {
setCameraPreviewAvailable(false);
return;
@@ -2023,7 +2068,6 @@ async function refreshCameraFrame() {
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
setCameraPreviewAvailable(true);
} catch (_) {
setCameraPreviewAvailable(false);
} finally {
@@ -2031,6 +2075,9 @@ async function refreshCameraFrame() {
}
}
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) {
pendingMiniMapState = state;
if (miniMapRenderTimer) return;
@@ -3063,6 +3110,7 @@ renderer.domElement.addEventListener('click', async (event) => {
function applySlamState(state) {
latestSlamState = state;
latestSlamStateReceivedAt = Date.now();
resetNavigationPoseForNewSession(state);
restoreNavigationPoseFromState(state);
updateNavigationLocalizationState(state);
@@ -3155,7 +3203,7 @@ if (SHOW_SENSOR_POINT_CLOUD && navigationSensorVisible) {
void refreshSensorPointCloud();
sensorPointCloudRefreshTimer = window.setInterval(() => void refreshSensorPointCloud(), 750);
}
if (ENABLE_CAMERA_PREVIEW && REAL_RUNTIME) {
if (ENABLE_CAMERA_PREVIEW) {
void refreshCameraFrame();
cameraFrameRefreshTimer = window.setInterval(() => void refreshCameraFrame(), 200);
}
@@ -3172,6 +3220,10 @@ function connectControlSocket() {
if (!CONTROL_WS) return;
controlSocket = new WebSocket(CONTROL_WS);
controlSocket.addEventListener('close', () => {
if (REAL_YAHBOOM_TELEOP) {
activeMoveKey = null;
stopRobot();
}
setTimeout(connectControlSocket, 1000);
});
controlSocket.addEventListener('error', () => {
@@ -3179,8 +3231,8 @@ function connectControlSocket() {
});
}
let maxLinear = 0.22;
let maxAngular = 2.84;
let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22;
let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84;
let activeMoveKey = null;
let teleopInterval = null;
let singleMoveStopTimer = null;
@@ -3204,12 +3256,19 @@ const speedBindings = {
};
function canUseTeleop(event) {
if (REAL_RUNTIME) return false;
if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false;
if (!document.hasFocus()) return false;
if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false;
const tag = event.target?.tagName?.toLowerCase();
return !['input', 'textarea', 'select', 'button'].includes(tag);
}
function realTeleopReady() {
return latestSlamState?.teleop_deadman === true
&& Date.now() - latestSlamStateReceivedAt < 1000
&& controlSocket?.readyState === WebSocket.OPEN;
}
function flushQueuedCmdVel() {
if (cmdVelRequestInFlight || !queuedCmdVel) return;
const command = queuedCmdVel;
@@ -3236,6 +3295,20 @@ function flushQueuedCmdVel() {
function sendCmdVel(linear, angular) {
const payload = { type:'cmd_vel', linear, angular };
if (REAL_YAHBOOM_TELEOP) {
// Never queue physical motion for replay after a connection recovers.
if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return;
if ((linear || angular) && !realTeleopReady()) return;
payload.deadman = true;
payload.linear = Math.max(-0.1, Math.min(0.1, linear));
payload.angular = Math.max(-0.5, Math.min(0.5, angular));
if (controlSocket.bufferedAmount > 0) {
controlSocket.close();
return;
}
controlSocket.send(JSON.stringify(payload));
return;
}
if (controlSocket?.readyState === WebSocket.OPEN) {
controlSocket.send(JSON.stringify(payload));
return;
@@ -3289,6 +3362,11 @@ function stopRobot() {
function sendActiveMoveCommand() {
if (!activeMoveKey) return;
if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
activeMoveKey = null;
stopRobot();
return;
}
const [linearSign, angularSign] = moveBindings[activeMoveKey];
if (linearSign === 0 && angularSign === 0) return;
@@ -3340,6 +3418,7 @@ window.addEventListener('keydown', (event) => {
if (speedBindings[key]) {
event.preventDefault();
if (REAL_YAHBOOM_TELEOP) return;
const [linearScale, angularScale] = speedBindings[key];
maxLinear = Math.max(0.01, maxLinear * linearScale);
maxAngular = Math.max(0.1, maxAngular * angularScale);
@@ -3355,6 +3434,11 @@ window.addEventListener('keydown', (event) => {
stopRobot();
return;
}
if (REAL_YAHBOOM_TELEOP) {
activeMoveKey = key;
startActiveMoveCommand();
return;
}
if (USE_MKMINI_MODEL && !REAL_RUNTIME && (key === 'j' || key === 'l')) {
activeMoveKey = key;
sendActiveMoveCommand();
@@ -3370,6 +3454,11 @@ window.addEventListener('keydown', (event) => {
});
window.addEventListener('keyup', (event) => {
if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) {
activeMoveKey = null;
stopRobot();
return;
}
if (!canUseTeleop(event)) return;
const key = event.key.toLowerCase();
if (moveBindings[key]) event.preventDefault();
@@ -3381,6 +3470,14 @@ window.addEventListener('blur', () => {
stopRobot();
});
function releaseRealTeleop() {
if (!REAL_YAHBOOM_TELEOP || !activeMoveKey) return;
activeMoveKey = null;
stopRobot();
}
document.addEventListener('visibilitychange', () => { if (document.hidden) releaseRealTeleop(); });
window.addEventListener('pagehide', releaseRealTeleop);
// 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 || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
@@ -3389,7 +3486,7 @@ const worldSceneTasks = CUSTOM_WORLD || SAVED_GAZEBO_MAP_WORLD || REAL_RUNTIME
const remoteNavigationSceneTasks = [];
Promise.all(EDITOR_MODE
? worldSceneTasks
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })]
: [...worldSceneTasks, ...remoteNavigationSceneTasks, (USE_YAHBOOM_MODEL ? loadYahboomModel : USE_MKMINI_MODEL ? loadMkMiniModel : loadBurgerModel)({ ...DEFAULT_SIMULATION_SPAWN, z:0.01, roll:0, pitch:0, yaw:0 })]
).then(() => {
if (EDITOR_MODE) {
window.parent.postMessage({ source:'autonomy-world-editor', type:'ready' }, '*');