Compare commits
10
Commits
be58d589e2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5d52521f | ||
|
|
c804d89b18 | ||
|
|
76dc9f19c9 | ||
|
|
e515ab1b8c | ||
|
|
1c7dd29d8a | ||
|
|
88e47fc39a | ||
|
|
cd16ea28a6 | ||
|
|
45019f8952 | ||
|
|
2e44e27967 | ||
|
|
2ccf172319 |
@@ -10,3 +10,4 @@
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
Generated
+1
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"postinstall": "node scripts/install-rerun-navigation.mjs",
|
||||
"prebuild": "node scripts/install-rerun-navigation.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test test/*.test.mjs",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = JSON.parse(readFileSync(resolve(vendorRoot, "navigation-build.json"), "utf8"));
|
||||
const installed = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
if (installed.version !== "0.36.3" || manifest.upstreamVersion !== installed.version) {
|
||||
throw new Error("Rerun navigation requires the audited 0.36.3 package; rebase before upgrading");
|
||||
}
|
||||
if (sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))) !== manifest.patchSha256) {
|
||||
throw new Error("Rerun navigation source patch identity mismatch");
|
||||
}
|
||||
|
||||
const eyeType = "{ position: [number, number, number]; lookTarget: [number, number, number]; eyeUp: [number, number, number] } | null";
|
||||
const additions = {
|
||||
"index.js": {
|
||||
marker: " get_active_recording_id() {",
|
||||
code: ` get_camera_eye() {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.ts": {
|
||||
marker: " get_active_recording_id(): string | null {",
|
||||
code: ` get_camera_eye(): ${eyeType} {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.d.ts": {
|
||||
marker: " get_active_recording_id(): string | null;",
|
||||
code: ` get_camera_eye(): ${eyeType};\n`,
|
||||
},
|
||||
};
|
||||
|
||||
// Validate the complete set before changing any installed file. A package
|
||||
// upgrade, corrupt artifact or independently modified wrapper fails closed.
|
||||
const writes = [];
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
const current = readFileSync(resolve(packageRoot, name));
|
||||
let output;
|
||||
if (additions[name]) {
|
||||
const { marker, code } = additions[name];
|
||||
const source = current.toString("utf8");
|
||||
const original = source.includes(code) ? source.replace(code, "") : source;
|
||||
if (sha(original) !== identity.upstreamSha256 || original.split(marker).length !== 2) {
|
||||
throw new Error(`Unexpected upstream wrapper: ${name}`);
|
||||
}
|
||||
output = Buffer.from(original.replace(marker, code + marker));
|
||||
} else {
|
||||
output = readFileSync(resolve(vendorRoot, identity.artifact));
|
||||
if (sha(output) !== identity.sha256) throw new Error(`Corrupt navigation artifact: ${name}`);
|
||||
if (![identity.upstreamSha256, identity.sha256].includes(sha(current))) {
|
||||
throw new Error(`Unexpected installed runtime: ${name}`);
|
||||
}
|
||||
}
|
||||
writes.push([resolve(packageRoot, name), output]);
|
||||
}
|
||||
for (const [path, output] of writes) writeFileSync(path, output);
|
||||
console.log("Installed NODE.DC Rerun 0.36.3 navigation/v1 (native camera, one renderer).");
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Inspector,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
@@ -27,6 +28,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
|
||||
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
||||
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
||||
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
||||
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
|
||||
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
|
||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||
@@ -47,6 +50,7 @@ import type {
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
|
||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||
import {
|
||||
@@ -67,6 +71,9 @@ import {
|
||||
type SceneSettings,
|
||||
} from "./sceneSettings";
|
||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
||||
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
|
||||
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
|
||||
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
|
||||
import { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||
import "./styles/scene-windows.css";
|
||||
@@ -149,7 +156,9 @@ export default function App() {
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
@@ -174,6 +183,7 @@ export default function App() {
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const closeDisplayRef = useRef<() => void>(() => {});
|
||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
@@ -221,6 +231,13 @@ export default function App() {
|
||||
);
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayPresented;
|
||||
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
|
||||
sceneSettingsRef.current = settings;
|
||||
displayDraftRef.current = settings;
|
||||
confirmedSceneSettingsRef.current = settings;
|
||||
setSceneSettings(settings);
|
||||
setDisplayDraft(settings);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||
@@ -303,6 +320,7 @@ export default function App() {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (
|
||||
!remote ||
|
||||
replayActiveRef.current ||
|
||||
workspaceLayoutProfile.profile ||
|
||||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
@@ -325,7 +343,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
if (!profile) return;
|
||||
if (!profile || selectedRecordedSessionIdRef.current) return;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
@@ -396,7 +414,10 @@ export default function App() {
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "display") {
|
||||
setDisplayWindowOpen(false);
|
||||
closeDisplayRef.current();
|
||||
}
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
@@ -428,9 +449,10 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const openView = (viewId: string) => {
|
||||
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
|
||||
const definition = workspaceById(viewId);
|
||||
if (!definition) return;
|
||||
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
|
||||
setActiveRoot(definition.root);
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
@@ -456,14 +478,22 @@ export default function App() {
|
||||
const next = { ...displayDraftRef.current, ...patch };
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
sessionDisplayProfile.edited();
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
}
|
||||
// Preview and persistence are separate. An open inspector must not freeze
|
||||
// either its controls or the shared accumulation slider below the scene.
|
||||
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(next);
|
||||
return;
|
||||
}
|
||||
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, viewerSettingsQuietPeriodMs);
|
||||
}, []);
|
||||
}, [sessionDisplayProfile.edited]);
|
||||
|
||||
const flushDisplaySettings = useCallback(() => {
|
||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||
@@ -473,8 +503,15 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
sessionDisplayProfile.edited();
|
||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||
}, [commitDisplaySettings]);
|
||||
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
|
||||
|
||||
closeDisplayRef.current = () => {
|
||||
const settings = {...displayDraftRef.current};
|
||||
commitDisplaySettings(settings);
|
||||
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
if (!sceneWorkspaceActive) return;
|
||||
@@ -609,6 +646,7 @@ export default function App() {
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
@@ -619,6 +657,7 @@ export default function App() {
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||
systemUtilityActions: computeContourSettings.utilityActions,
|
||||
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
|
||||
});
|
||||
|
||||
const header = (
|
||||
@@ -781,6 +820,8 @@ export default function App() {
|
||||
settleRecordedReplaySwitch(outcome)}
|
||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||
/>
|
||||
) : activeDefinition.kind === "missions" ? (
|
||||
<div ref={setWorkspaceHeaderToolsHost} />
|
||||
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
@@ -799,15 +840,28 @@ export default function App() {
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
<PlanningCaptureGuard
|
||||
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||
onResume={() => openView('local-device', 'planning')}
|
||||
>
|
||||
{activeDefinition.kind === "device" ? (
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
||||
launchProfile === 'planning' ? <PlanningConnectionWindow>
|
||||
<DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
</PlanningConnectionWindow> : <DeviceWorkspace
|
||||
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
|
||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
||||
/>
|
||||
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
|
||||
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
|
||||
) : (
|
||||
<WorkspaceRenderer
|
||||
launchProfile={launchProfile}
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
headerToolsHost={workspaceHeaderToolsHost}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
@@ -848,6 +902,7 @@ export default function App() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PlanningCaptureGuard>
|
||||
</ApplicationPanel>
|
||||
) : null}
|
||||
/>
|
||||
@@ -1010,8 +1065,11 @@ export default function App() {
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
>
|
||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
</Window>
|
||||
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
|
||||
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
|
||||
onDismiss={sessionDisplayProfile.dismissError} />
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
|
||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
|
||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
let onBufferError: (() => void) | null = null;
|
||||
let objectUrl = "";
|
||||
let retryTimer: number | undefined;
|
||||
let bufferingNoticeTimer: number | undefined;
|
||||
let receivedMedia = false;
|
||||
let failed = false;
|
||||
let playingReported = false;
|
||||
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
|
||||
);
|
||||
const queue: ArrayBuffer[] = [];
|
||||
let queuedBytes = 0;
|
||||
const clearBufferingNotice = () => {
|
||||
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
|
||||
bufferingNoticeTimer = undefined;
|
||||
};
|
||||
|
||||
const fail = (copy: string) => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
recoveryPendingAuthorityRef.current = null;
|
||||
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
return;
|
||||
}
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
failed = true;
|
||||
transportHealthyRef.current = false;
|
||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
|
||||
const onPlaying = () => {
|
||||
if (!transportIsCurrent() || failed) return;
|
||||
clearBufferingNotice();
|
||||
startupWatchdog?.markPlaying();
|
||||
transportHealthyRef.current = true;
|
||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||
@@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({
|
||||
}
|
||||
};
|
||||
|
||||
const onWaiting = () => {
|
||||
if (!transportIsCurrent() || failed || !playingReported) return;
|
||||
// A short receipt gap must not flicker or restart the acquisition-owned
|
||||
// stream. A sustained decoder wait must not retain a playing presentation.
|
||||
if (bufferingNoticeTimer !== undefined) return;
|
||||
bufferingNoticeTimer = window.setTimeout(() => {
|
||||
bufferingNoticeTimer = undefined;
|
||||
if (!transportIsCurrent() || failed || video.readyState >= 3) return;
|
||||
setStatus("buffering");
|
||||
setMessage("Ожидание новых кадров.");
|
||||
}, CAMERA_BUFFERING_NOTICE_DELAY_MS);
|
||||
};
|
||||
video.addEventListener("playing", onPlaying);
|
||||
video.addEventListener("waiting", onWaiting);
|
||||
video.addEventListener("stalled", onWaiting);
|
||||
|
||||
const appendNext = () => {
|
||||
if (
|
||||
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
|
||||
disposed = true;
|
||||
transportHealthyRef.current = false;
|
||||
startupWatchdog?.clear();
|
||||
clearBufferingNotice();
|
||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||
queue.length = 0;
|
||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||
video.removeEventListener("playing", onPlaying);
|
||||
video.removeEventListener("waiting", onWaiting);
|
||||
video.removeEventListener("stalled", onWaiting);
|
||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||
if (sourceBuffer && onBufferUpdateEnd) {
|
||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
|
||||
{status !== "playing" ? (
|
||||
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
||||
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
||||
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
||||
<strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
|
||||
<span>{message}</span>
|
||||
{status === "error" ? (
|
||||
<button
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: "source",
|
||||
scope: "standalone",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./rerun/recordedRerunCameraJournal";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
|
||||
import {
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
|
||||
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
||||
sceneSettings?: Pick<
|
||||
SceneSettings,
|
||||
| "pointDecimationPercent"
|
||||
| "accumulationSeconds"
|
||||
| "showGrid"
|
||||
| "showPoints"
|
||||
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
|
||||
cameraContract?: string | null;
|
||||
appliedFollowTrajectory?: boolean | null;
|
||||
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
||||
configureCameraJournal?: (
|
||||
eye: RecordedRerunCameraEye,
|
||||
spatialViewportStart: number,
|
||||
) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye;
|
||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye | null;
|
||||
getCurrentTimeNs?: () => number | null;
|
||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
channel: {
|
||||
readonly ready: boolean;
|
||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
currentTimeNs,
|
||||
reactivateUpdates = false,
|
||||
onCameraMaxOrbitalRadius,
|
||||
displayPointBank,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
|
||||
currentTimeNs?: number | null;
|
||||
reactivateUpdates?: boolean;
|
||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
displayPointBank?: string | null;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
const resolvedUnifiedPerception =
|
||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
||||
// Rerun exposes its interpolated TimeReal cursor as f64 nanoseconds, even
|
||||
// when paused after seeking. The API needs an integer pose-query timestamp.
|
||||
// Quantize at this boundary; never round the viewer's actual playback cursor.
|
||||
const requestTimeNs = currentTimeNs == null ? currentTimeNs : Math.round(currentTimeNs);
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
...cameraEye.eyeUp,
|
||||
].some((value) => !Number.isFinite(value))) ||
|
||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
||||
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
|
||||
)) ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
application_id: identity.applicationId,
|
||||
recording_id: identity.recordingId,
|
||||
blueprint_session_id: blueprintSessionId,
|
||||
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
show_grid: settings.showGrid,
|
||||
show_points: settings.showPoints,
|
||||
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
eye_up: cameraEye?.eyeUp ?? null,
|
||||
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||
current_time_ns: currentTimeNs,
|
||||
current_time_ns: requestTimeNs,
|
||||
}),
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
|
||||
followTrajectory: boolean;
|
||||
},
|
||||
): string {
|
||||
// Following is a tracking property of the current native eye. It must never
|
||||
// initialize the browser journal again: doing so replaces the operator's
|
||||
// current pose with the startup preset immediately before the blueprint is
|
||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
||||
// Following uses the native eye snapshot, never the startup preset.
|
||||
// Plan/3D and explicit reset are the only preset transitions.
|
||||
return [activeView, viewResetGeneration, planView].join(":");
|
||||
}
|
||||
|
||||
@@ -841,6 +842,7 @@ function RerunViewportInstance({
|
||||
const presentationGateRef = useRef(presentationGate);
|
||||
presentationGateRef.current = presentationGate;
|
||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
|
||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||
const recordedBlueprintUrl = sourceUrl
|
||||
? resolveRecordedBlueprintUrl(
|
||||
@@ -857,6 +859,17 @@ function RerunViewportInstance({
|
||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
||||
const pointDisplay = useRecordedPointDisplay({
|
||||
endpoint: recordedBlueprintUrl, settings: sceneSettings, revision: blueprintChannelRevision,
|
||||
sourceGeneration: recordedArtifact?.sha256,
|
||||
ready: status === 'ready' && pointDisplayVisibilityGate === `${recordedBlueprintUrl}:${blueprintChannelRevision}`,
|
||||
onLoad: onPointColorLoadChange,
|
||||
getOwner: () => {
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
return active?.channel.ready && identity ? {identity, send: bytes => active.channel.send_rrd(bytes)} : null;
|
||||
},
|
||||
});
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
@@ -1638,29 +1651,14 @@ function RerunViewportInstance({
|
||||
cameraContract: null,
|
||||
appliedFollowTrajectory: null,
|
||||
pendingFollowCameraEye: null,
|
||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
||||
if ("configure_camera_journal" in viewer) {
|
||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCameraEye: () => "get_camera_eye" in viewer
|
||||
? viewer.get_camera_eye()
|
||||
: RECORDED_RERUN_ORBITAL_EYE,
|
||||
setCameraViewportStart: (spatialViewportStart) => {
|
||||
if ("set_camera_viewport_start" in viewer) {
|
||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
||||
}
|
||||
},
|
||||
: null,
|
||||
getCurrentTimeNs: () => {
|
||||
const currentIdentity = recordedIdentityRef.current;
|
||||
if (!currentIdentity) return null;
|
||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
||||
},
|
||||
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if ("set_camera_max_orbital_radius" in viewer) {
|
||||
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
channel,
|
||||
};
|
||||
blueprintChannelRef.current = blueprintChannel;
|
||||
@@ -2010,6 +2008,7 @@ function RerunViewportInstance({
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPointColorsUrl || !sceneSettings) return;
|
||||
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (
|
||||
@@ -2089,6 +2088,7 @@ function RerunViewportInstance({
|
||||
sceneSettings?.colorMode,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.palette,
|
||||
sceneSettings?.pointDecimationPercent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2113,12 +2113,6 @@ function RerunViewportInstance({
|
||||
},
|
||||
);
|
||||
const cameraContractChanged = active.cameraContract !== cameraContract;
|
||||
if (cameraContractChanged) {
|
||||
active.configureCameraJournal?.(
|
||||
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
}
|
||||
const abort = new AbortController();
|
||||
const previousFollow = active.appliedFollowTrajectory ?? false;
|
||||
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
||||
@@ -2143,7 +2137,9 @@ function RerunViewportInstance({
|
||||
if (eyeRelativeToTracking && currentTimeNs == null) {
|
||||
throw new Error("Recorded tracking cursor is unavailable");
|
||||
}
|
||||
return fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||
return fetchRecordedBlueprintRrd(recordedBlueprintUrl,
|
||||
{...sceneSettings, showPoints: sceneSettings.showPoints && !pointDisplay.hidePoints}, identity, {
|
||||
displayPointBank: pointDisplay.bank,
|
||||
origin: window.location.origin,
|
||||
blueprintSessionId: blueprintSessionIdRef.current,
|
||||
signal: abort.signal,
|
||||
@@ -2157,13 +2153,11 @@ function RerunViewportInstance({
|
||||
planView: recordedPlanView,
|
||||
cameraEye,
|
||||
eyeRelativeToTracking,
|
||||
currentTimeNs,
|
||||
// Only following-eye transitions need a pose at the cursor. Supplying
|
||||
// time for display-only updates makes the API scan the entire archive
|
||||
// for camera bounds, although neither the camera nor its pose changed.
|
||||
currentTimeNs: eyeRelativeToTracking ? currentTimeNs : undefined,
|
||||
reactivateUpdates,
|
||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if (blueprintChannelRef.current === active) {
|
||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const canApply = () => (
|
||||
@@ -2175,24 +2169,29 @@ function RerunViewportInstance({
|
||||
const applyPayload = (payload: Uint8Array) => {
|
||||
if (!canApply()) return false;
|
||||
active.channel.send_rrd(payload);
|
||||
active.setCameraViewportStart?.(
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
// Exclude inactive point generations before admitting their data into
|
||||
// cached recordings whose embedded blueprint predates display controls.
|
||||
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
|
||||
return true;
|
||||
};
|
||||
void (async () => {
|
||||
const firstEye = disablingFollow
|
||||
? transitionEye ?? undefined
|
||||
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
||||
? pendingFollowEye ?? active.getCameraEye?.()
|
||||
: undefined;
|
||||
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
|
||||
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
|
||||
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
||||
disablingFollow || recordedFollowTrajectory
|
||||
);
|
||||
const firstPayload = await requestBlueprint(
|
||||
firstEye,
|
||||
firstEyeIsTrackingRelative,
|
||||
enablingFollow || disablingFollow || Boolean(pendingFollowEye),
|
||||
// Upstream copies incoming blueprints into an active store. Every
|
||||
// settings update must activate it; merely appending rows is invisible.
|
||||
// Activation copies the incoming blueprint store, not the operator's
|
||||
// edited store. Preserve its actual native eye on display-only updates;
|
||||
// omitting the eye resurrects the embedded startup camera.
|
||||
true,
|
||||
);
|
||||
if (!applyPayload(firstPayload)) return;
|
||||
active.cameraContract = cameraContract;
|
||||
@@ -2213,9 +2212,10 @@ function RerunViewportInstance({
|
||||
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
||||
if (!applyPayload(stabilizedPayload)) return;
|
||||
active.pendingFollowCameraEye = null;
|
||||
})().catch(() => {
|
||||
})().catch((error: unknown) => {
|
||||
// The recording remains usable with its embedded default blueprint.
|
||||
// A later settings change retries through the same small channel.
|
||||
if (!abort.signal.aborted) console.warn("Recorded scene settings were not applied", error);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [
|
||||
@@ -2243,6 +2243,9 @@ function RerunViewportInstance({
|
||||
sceneSettings?.showPoints,
|
||||
sceneSettings?.showTrajectory,
|
||||
sceneSettings?.showGrid,
|
||||
pointDisplay.ready,
|
||||
pointDisplay.bank,
|
||||
pointDisplay.hidePoints,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
|
||||
import type { RouteCheck } from "../../core/missions/planner";
|
||||
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
|
||||
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
|
||||
}) {
|
||||
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
|
||||
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
|
||||
<LoadingRegion loading={busy} label="Проверка исходных данных">
|
||||
{error && <p role="alert">{error}</p>}
|
||||
{report && <><dl>
|
||||
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
|
||||
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
|
||||
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
|
||||
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
|
||||
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
|
||||
{report.warnings.map(w => <p key={w}>{w}</p>)}
|
||||
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
|
||||
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
|
||||
</LoadingRegion>
|
||||
</div></Window>;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { RangeControl } from "@nodedc/ui-react";
|
||||
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
|
||||
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
|
||||
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
|
||||
const [cursor, setCursor] = useState(0);
|
||||
useEffect(() => setCursor(0), [poses]);
|
||||
const projection = useMemo(() => {
|
||||
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
|
||||
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
|
||||
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
|
||||
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
|
||||
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
|
||||
return { project, reference: line(source.poses), selected: line(poses), scale };
|
||||
}, [source, poses]);
|
||||
const point = poses[Math.min(cursor, poses.length - 1)];
|
||||
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
|
||||
const [x, y] = projection.project(point);
|
||||
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
|
||||
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
|
||||
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
|
||||
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
|
||||
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
|
||||
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
|
||||
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
|
||||
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
|
||||
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import type {ReactNode} from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
|
||||
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
|
||||
const { data, error, retry } = useSessionOverview(sessionId);
|
||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||
const failure = error || (data?.state === "error" ? data.message : null);
|
||||
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
|
||||
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
|
||||
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {ReactNode} from 'react';
|
||||
import {Button, StatusBadge} from '@nodedc/ui-react';
|
||||
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Composition-level handoff; never sends a device or acquisition command. */
|
||||
export function PlanningCaptureGuard({enabled, onResume, children}: {
|
||||
enabled: boolean;
|
||||
onResume: () => void;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const planning = usePlanningTest();
|
||||
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
|
||||
return <div className="mission-planner__check">
|
||||
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
|
||||
<p>{planning.test!.draft.name}</p>
|
||||
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
|
||||
{planning.error && <p role="alert">{planning.error}</p>}
|
||||
<div className="mission-planner__actions">
|
||||
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
|
||||
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import {useEffect,useState,type ReactNode} from 'react';
|
||||
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
|
||||
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
|
||||
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
|
||||
export function PlanningConnectionWindow({children}:{children:ReactNode}){
|
||||
const p=usePlanningTest(); const [open,setOpen]=useState(true);
|
||||
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
||||
useEffect(()=>{
|
||||
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
|
||||
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
|
||||
if(models.length===1)void selectModel(models[0].model.id);
|
||||
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
|
||||
if(!p.test)return <>{children}</>;
|
||||
const t=p.test, terminal=planningTestTerminal(t);
|
||||
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
|
||||
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
|
||||
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
|
||||
return <div className="mission-planner__check">
|
||||
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
|
||||
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
|
||||
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
|
||||
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
|
||||
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
|
||||
<div className="mission-planner__check">{details}
|
||||
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
|
||||
: terminal?<p>{t.message}</p>:children}
|
||||
</div>
|
||||
</Window>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import {useEffect,useRef,useState} from 'react';
|
||||
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
|
||||
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
|
||||
import {plannerBase} from '../../core/missions/planner';
|
||||
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
|
||||
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
|
||||
|
||||
function nextAnimationFrame(limitMs=500):Promise<number|null>{
|
||||
return new Promise(resolve=>{
|
||||
let frame:number|undefined,settled=false;
|
||||
const finish=(value:number|null)=>{
|
||||
if(settled)return;
|
||||
settled=true;
|
||||
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
|
||||
clearTimeout(timeout);resolve(value);
|
||||
};
|
||||
const timeout=setTimeout(()=>finish(null),limitMs);
|
||||
frame=requestAnimationFrame(()=>finish(performance.now()));
|
||||
});
|
||||
}
|
||||
|
||||
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
|
||||
type HeightBounds={min:number;max:number};
|
||||
const CLIP_CEILING_M=80;
|
||||
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
|
||||
const optionsRef=useRef(options);optionsRef.current=options;
|
||||
const stateRef=useRef({active,revision});stateRef.current={active,revision};
|
||||
const ceilingRef=useRef<number|null>(null);
|
||||
const boundsRef=useRef<{min:number;max:number}|null>(null);
|
||||
const host=useRef<HTMLDivElement>(null);
|
||||
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
|
||||
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
|
||||
const [ceiling,setCeiling]=useState<number|null>(null);
|
||||
useEffect(()=>{
|
||||
ceilingRef.current=null;
|
||||
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
|
||||
boundsRef.current=next;
|
||||
setBounds(next);setCeiling(null);
|
||||
},[runId,heightBounds?.min,heightBounds?.max]);
|
||||
useEffect(()=>{
|
||||
if(!host.current)return;
|
||||
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
|
||||
const reporter=createPlanningPresentationReporter({
|
||||
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
|
||||
});
|
||||
const runtime=createIsolatedRerunHost(host.current);setState('loading');
|
||||
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
|
||||
void runtime.ready.then(async({viewer,mount})=>{
|
||||
if(disposed)return;
|
||||
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
|
||||
if(disposed)return;
|
||||
const channel=viewer.open_channel('planning-'+runId);
|
||||
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
|
||||
stopStream=startPlanningSceneStream({
|
||||
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
|
||||
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
|
||||
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
|
||||
apply:async(bytes,delivery)=>{
|
||||
if(disposed)return;
|
||||
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
|
||||
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
|
||||
const previous=boundsRef.current;
|
||||
if(!previous||previous.min!==next.min||previous.max!==next.max){
|
||||
boundsRef.current=next;setBounds(next);
|
||||
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
|
||||
ceilingRef.current=null;setCeiling(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
sceneAvailable=true;
|
||||
const admissionStarted=performance.now();
|
||||
channel.send_rrd(bytes);
|
||||
const admissionEnded=performance.now();
|
||||
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
|
||||
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
|
||||
// opportunities are retained as an explicit proxy, never as a GPU claim.
|
||||
const first=await nextAnimationFrame();
|
||||
const second=first===null?null:await nextAnimationFrame();
|
||||
if(!disposed)reporter.record(delivery,{
|
||||
rerunAdmissionMs:admissionEnded-admissionStarted,
|
||||
firstAnimationFrameMs:first===null?null:first-admissionEnded,
|
||||
secondAnimationFrameMs:second===null?null:second-admissionEnded,
|
||||
});
|
||||
},
|
||||
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
|
||||
});
|
||||
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
|
||||
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
|
||||
},[runId,retry]);
|
||||
const sourceBounds=bounds??heightBounds;
|
||||
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
|
||||
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
|
||||
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
|
||||
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
|
||||
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
|
||||
onChange={value=>{
|
||||
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
|
||||
ceilingRef.current=next;setCeiling(next);
|
||||
}}/>
|
||||
</div>}
|
||||
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
|
||||
</LoadingRegion>;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
|
||||
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
|
||||
|
||||
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
|
||||
const r=p.result;
|
||||
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
|
||||
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
|
||||
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
|
||||
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
|
||||
</div>},
|
||||
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
|
||||
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
|
||||
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
|
||||
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
|
||||
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
|
||||
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
|
||||
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
|
||||
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
|
||||
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
|
||||
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
|
||||
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
|
||||
</div>},
|
||||
]}/>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from 'react';
|
||||
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
|
||||
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
|
||||
|
||||
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
|
||||
items: PlanningProject[];
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChange: (key: string) => void;
|
||||
onRemove: (project: PlanningProject) => Promise<void>;
|
||||
}) {
|
||||
const [target, setTarget] = useState<PlanningProject | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
return <>
|
||||
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
|
||||
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
|
||||
value: item.key, label: item.name,
|
||||
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
|
||||
action: {
|
||||
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
|
||||
icon: <Icon name="trash" />,
|
||||
tone: 'danger' as const,
|
||||
disabled: !planningProjectDeletable(item),
|
||||
onAction: () => { setError(null); setTarget(item); },
|
||||
},
|
||||
}))]}
|
||||
onChange={key => { if (key) onChange(key); }} />
|
||||
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
|
||||
description={target ? <>
|
||||
<strong>{target.name}</strong>
|
||||
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
|
||||
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
|
||||
</> : null}
|
||||
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
|
||||
onClose={() => { if (!busy) setTarget(null); }}
|
||||
onConfirm={async () => {
|
||||
if (!target || busy) return;
|
||||
setBusy(true); setError(null);
|
||||
try { await onRemove(target); setTarget(null); }
|
||||
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
|
||||
finally { setBusy(false); }
|
||||
}} />
|
||||
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
|
||||
onDismiss={() => setError(null)} />
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
|
||||
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
|
||||
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
|
||||
export function PlanningProjectSettings({p,onStart,starting}:{
|
||||
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
|
||||
}) {
|
||||
const disabled=p.busy||starting;
|
||||
const length=routeLength(p.poses);
|
||||
const routeReady=canStartPlanningRoute(length,'scanner');
|
||||
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
|
||||
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
|
||||
</div>},
|
||||
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
|
||||
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
|
||||
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
|
||||
{p.source&&<>
|
||||
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
|
||||
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
|
||||
</>}
|
||||
</div>},
|
||||
]}/>
|
||||
<div className="inspector-control-stack">
|
||||
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
|
||||
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
|
||||
{p.error&&<p role="alert">{p.error}</p>}
|
||||
</div>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import {useState,type ReactNode,type RefObject} from 'react';
|
||||
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
|
||||
|
||||
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
|
||||
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
|
||||
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
|
||||
}){
|
||||
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
|
||||
const [maximized,setMaximized]=useState(false);
|
||||
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
|
||||
title={title} maximized={maximized} onMaximizedChange={setMaximized}
|
||||
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
|
||||
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
|
||||
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
|
||||
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
|
||||
}}>
|
||||
<div className="mission-planner__check">{children}</div>
|
||||
</WorkspaceWindow>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
|
||||
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
|
||||
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
let disposed = false; setState("loading");
|
||||
const runtime = createIsolatedRerunHost(host.current);
|
||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
if (disposed) return;
|
||||
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
|
||||
}, [sourceUrl, retry]);
|
||||
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
|
||||
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
|
||||
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { overviewChartPoints } from "../../core/observation/sessionOverview";
|
||||
|
||||
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
|
||||
const svg = useRef<SVGSVGElement>(null);
|
||||
const [size, setSize] = useState({ width: 900, height: 160 });
|
||||
useEffect(() => {
|
||||
if (!svg.current) return;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||
}
|
||||
});
|
||||
observer.observe(svg.current);
|
||||
return () => observer.disconnect();
|
||||
}, [chart.length > 0]);
|
||||
const width = Math.max(1, size.width - 106);
|
||||
const height = Math.max(1, size.height - 38);
|
||||
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
|
||||
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
|
||||
return <div className="session-overview__chart">
|
||||
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
|
||||
<g transform="translate(64,10)">
|
||||
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
|
||||
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
|
||||
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
|
||||
</g>)}
|
||||
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
|
||||
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
|
||||
</g>
|
||||
</svg>
|
||||
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
|
||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
|
||||
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
|
||||
|
||||
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
|
||||
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [retry, setRetry] = useState(0);
|
||||
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
|
||||
const [ceiling, setCeiling] = useState<number | null>(null);
|
||||
const [mode, setMode] = useState<OverviewViewMode>("3d");
|
||||
const [viewError, setViewError] = useState<string | null>(null);
|
||||
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
|
||||
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const comparison = metadata?.comparison;
|
||||
const appliedMode = useRef<OverviewViewMode | null>(null);
|
||||
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
|
||||
useEffect(() => {
|
||||
if (!host.current) return;
|
||||
let disposed = false;
|
||||
const abort = new AbortController();
|
||||
appliedMode.current = null;
|
||||
setState("loading");
|
||||
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
|
||||
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
|
||||
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
|
||||
if (disposed) return;
|
||||
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
|
||||
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
|
||||
const runtime = createIsolatedRerunHost(host.current);
|
||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
if (disposed) return;
|
||||
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||
width: "100%", height: "100%", hide_welcome_screen: true,
|
||||
enable_history: false, allow_fullscreen: false,
|
||||
});
|
||||
if (disposed) return;
|
||||
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
|
||||
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
|
||||
}, [sourceUrl, retry]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !metadata || !controller.current) return;
|
||||
const abort = new AbortController();
|
||||
setUpdating(true);
|
||||
const timer = setTimeout(() => {
|
||||
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
|
||||
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
|
||||
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
|
||||
if (abort.signal.aborted || !controller.current) return;
|
||||
controller.current.channel.send_rrd(result.bytes);
|
||||
appliedMode.current = mode;
|
||||
setVisiblePoints(result.visiblePoints); setViewError(null);
|
||||
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
|
||||
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
|
||||
}, 180);
|
||||
return () => { abort.abort(); clearTimeout(timer); };
|
||||
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
|
||||
|
||||
const low = comparison?.height_min_m ?? metadata?.height_min_m;
|
||||
const high = comparison?.height_max_m ?? metadata?.height_max_m;
|
||||
return <>
|
||||
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
|
||||
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
|
||||
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
|
||||
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
|
||||
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
|
||||
{toolbar}
|
||||
</div>
|
||||
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
|
||||
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
|
||||
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
|
||||
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
|
||||
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
|
||||
</div>}
|
||||
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>
|
||||
<span className="session-overview__note" role="status" aria-busy={updating}>
|
||||
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
|
||||
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
|
||||
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
|
||||
</span>
|
||||
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,244 +1,28 @@
|
||||
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
|
||||
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
|
||||
type Vector3 = [number, number, number];
|
||||
type DragMode = "rotate" | "pan" | "roll";
|
||||
|
||||
const ROTATION_RADIANS_PER_POINT = 0.004;
|
||||
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
|
||||
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
|
||||
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
|
||||
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
|
||||
const DOM_WHEEL_DELTA_LINE = 1;
|
||||
const DOM_WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||
position: [16, -16, 18],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 0, 1],
|
||||
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
|
||||
};
|
||||
|
||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||
position: [0, 0, 30],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 1, 0],
|
||||
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
|
||||
};
|
||||
|
||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const length = (value: Vector3) => Math.hypot(...value);
|
||||
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
|
||||
const magnitude = length(value);
|
||||
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
|
||||
};
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
|
||||
const axis = normalize(rawAxis, [0, 0, 1]);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return add(
|
||||
add(scale(value, cosine), scale(cross(axis, value), sine)),
|
||||
scale(axis, dot(axis, value) * (1 - cosine)),
|
||||
);
|
||||
};
|
||||
|
||||
function cloneEye(value: RecordedRerunCameraEye): {
|
||||
position: Vector3;
|
||||
lookTarget: Vector3;
|
||||
eyeUp: Vector3;
|
||||
} {
|
||||
return {
|
||||
position: vector(value.position),
|
||||
lookTarget: vector(value.lookTarget),
|
||||
eyeUp: vector(value.eyeUp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
||||
* here lets a later layer blueprint activate with the exact operator pose.
|
||||
*/
|
||||
export function createRecordedRerunCameraJournal(
|
||||
canvas: HTMLCanvasElement,
|
||||
scope: Window & typeof globalThis,
|
||||
) {
|
||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
||||
let spatialViewportStart = 0;
|
||||
let pointerId: number | null = null;
|
||||
let dragMode: DragMode | null = null;
|
||||
let previousPointer: readonly [number, number] | null = null;
|
||||
let latestPointer: readonly [number, number] | null = null;
|
||||
let maxOrbitalRadius = 1.0e17;
|
||||
|
||||
const isSpatialPoint = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
||||
};
|
||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
||||
const usableUp = () => {
|
||||
const fwd = forward();
|
||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
||||
? [1, 0, 0]
|
||||
: cross(fwd, [0, 0, 1]);
|
||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
||||
const candidate = normalize(eye.eyeUp, fallback);
|
||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
||||
};
|
||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
||||
|
||||
const rotate = (deltaX: number, deltaY: number) => {
|
||||
const radius = orbitRadius();
|
||||
const up = usableUp();
|
||||
let fwd = forward();
|
||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
||||
fwd = normalize(
|
||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
||||
fwd,
|
||||
);
|
||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
||||
const maxPitch = 0.99 * Math.PI / 2;
|
||||
const nextPitch = clamp(
|
||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
||||
-maxPitch,
|
||||
maxPitch,
|
||||
);
|
||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
||||
};
|
||||
|
||||
const pan = (deltaX: number, deltaY: number) => {
|
||||
const fwd = forward();
|
||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
||||
const speed = 0.001 * orbitRadius();
|
||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
||||
eye.position = add(eye.position, translation);
|
||||
eye.lookTarget = add(eye.lookTarget, translation);
|
||||
};
|
||||
|
||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const left = rect.left + rect.width * spatialViewportStart;
|
||||
const centerX = left + (rect.right - left) / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const relativeX = event.clientX - centerX;
|
||||
const relativeY = event.clientY - centerY;
|
||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
||||
if (divisor <= Number.EPSILON) return;
|
||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
||||
};
|
||||
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (!isSpatialPoint(event)) return;
|
||||
pointerId = event.pointerId;
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
||||
const deltaX = event.clientX - previousPointer[0];
|
||||
const deltaY = event.clientY - previousPointer[1];
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
||||
else roll(event, deltaX, deltaY);
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerMove(event);
|
||||
pointerId = null;
|
||||
previousPointer = null;
|
||||
dragMode = null;
|
||||
};
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
||||
? event.clientX
|
||||
: latestPointer?.[0];
|
||||
if (wheelX === undefined || rect.width <= 0) return;
|
||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
||||
? rect.height
|
||||
: 1;
|
||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
||||
// eframe negates the DOM delta before Rerun applies
|
||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
||||
// exponent products collapse to the raw accumulated DOM delta below.
|
||||
const divisor = event.ctrlKey
|
||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
||||
const offset = subtract(eye.position, eye.lookTarget);
|
||||
const radius = length(offset);
|
||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
||||
const nextRadius = clamp(
|
||||
radius * radiusFactor,
|
||||
RERUN_MIN_ORBIT_DISTANCE,
|
||||
Math.max(radius, maxOrbitalRadius),
|
||||
);
|
||||
eye.position = add(
|
||||
eye.lookTarget,
|
||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
||||
scope.addEventListener("pointermove", pointerMove, true);
|
||||
scope.addEventListener("pointerup", pointerEnd, true);
|
||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
||||
|
||||
return {
|
||||
current(): RecordedRerunCameraEye {
|
||||
return {
|
||||
position: [...eye.position],
|
||||
lookTarget: [...eye.lookTarget],
|
||||
eyeUp: [...eye.eyeUp],
|
||||
};
|
||||
},
|
||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
||||
eye = cloneEye(nextEye);
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
pointerId = null;
|
||||
dragMode = null;
|
||||
previousPointer = null;
|
||||
},
|
||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
},
|
||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
||||
if (
|
||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
||||
) {
|
||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
||||
scope.removeEventListener("pointermove", pointerMove, true);
|
||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
||||
scope.removeEventListener("wheel", wheel, true);
|
||||
},
|
||||
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
|
||||
if (value == null) return null; // No 3D frame rendered yet.
|
||||
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
|
||||
const eye = value as Record<string, unknown>;
|
||||
const vector = (key: string): [number, number, number] => {
|
||||
const item = eye[key];
|
||||
if (!Array.isArray(item) || item.length !== 3 ||
|
||||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
|
||||
throw new Error("Invalid native Rerun camera vector");
|
||||
}
|
||||
return [item[0], item[1], item[2]];
|
||||
};
|
||||
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
|
||||
"set_current_time" | "set_playing"
|
||||
> & {
|
||||
open_channel: (name?: string) => Channel;
|
||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye;
|
||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye | null;
|
||||
};
|
||||
|
||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
||||
@@ -90,16 +87,7 @@ export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
|
||||
set_active_timeline: (...args) => command("set_active_timeline", args),
|
||||
set_current_time: (...args) => command("set_current_time", args),
|
||||
set_playing: (...args) => command("set_playing", args),
|
||||
configure_camera_journal: (eye, spatialViewportStart) => command(
|
||||
"configure-camera-journal", [eye, spatialViewportStart],
|
||||
),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
set_camera_viewport_start: (spatialViewportStart) => command(
|
||||
"set-camera-viewport-start", [spatialViewportStart],
|
||||
),
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
|
||||
"set-camera-max-orbital-radius", [maxOrbitalRadius],
|
||||
),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
||||
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
let notify: RerunNotify | null = null;
|
||||
const subscriptions = new Map<number, () => void>();
|
||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
||||
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
|
||||
const send = (message: object) => notify?.(JSON.stringify(message));
|
||||
const stop = () => {
|
||||
notify = null;
|
||||
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||
}
|
||||
channels.clear();
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = null;
|
||||
const current = native;
|
||||
native = null;
|
||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
const starting = required();
|
||||
try {
|
||||
await starting.start(args[0], mount, args[1]);
|
||||
if (native === starting && starting.canvas) {
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = createRecordedRerunCameraJournal(
|
||||
starting.canvas,
|
||||
window as Window & typeof globalThis,
|
||||
);
|
||||
}
|
||||
if (native === starting) send({ type: "started", id });
|
||||
} catch (error) {
|
||||
if (native === starting) send({ type: "start-failed", id, message: String(error) });
|
||||
@@ -87,10 +77,9 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
channels.delete(id);
|
||||
}
|
||||
break;
|
||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
||||
case "get-camera-eye": result = readNativeRerunCameraEye(
|
||||
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
|
||||
); break;
|
||||
case "open": required().open(args[0]); break;
|
||||
case "close": required().close(args[0]); break;
|
||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
|
||||
import type {RecordedPointColorLoadState} from '../RerunViewport';
|
||||
|
||||
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
|
||||
|
||||
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
|
||||
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
|
||||
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
|
||||
sourceGeneration?: string;
|
||||
}) {
|
||||
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
|
||||
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
|
||||
const loadRef = useRef(onLoad); loadRef.current = onLoad;
|
||||
const failed = useRef(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const key = settings ? pointDisplayKey(settings) : '';
|
||||
const percent = settings?.pointDecimationPercent ?? 0;
|
||||
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
|
||||
const matched = sameSource && applied?.key === key;
|
||||
useEffect(() => {
|
||||
// A new settings interaction retries a failed request, but accumulation or
|
||||
// size edits must not cancel an already-running preparation of the same key.
|
||||
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!endpoint || !settings || !ready) return;
|
||||
if (percent === 0 || percent === 100) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
|
||||
return;
|
||||
}
|
||||
if (matched) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: 'Прореженное облако готово.'});
|
||||
return;
|
||||
}
|
||||
const owner = ownerRef.current();
|
||||
if (!owner) return;
|
||||
const abort = new AbortController();
|
||||
failed.current = false;
|
||||
// Never append another generation at the same temporal entity path: an
|
||||
// accumulated range would otherwise draw older samples as well.
|
||||
const bank = crypto.randomUUID().replaceAll('-', '');
|
||||
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
|
||||
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
|
||||
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
|
||||
if (!abort.signal.aborted) owner.send(chunk);
|
||||
}, undefined, sourceGeneration).then(bytes => {
|
||||
if (abort.signal.aborted) return;
|
||||
setApplied({endpoint, revision, key, bank, sourceGeneration});
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
|
||||
message: 'Прореженное облако готово.'});
|
||||
}).catch(() => {
|
||||
if (!abort.signal.aborted) {
|
||||
failed.current = true;
|
||||
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
|
||||
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
|
||||
}
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
|
||||
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
|
||||
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
|
||||
hidePoints: percent === 100};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ interface ApplicationPanelActionsOptions {
|
||||
saveWorkspaceLayout: () => Promise<void>;
|
||||
workspaceLayoutSaving: boolean;
|
||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
|
||||
}
|
||||
|
||||
export function deviceRuntimeUtilityAction({
|
||||
@@ -51,6 +52,7 @@ export function useApplicationPanelActions({
|
||||
saveWorkspaceLayout,
|
||||
workspaceLayoutSaving,
|
||||
systemUtilityActions,
|
||||
sessionOverview,
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
@@ -71,6 +73,11 @@ export function useApplicationPanelActions({
|
||||
});
|
||||
}
|
||||
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
||||
if (definition?.kind === "recordings" && sessionOverview) actions.push({
|
||||
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
|
||||
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
|
||||
onClick: sessionOverview.toggle,
|
||||
});
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
@@ -81,5 +88,6 @@ export function useApplicationPanelActions({
|
||||
saveWorkspaceLayout,
|
||||
systemUtilityActions,
|
||||
workspaceLayoutSaving,
|
||||
sessionOverview,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -97,9 +97,18 @@ export interface DevicePluginHostActions {
|
||||
activateAutomaticSpatialSource: () => void;
|
||||
}
|
||||
|
||||
/** Session-bound, presentation-only extension of an active spatial workflow. */
|
||||
export interface SpatialActivityPresentation {
|
||||
sessionId: string;
|
||||
label: string;
|
||||
detail: string;
|
||||
busy: boolean;
|
||||
}
|
||||
|
||||
export interface DevicePluginConnectionProps {
|
||||
model: DeviceModelDefinition;
|
||||
host: DevicePluginHostActions;
|
||||
spatialActivity?: SpatialActivityPresentation;
|
||||
}
|
||||
|
||||
export interface DeviceUiPlugin {
|
||||
|
||||
@@ -40,7 +40,7 @@ const defaultQuickActions: Record<
|
||||
home: ["spatial-scene", "vehicles"],
|
||||
fleet: ["vehicles", "contour-health"],
|
||||
observation: ["cameras", "world-map"],
|
||||
missions: ["mission-planner", null],
|
||||
missions: ["routes", null],
|
||||
data: ["recordings", "datasets"],
|
||||
system: ["modules", "integrations"],
|
||||
polygon: ["lab-archive", "local-device"],
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||
import { plannerBase, plannerRequest, type Draft } from './planner';
|
||||
|
||||
export interface PlanningLiveTest {
|
||||
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
|
||||
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
|
||||
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
|
||||
tracking_state?: 'acquiring'|'tracking'|'lost';
|
||||
tracking_established?: boolean;
|
||||
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
|
||||
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
|
||||
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
|
||||
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
|
||||
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
|
||||
route_relocalization_policy?: {version?:string;query_radius_m?:number};
|
||||
}
|
||||
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
|
||||
const endpoint = plannerBase+'/live-tests';
|
||||
const terminal = new Set(['completed','cancelled','error','interrupted']);
|
||||
const Context = createContext<{
|
||||
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
|
||||
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
|
||||
}|null>(null);
|
||||
|
||||
/** Server owns the run and its frozen reference, never the workspace launch profile. */
|
||||
export function PlanningTestProvider({children}:{children:ReactNode}) {
|
||||
const [test,setTest]=useState<PlanningLiveTest|null>(null);
|
||||
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
|
||||
const [pollError,setPollError]=useState<string|null>(null);
|
||||
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
|
||||
const generation=useRef(0);
|
||||
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
|
||||
const select=useCallback(async(id:string)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
useEffect(()=>{
|
||||
let disposed=false, timer:ReturnType<typeof setTimeout>;
|
||||
const poll=async()=>{
|
||||
const expected=generation.current;
|
||||
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
|
||||
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
|
||||
if(!disposed)timer=setTimeout(()=>void poll(),1500);
|
||||
};void poll();return()=>{disposed=true;clearTimeout(timer);};
|
||||
},[]);
|
||||
const begin=useCallback(async(draft:Draft)=>{
|
||||
generation.current+=1;setBusy(true);setError(null);
|
||||
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[]);
|
||||
const finish=useCallback(async()=>{
|
||||
if(!test)return;generation.current+=1;setBusy(true);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
const retryInitialization=useCallback(async()=>{
|
||||
if(!test)return;generation.current+=1;setBusy(true);setError(null);
|
||||
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
||||
finally{generation.current+=1;setBusy(false);}
|
||||
},[test]);
|
||||
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
|
||||
}
|
||||
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
|
||||
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
|
||||
|
||||
/** A prepared consumer owns the next capture until explicitly finished or used. */
|
||||
export function planningAwaitsCapture(test:PlanningLiveTest|null){
|
||||
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
|
||||
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
|
||||
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
|
||||
export type Direction = "forward" | "reverse";
|
||||
export interface Draft {
|
||||
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
|
||||
zone: { session_id: string; generation: string; label: string };
|
||||
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
|
||||
}
|
||||
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
|
||||
export const plannerBase = "/api/v1/mission-planner";
|
||||
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
|
||||
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
|
||||
}
|
||||
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
|
||||
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
|
||||
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
|
||||
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
|
||||
const poses = source.poses.slice(start, end + 1);
|
||||
return direction === "reverse" ? poses.reverse() : poses;
|
||||
}
|
||||
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
|
||||
export function endAtDistance(source: PlanningSource, start: number, length: number) {
|
||||
const target = source.poses[start].distance_m + length;
|
||||
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
|
||||
return index < 0 ? source.poses.length - 1 : index;
|
||||
}
|
||||
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
|
||||
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|
||||
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|
||||
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
|
||||
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
|
||||
let low = min, high = max;
|
||||
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
|
||||
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
|
||||
|
||||
export interface PlanningMatchState {
|
||||
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
|
||||
result:{status:string}|null;
|
||||
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
|
||||
presentation_state?:'live'|'historical'|'unlocalized';
|
||||
}
|
||||
/** Current geometry is insufficient: the temporal gate must also be tracking. */
|
||||
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
|
||||
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
|
||||
&&(t.presentation_state===undefined||t.presentation_state==='live')
|
||||
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
|
||||
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
|
||||
&&t.result?.status==='candidate';
|
||||
}
|
||||
|
||||
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||
type PlanningPresentation=PlanningMatchState & {message:string};
|
||||
const phaseLabels:Record<string,string>={
|
||||
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
|
||||
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
|
||||
};
|
||||
|
||||
/** Terminal failures and data freshness take precedence over successful old fits. */
|
||||
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
|
||||
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
|
||||
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
|
||||
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
|
||||
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
|
||||
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
|
||||
if(t.planning_phase==='lost'&&!t.tracking_established)
|
||||
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
|
||||
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
|
||||
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
|
||||
if(t.planning_phase&&phaseLabels[t.planning_phase])
|
||||
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
|
||||
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
|
||||
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
|
||||
}
|
||||
|
||||
/** A plugin may show this only for its matching, authoritative acquiring session. */
|
||||
export function planningActivity(
|
||||
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
|
||||
):SpatialActivityPresentation|undefined{
|
||||
if(!t.query_session_id||!['running','error'].includes(t.state)
|
||||
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
|
||||
const status=planningStatus(t,error);
|
||||
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
|
||||
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { PlanningSceneDelivery } from './planningSceneStream';
|
||||
|
||||
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
|
||||
'missioncore.planning-browser-presentation/v1';
|
||||
|
||||
type BrowserPresentationSample = {
|
||||
cloud_revision:number;
|
||||
cloud_sequence:number;
|
||||
display_epoch:string;
|
||||
request_ms:number;
|
||||
rerun_admission_ms:number;
|
||||
first_animation_frame_ms:number|null;
|
||||
second_animation_frame_ms:number|null;
|
||||
frame_timeout:boolean;
|
||||
source_to_second_animation_frame_upper_bound_ms:number|null;
|
||||
};
|
||||
|
||||
type ReporterDependencies = {
|
||||
url:string;
|
||||
fetcher?:typeof fetch;
|
||||
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||
};
|
||||
|
||||
const batchSize=8;
|
||||
const flushDelayMs=250;
|
||||
|
||||
/**
|
||||
* Bounded, best-effort browser observation. These samples never drive scene
|
||||
* admission, registration, navigation, or device control.
|
||||
*/
|
||||
export function createPlanningPresentationReporter(deps:ReporterDependencies){
|
||||
const fetcher=deps.fetcher??fetch;
|
||||
const schedule=deps.schedule??setTimeout;
|
||||
const cancel=deps.cancel??clearTimeout;
|
||||
let pending:BrowserPresentationSample[]=[];
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
let sending=false,stopped=false;
|
||||
|
||||
const flush=async()=>{
|
||||
if(sending||stopped||!pending.length)return;
|
||||
if(timer!==undefined){cancel(timer);timer=undefined;}
|
||||
sending=true;
|
||||
const samples=pending.splice(0,batchSize);
|
||||
try{
|
||||
// Deliberately best-effort: the presentation observer must not make the
|
||||
// live Rerun channel wait for an unrelated report request.
|
||||
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
|
||||
});
|
||||
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
|
||||
}catch{
|
||||
// A lost browser report is observable as fewer samples, never retried into
|
||||
// an unbounded queue and never promoted into a capture/fit failure.
|
||||
}finally{
|
||||
sending=false;
|
||||
if(!stopped&&pending.length)void flush();
|
||||
}
|
||||
};
|
||||
|
||||
const record=(delivery:PlanningSceneDelivery,timing:{
|
||||
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
|
||||
secondAnimationFrameMs:number|null;
|
||||
})=>{
|
||||
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
|
||||
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
|
||||
const second=timing.secondAnimationFrameMs;
|
||||
pending.push({
|
||||
cloud_revision:delivery.cloudRevision,
|
||||
cloud_sequence:delivery.cloudSequence,
|
||||
display_epoch:delivery.displayEpoch,
|
||||
request_ms:delivery.requestMs,
|
||||
rerun_admission_ms:timing.rerunAdmissionMs,
|
||||
first_animation_frame_ms:timing.firstAnimationFrameMs,
|
||||
second_animation_frame_ms:second,
|
||||
frame_timeout:second===null,
|
||||
source_to_second_animation_frame_upper_bound_ms:second===null?null:
|
||||
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
|
||||
});
|
||||
if(pending.length>=batchSize)void flush();
|
||||
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
|
||||
};
|
||||
|
||||
return {
|
||||
record,
|
||||
dispose:()=>{
|
||||
stopped=true;
|
||||
if(timer!==undefined)cancel(timer);
|
||||
pending=[];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Draft } from './planner';
|
||||
import type { RegistrationReport } from './useRegistrationTest';
|
||||
import { plannerBase, plannerRequest } from './planner';
|
||||
|
||||
export interface PlanningProject {
|
||||
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
|
||||
state: string; created_at_utc: string; result_status: string|null;
|
||||
reference_label: string; query_label: string|null; draft_id: string; revision: number;
|
||||
}
|
||||
export interface PlanningProjectDetail extends PlanningProject {
|
||||
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
|
||||
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
|
||||
scene_note?:string|null;
|
||||
}
|
||||
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
|
||||
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|
||||
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
|
||||
export async function deletePlanningProject(project: PlanningProject) {
|
||||
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
|
||||
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
|
||||
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
|
||||
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
|
||||
);
|
||||
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
|
||||
}
|
||||
export function planningProjectStatus(p: PlanningProject) {
|
||||
if (p.kind === 'draft') return 'Подготовка';
|
||||
if (planningProjectPending(p)) return 'Выполняется';
|
||||
if (p.result_status === 'candidate') return 'Кандидат совмещения';
|
||||
if (p.result_status === 'rejected') return 'Совмещение отклонено';
|
||||
return 'Без результата совмещения';
|
||||
}
|
||||
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
|
||||
return items.find(p => p.key === last)?.key
|
||||
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
|
||||
?? items[0]?.key ?? '';
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
|
||||
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
|
||||
export type PlanningSceneDelivery = {
|
||||
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
|
||||
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
|
||||
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
|
||||
};
|
||||
type Dependencies = {
|
||||
url:string; snapshot:()=>Snapshot;
|
||||
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
|
||||
fetcher?:typeof fetch; now?:()=>number;
|
||||
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||
};
|
||||
|
||||
export function startPlanningSceneStream(deps:Dependencies){
|
||||
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
|
||||
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
|
||||
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
|
||||
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||
let request:AbortController|undefined;
|
||||
const poll=async()=>{
|
||||
if(stopped)return;
|
||||
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
|
||||
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
|
||||
timer=schedule(()=>void poll(),500);return;
|
||||
}
|
||||
request=new AbortController();
|
||||
const deadline=schedule(()=>request?.abort(),2500);
|
||||
let failed=false;
|
||||
try{
|
||||
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
|
||||
params.set('base',String(needsBase||!cursor||key!==nextKey));
|
||||
if(cursor)params.set('cursor',cursor);
|
||||
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
|
||||
if(!response.ok)throw new Error('scene unavailable');
|
||||
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
|
||||
if(stopped)return;
|
||||
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
|
||||
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
|
||||
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
|
||||
const elapsed=now()-started;
|
||||
if(presentation==='live'){
|
||||
// Include the entire request duration: a conservative upper bound, without
|
||||
// comparing clocks on different hosts. Late bytes must not revive green.
|
||||
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
|
||||
throw new Error('scene expired during delivery');
|
||||
}
|
||||
const latest=deps.snapshot();
|
||||
// Never apply a response from the previous display mode or live/ended state.
|
||||
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
|
||||
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
|
||||
if(!nextCursor)throw new Error('scene cursor missing');
|
||||
if(bytes)await deps.apply(bytes,{
|
||||
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
|
||||
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
|
||||
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
|
||||
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
|
||||
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
|
||||
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
|
||||
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
|
||||
}); // Native admission only; the caller may record a separate presentation proxy.
|
||||
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
|
||||
}catch{
|
||||
// Keep the last admitted cursor (including camera intent). A full geometry
|
||||
// repair after a missed/expired response is not permission to reset the eye.
|
||||
if(!stopped){failed=true;needsBase=true;deps.error();}
|
||||
}finally{
|
||||
cancel(deadline);request=undefined;
|
||||
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
|
||||
}
|
||||
};
|
||||
void poll();
|
||||
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
|
||||
}
|
||||
|
||||
function parseNonnegativeInteger(value:string|null){
|
||||
if(value===null||!/^\d+$/.test(value))return null;
|
||||
const parsed=Number(value);
|
||||
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value:string|null){
|
||||
if(value===null||value.trim()==='')return null;
|
||||
const parsed=Number(value);
|
||||
return Number.isFinite(parsed)?parsed:null;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
||||
|
||||
export function useMissionPlanner() {
|
||||
const [sessions, setSessions] = useState<SessionOption[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
const [saved, setSaved] = useState<Draft | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [direction, setDirection] = useState<Direction>("forward");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [catalogBusy, setCatalogBusy] = useState(true);
|
||||
const [loadVersion, setLoadVersion] = useState(0);
|
||||
const [sourceVersion, setSourceVersion] = useState(0);
|
||||
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
|
||||
const [check, setCheck] = useState<RouteCheck | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setCatalogBusy(true); setError(null);
|
||||
void Promise.all([
|
||||
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
|
||||
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
|
||||
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
|
||||
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
|
||||
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
|
||||
return () => abort.abort();
|
||||
}, [loadVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setSourceError(null);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
|
||||
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
|
||||
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId, sourceVersion, pinnedGeneration]);
|
||||
|
||||
const currentSource = source?.session_id === sessionId ? source : null;
|
||||
// New executions always own the complete reference. No independent interval
|
||||
// state can survive a source change or collapse on same-source reselection.
|
||||
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
|
||||
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
|
||||
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
|
||||
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|
||||
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
|
||||
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
|
||||
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
|
||||
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
|
||||
const openDraft = async (id: string) => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
|
||||
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
|
||||
setDirection(next.route.direction); setCheck(null);
|
||||
setSourceVersion(n => n + 1);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const save = async () => {
|
||||
if (!ready || !currentSource) return;
|
||||
setBusy(true); setError(null); setCheck(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
|
||||
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
|
||||
generation: currentSource.generation, whole_recording: true, direction,
|
||||
}) });
|
||||
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
|
||||
return next;
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const runCheck = async () => {
|
||||
if (!saved || dirty || !ready) return;
|
||||
setBusy(true); setError(null); setCheck(null);
|
||||
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
|
||||
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
const loadMore = async () => {
|
||||
if (!cursor) return;
|
||||
setCatalogBusy(true);
|
||||
try {
|
||||
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
|
||||
};
|
||||
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
|
||||
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
|
||||
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
|
||||
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
|
||||
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { plannerBase, plannerRequest } from './planner';
|
||||
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
|
||||
|
||||
const storageKey = 'missioncore.planning-project.selected.v1';
|
||||
export function usePlanningProjects() {
|
||||
const [items, setItems] = useState<PlanningProject[]>([]);
|
||||
const [key, setKey] = useState('');
|
||||
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
|
||||
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
|
||||
const [version, setVersion] = useState(0);
|
||||
const initialized = useRef(false);
|
||||
const selectedKey = useRef('');
|
||||
const removedKeys = useRef(new Set<string>());
|
||||
const removing = useRef(false);
|
||||
const select = useCallback((value: string) => {
|
||||
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
|
||||
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
|
||||
}, []);
|
||||
const refresh = useCallback(() => setVersion(n=>n+1), []);
|
||||
const remove = useCallback(async (project: PlanningProject) => {
|
||||
if (removing.current) throw new Error('Удаление уже выполняется.');
|
||||
removing.current = true;
|
||||
try {
|
||||
await deletePlanningProject(project);
|
||||
removedKeys.current.add(project.key);
|
||||
setItems(items => items.filter(item => item.key !== project.key));
|
||||
if (selectedKey.current === project.key) select('');
|
||||
} finally { removing.current = false; }
|
||||
}, [select]);
|
||||
useEffect(()=>{
|
||||
const abort = new AbortController(); setLoading(true); setError(null);
|
||||
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
|
||||
if (abort.signal.aborted) return;
|
||||
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
|
||||
setItems(visible);
|
||||
if (!initialized.current) {
|
||||
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
|
||||
select(defaultPlanningProject(visible,last));
|
||||
}
|
||||
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
|
||||
return ()=>abort.abort();
|
||||
},[version,select]);
|
||||
useEffect(()=>{
|
||||
if (!key) return;
|
||||
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
|
||||
setDetail(null); setError(null);
|
||||
const [kind,id]=key.split(':');
|
||||
const poll=async()=>{
|
||||
try {
|
||||
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
|
||||
if(abort.signal.aborted || removedKeys.current.has(key))return;
|
||||
if(next.key!==key)throw new Error('Получен результат другого исследования.');
|
||||
setDetail(next);setError(null);
|
||||
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
|
||||
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
|
||||
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
|
||||
};
|
||||
void poll(); return()=>{abort.abort();clearTimeout(timer);};
|
||||
},[key,version]);
|
||||
return {items,key,detail,loading,error,select,refresh,remove};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
|
||||
|
||||
export interface RegistrationReport {
|
||||
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
|
||||
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
|
||||
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
|
||||
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
|
||||
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
|
||||
}
|
||||
|
||||
export function useRegistrationTest(draft: Draft | null) {
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
|
||||
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
|
||||
const [catalogBusy,setCatalogBusy]=useState(false);
|
||||
// Repeat recordings belong to the planner, including its own captured passes;
|
||||
// they are not restricted to the independent-reference catalog.
|
||||
useEffect(()=>{
|
||||
const abort=new AbortController();setCatalogBusy(true);
|
||||
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
|
||||
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
|
||||
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
|
||||
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
|
||||
return()=>abort.abort();
|
||||
},[]);
|
||||
const loadMore=async()=>{
|
||||
if(!cursor||catalogBusy)return;setCatalogBusy(true);
|
||||
try{
|
||||
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
|
||||
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
|
||||
};
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
||||
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
|
||||
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
|
||||
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId]);
|
||||
const busy = submitting;
|
||||
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
|
||||
const run = async (savedDraft = draft) => {
|
||||
if (!source || busy || !savedDraft) return;
|
||||
setSubmitting(true); setError(null);
|
||||
try {
|
||||
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
|
||||
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
|
||||
});
|
||||
return data;
|
||||
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
|
||||
};
|
||||
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {SceneSettings} from '../../sceneSettings';
|
||||
|
||||
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
|
||||
export function pointDisplayKey(settings: PointDisplaySettings): string {
|
||||
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
|
||||
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
|
||||
}
|
||||
|
||||
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
|
||||
export async function streamRecordedPointDisplay(
|
||||
blueprintUrl: string, settings: PointDisplaySettings,
|
||||
identity: {applicationId: string; recordingId: string}, displayBank: string,
|
||||
signal: AbortSignal, send: (chunk: Uint8Array) => void,
|
||||
fetcher: typeof fetch = fetch,
|
||||
sourceGeneration?: string,
|
||||
): Promise<number> {
|
||||
const endpoint = new URL(blueprintUrl, window.location.origin);
|
||||
const percent = settings.pointDecimationPercent ?? 0;
|
||||
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|
||||
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|
||||
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|
||||
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
|
||||
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
|
||||
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
|
||||
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
|
||||
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
|
||||
point_decimation_percent: percent, display_bank: displayBank,
|
||||
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
|
||||
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
|
||||
throw new Error('Не удалось подготовить прореженное облако');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
let bytes = 0;
|
||||
let pending = new Uint8Array(0);
|
||||
let admitted = false;
|
||||
let complete = false;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
const combined = new Uint8Array(pending.length + value.length);
|
||||
combined.set(pending); combined.set(value, pending.length); pending = combined;
|
||||
if (!admitted) {
|
||||
if (pending.length < 4) continue;
|
||||
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
|
||||
pending = pending.slice(4); admitted = true;
|
||||
}
|
||||
while (pending.length >= 4 && !complete) {
|
||||
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
|
||||
if (length === 0) { pending = pending.slice(4); complete = true; break; }
|
||||
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
|
||||
if (pending.length < length + 4) break;
|
||||
const payload = pending.slice(4, length + 4);
|
||||
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
|
||||
send(payload); pending = pending.slice(length + 4);
|
||||
}
|
||||
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
|
||||
}
|
||||
if (!complete || pending.length) throw new Error('Поток облака не завершён');
|
||||
return bytes;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export type ObservationSessionStatus =
|
||||
| "interrupted"
|
||||
| "failed";
|
||||
|
||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
||||
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
|
||||
|
||||
export interface ObservationLabInstance {
|
||||
labId: string;
|
||||
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
|
||||
seekable: true;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
mapGeneration?: string;
|
||||
playback: {
|
||||
speed: number;
|
||||
loop: boolean;
|
||||
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
|
||||
"seekable",
|
||||
"byte_length",
|
||||
"sha256",
|
||||
"map_generation",
|
||||
"playback",
|
||||
"media_sources",
|
||||
]);
|
||||
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
|
||||
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
||||
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
|
||||
}
|
||||
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
|
||||
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
|
||||
}
|
||||
const viewerSourceUrl = requireString(
|
||||
launch.viewer_source_url,
|
||||
"launch.viewer_source_url",
|
||||
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
|
||||
integer: true,
|
||||
}),
|
||||
sha256: launch.sha256,
|
||||
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
|
||||
playback: { speed, loop: launch.playback.loop },
|
||||
mediaSources,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
|
||||
|
||||
const schema = 'missioncore.session-display-profile/v1';
|
||||
const fields = {
|
||||
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
|
||||
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
|
||||
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
|
||||
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
|
||||
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
|
||||
} as const;
|
||||
|
||||
function endpoint(sessionId: string) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
|
||||
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
|
||||
}
|
||||
|
||||
export function encodeSessionDisplay(settings: SceneSettings) {
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
|
||||
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
|
||||
}
|
||||
|
||||
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
|
||||
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
|
||||
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
|
||||
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
|
||||
}
|
||||
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
|
||||
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
|
||||
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
|
||||
}
|
||||
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|
||||
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|
||||
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|
||||
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|
||||
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
|
||||
throw new Error('Некорректные настройки отображения');
|
||||
}
|
||||
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
|
||||
}
|
||||
|
||||
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
|
||||
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
|
||||
const document = await response.json();
|
||||
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
|
||||
: decodeSessionDisplay(document, sessionId);
|
||||
}
|
||||
|
||||
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
|
||||
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
|
||||
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
|
||||
decodeSessionDisplay(await response.json(), sessionId);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
export interface SessionOverviewMetrics {
|
||||
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
|
||||
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
|
||||
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
|
||||
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
|
||||
interval_statistics_complete: boolean; gaps_over_second: number | null;
|
||||
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
|
||||
spatial_available: boolean;
|
||||
}
|
||||
export interface SessionOverview {
|
||||
schema_version: "missioncore.session-overview/v1";
|
||||
state: "queued" | "preparing" | "ready" | "error";
|
||||
session: { session_id: string; display_name: string; duration_seconds: number | null;
|
||||
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
|
||||
metrics?: SessionOverviewMetrics | null;
|
||||
scene_url?: string | null;
|
||||
message?: string;
|
||||
messages_processed?: number;
|
||||
}
|
||||
|
||||
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
|
||||
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
|
||||
signal, method: retry ? "POST" : "GET", cache: "no-store",
|
||||
});
|
||||
if (!response.ok) throw new Error("Обзор записи недоступен.");
|
||||
const data = await response.json() as SessionOverview;
|
||||
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|
||||
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
|
||||
if (data.scene_url) {
|
||||
const url = new URL(data.scene_url, window.location.origin);
|
||||
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
|
||||
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
|
||||
const xmax = Math.max(1, ...valid.map(p => p[0]));
|
||||
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
|
||||
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
export type OverviewViewMode = "3d" | "top";
|
||||
export type OverviewRepresentation = "original" | "corrected";
|
||||
export interface OverviewComparison {
|
||||
generation: string; map_generation: string; sample_points: number; source_points: number;
|
||||
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
|
||||
}
|
||||
export interface OverviewSpatialMetadata {
|
||||
default_representation?: OverviewRepresentation;
|
||||
height_min_m: number | null; height_max_m: number | null; sample_points: number;
|
||||
comparison?: OverviewComparison;
|
||||
}
|
||||
|
||||
function endpoint(source: string) {
|
||||
const url = new URL(source, window.location.origin);
|
||||
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
|
||||
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
|
||||
return url;
|
||||
}
|
||||
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
|
||||
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
|
||||
if (!response.ok) throw new Error("Параметры среза недоступны.");
|
||||
return response.json();
|
||||
}
|
||||
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
|
||||
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
|
||||
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
|
||||
const url = endpoint(source);
|
||||
const generation = url.searchParams.get("generation");
|
||||
url.search = "";
|
||||
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
|
||||
comparison_generation: comparisonGeneration, representation }) });
|
||||
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
|
||||
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
|
||||
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
|
||||
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
|
||||
|
||||
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
|
||||
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const applyRef = useRef(apply); applyRef.current = apply;
|
||||
const current = useRef(sessionId); current.current = sessionId;
|
||||
const writeTail = useRef(Promise.resolve());
|
||||
const revision = useRef(0);
|
||||
useEffect(() => {
|
||||
setError(null);
|
||||
if (!sessionId) return;
|
||||
const abort = new AbortController();
|
||||
const readRevision = revision.current;
|
||||
applyRef.current({...defaultSceneSettings});
|
||||
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
|
||||
if (abort.signal.aborted || current.current !== sessionId) return;
|
||||
if (revision.current === readRevision) applyRef.current(settings);
|
||||
}).catch(reason => {
|
||||
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [sessionId]);
|
||||
const edited = useCallback(() => { revision.current += 1; }, []);
|
||||
const save = useCallback((settings: SceneSettings) => {
|
||||
const id = current.current;
|
||||
if (!id) return;
|
||||
const snapshot = {...settings};
|
||||
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
|
||||
if (current.current === id) setError(null);
|
||||
}).catch(reason => {
|
||||
if (current.current === id) setError(String(reason.message ?? reason));
|
||||
});
|
||||
}, []);
|
||||
return {save, edited, error, dismissError: () => setError(null)};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
|
||||
|
||||
export function useSessionOverview(sessionId: string) {
|
||||
const [data, setData] = useState<SessionOverview | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||
useEffect(() => {
|
||||
const abort = new AbortController();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
setData(null); setError(null);
|
||||
const load = async (retry = false) => {
|
||||
try {
|
||||
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
|
||||
if (abort.signal.aborted) return;
|
||||
setData(next);
|
||||
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
|
||||
} catch (reason) {
|
||||
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
|
||||
}
|
||||
};
|
||||
void load(retryGeneration > 0);
|
||||
return () => { abort.abort(); clearTimeout(timer); };
|
||||
}, [sessionId, retryGeneration]);
|
||||
return { data: data?.session.session_id === sessionId ? data : null, error,
|
||||
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
|
||||
}
|
||||
|
||||
export function useSessionOverviewMode(enabled: boolean) {
|
||||
const [open, setOpen] = useState(false);
|
||||
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
|
||||
};
|
||||
window.addEventListener("keydown", close, true);
|
||||
return () => window.removeEventListener("keydown", close, true);
|
||||
}, [open]);
|
||||
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Entry intent belongs to application navigation, not a retained server job. */
|
||||
export type WorkspaceLaunchProfile = 'direct' | 'planning';
|
||||
|
||||
export function workspaceLaunchProfile(
|
||||
current: WorkspaceLaunchProfile,
|
||||
kind: string,
|
||||
requested: WorkspaceLaunchProfile = 'direct',
|
||||
): WorkspaceLaunchProfile {
|
||||
return kind === 'device' || kind === 'spatial' ? requested : current;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import "@nodedc/ui-core/styles.css";
|
||||
|
||||
import App from "./App";
|
||||
import { installedDevicePlugins, installedNodeSensorContributions } from "./composition/devicePlugins";
|
||||
import { PlanningTestProvider } from "./core/missions/PlanningTestContext";
|
||||
import { DevicePluginHostProvider } from "./core/device-plugins/DevicePluginHost";
|
||||
import { ComputeContourProvider } from "./core/system/ComputeContourContext";
|
||||
import "./styles.css";
|
||||
@@ -25,7 +26,7 @@ createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<DevicePluginHostProvider plugins={installedDevicePlugins} nodeSensorContributions={installedNodeSensorContributions}>
|
||||
<ComputeContourProvider>
|
||||
<App />
|
||||
<PlanningTestProvider><App /></PlanningTestProvider>
|
||||
</ComputeContourProvider>
|
||||
</DevicePluginHostProvider>
|
||||
</StrictMode>,
|
||||
|
||||
@@ -402,11 +402,11 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
},
|
||||
{
|
||||
id: "mission-planner",
|
||||
root: "missions",
|
||||
root: "polygon",
|
||||
label: "Планировщик",
|
||||
title: "Планировщик миссии",
|
||||
eyebrow: "МИССИИ / ЧЕРНОВИК",
|
||||
description: "Черновик миссии, аппараты, зона, маршрут, действия и ограничения.",
|
||||
title: "Исследование планирования",
|
||||
eyebrow: "LAB / ПЛАНИРОВАНИЕ",
|
||||
description: "Эталонный участок и сопоставление нового прохода сканера.",
|
||||
icon: "edit",
|
||||
kind: "missions",
|
||||
groups: [
|
||||
@@ -414,8 +414,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
title: "Состав миссии",
|
||||
description: "Продуктовый контракт без отправки команд на борт.",
|
||||
capabilities: [
|
||||
contract("Назначение аппарата", "Один аппарат сейчас, несколько — в будущем."),
|
||||
contract("Зона и маршрут", "Точки, коридор, высота/скорость и геозоны."),
|
||||
active("Зона и маршрут", "Выбор сохранённой записи, участка траектории и направления."),
|
||||
contract("Полезная нагрузка", "Запуск сенсоров и требуемые выходные данные."),
|
||||
later("Проверка выполнимости", "Расчёт энергии, связи, столкновений и резервов."),
|
||||
],
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
.mission-planner { height: 100%; box-sizing: border-box; min-height: 360px; display: flex; flex-direction: column; gap: 16px; min-width: 0; flex: 1; padding: 8px 16px 20px; }
|
||||
.mission-planner__toolbar { display: flex; align-items: end; gap: 8px; flex-wrap: wrap; }
|
||||
.mission-planner__toolbar > .nodedc-field { flex: 1; min-width: 180px; }
|
||||
.mission-planner__toolbar > .nodedc-select-anchor { width: 260px; max-width: 100%; }
|
||||
.mission-planner__layout { display: grid; grid-template-columns: minmax(270px, 320px) minmax(0, 1fr); gap: 16px; flex: 1; min-height: 0; }
|
||||
.mission-planner__editor { min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.mission-planner__section { display: flex; flex-direction: column; gap: 10px; }
|
||||
.mission-planner h2 { font-size: var(--nodedc-font-size-md); margin: 0; font-weight: 600; }
|
||||
.mission-planner p, .mission-planner__check p { font-size: var(--nodedc-font-size-sm); margin: 0; line-height: 1.5; }
|
||||
.mission-planner small { font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); line-height: 1.5; }
|
||||
.mission-planner__actions { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.mission-planner__viewer { min-height: 0; }
|
||||
.mission-planner__viewer-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 12px; }
|
||||
.mission-planner__zone-loading { flex: 1; min-height: 320px; }
|
||||
.mission-planner__notice { font-size: var(--nodedc-font-size-sm); padding: 8px 0; }
|
||||
.mission-planner__expanded { width: min(1300px, 94vw); }
|
||||
.mission-planner__expanded .mission-planner__viewer { height: min(65vh, 620px); min-height: 360px; }
|
||||
.mission-planner__expanded .mission-route-preview svg { min-height: 140px; }
|
||||
.mission-route-preview { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 12px; padding: 16px; }
|
||||
.mission-route-preview svg { width: 100%; flex: 1; min-height: 250px; }
|
||||
.mission-route-preview p { margin: 0; line-height: 1.5; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.mission-route-preview__reference { stroke: var(--nodedc-text-muted); opacity: .35; stroke-width: 2; }
|
||||
.mission-route-preview__selected { stroke: var(--nodedc-text-primary); stroke-width: 2; }
|
||||
.mission-route-preview__cursor { fill: var(--nodedc-text-primary); }
|
||||
.mission-route-preview text { fill: var(--nodedc-text-muted); font-size: 12px; }
|
||||
.mission-planner__check { display: flex; flex-direction: column; gap: 16px; }
|
||||
.mission-planner__check dl { font-size: var(--nodedc-font-size-sm); }
|
||||
.mission-planner__check dl > div { display: flex; justify-content: space-between; gap: 16px; padding: 8px 0; }
|
||||
.mission-planner__check dd { margin: 0; text-align: right; }
|
||||
.mission-planner__check dt { color: var(--nodedc-text-muted); }
|
||||
@media (max-width: 900px) { .mission-planner { height: auto; } .mission-planner__layout { grid-template-columns: minmax(0, 1fr); } .mission-planner__editor { max-height: 55vh; } .mission-planner__viewer { height: 65vh; } }
|
||||
|
||||
.mission-registration { width: min(1100px, 94vw); }
|
||||
.mission-registration__scene { position: relative; height: 380px; min-height: 300px; overflow: hidden; }
|
||||
.mission-registration--expanded .mission-registration__scene { height: 65vh; }
|
||||
.mission-registration .mission-planner__viewer-head { font-size: var(--nodedc-font-size-xs); }
|
||||
|
||||
.planning-connection { width: min(1080px, 94vw); }
|
||||
.planning-live__viewport { overflow: hidden; min-height: 520px; }
|
||||
.planning-live__scene { position: relative; height: 60vh; min-height: 420px; overflow: hidden; }
|
||||
.planning-live--expanded { width: min(1400px, 96vw); }
|
||||
.planning-live--expanded .planning-live__scene { height: 70vh; }
|
||||
.planning-live .mission-planner__toolbar, .planning-live .mission-planner__actions { font-size: var(--nodedc-font-size-sm); }
|
||||
|
||||
.planning-live__scene > .session-overview__empty { position: absolute; inset: 0; }
|
||||
|
||||
.spatial-viewport-shell > .planning-live__scene { position: absolute; inset: 0; width: 100%; height: 100%; min-height: 0; }
|
||||
.planning-live__summary { display: flex; flex-wrap: wrap; gap: 8px 20px; padding: 8px 4px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.planning-live__camera-note { position: absolute; right: 20px; bottom: 24px; display: flex; align-items: center; gap: 8px; font-size: var(--nodedc-font-size-xs); color: var(--nodedc-text-muted); }
|
||||
.mission-planner__editor .mission-planner__actions { display: grid; grid-template-columns: minmax(0, 1fr); }
|
||||
.mission-planner__editor .inspector-control-stack > .nodedc-button, .mission-planner__editor .mission-planner__actions > .nodedc-button { width: 100%; }
|
||||
.mission-planner__viewer-head--end, .session-overview__scene-head--end { justify-content: flex-end; padding: 12px; }
|
||||
/* The pinned embedded Rerun canvas reserves 28 px above its 26 px view strip.
|
||||
Allocate this non-content chrome outside the clipped host; pointer coordinates
|
||||
remain native and the scene retains the full host height. */
|
||||
.session-overview__runtime.rerun-single-view-content { top: -54px; }
|
||||
|
||||
/* Scene-first planning: settings float within the stage and never allocate a column. */
|
||||
.planning-project { padding: 8px 16px 16px; }
|
||||
.planning-project__header-tools { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
||||
.planning-project__header-tools > .nodedc-select-anchor { width: clamp(200px, 20vw, 300px); min-width: 0; }
|
||||
.planning-project__stage { position: relative; overflow: hidden; flex: 1; min-height: 360px; }
|
||||
.planning-project__viewport { position: absolute; inset: 0; overflow: hidden; }
|
||||
.planning-project__viewport > .mission-registration__scene { flex: 1; height: auto; min-height: 0; }
|
||||
.planning-project__result-header { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; padding: 12px 16px; font-size: var(--nodedc-font-size-sm); }
|
||||
.planning-project__legend { padding: 8px 16px; }
|
||||
.planning-project__inspector .inspector-control-stack > .nodedc-button { width: 100%; }
|
||||
.planning-project__inspector p { margin: 0; font-size: var(--nodedc-font-size-sm); line-height: 1.5; }
|
||||
.planning-project__inspector dl { margin: 0; font-size: var(--nodedc-font-size-sm); }
|
||||
.planning-project__inspector dl > div { display: flex; align-items: start; justify-content: space-between; gap: 12px; padding: 6px 0; }
|
||||
.planning-project__inspector dt { color: var(--nodedc-text-muted); }
|
||||
.planning-project__inspector dd { margin: 0; text-align: right; overflow-wrap: anywhere; }
|
||||
@media (max-width: 900px) { .mission-planner.planning-project { height: 100%; } .planning-project__header-tools { flex-wrap: wrap; } }
|
||||
@@ -187,9 +187,6 @@
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.scene-metrics {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* The shared shell switches to its compact overlay at 760 px, but Mission
|
||||
@@ -544,11 +541,6 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.scene-status--top-left {
|
||||
top: 0.6rem;
|
||||
left: 0.6rem;
|
||||
}
|
||||
|
||||
.scene-device-controls {
|
||||
top: 0.6rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
.session-overview { height: 100%; min-height: 360px; flex: 1; min-width: 0; }
|
||||
.session-overview__loading, .session-overview__split { height: 100%; min-height: 0; }
|
||||
.session-overview__panel { height: 100%; min-height: 0; min-width: 0; display: flex; flex-direction: column; overflow: hidden; container-type: inline-size; }
|
||||
.session-overview__panel h2 { margin: 0; padding: 16px 18px; font: inherit; font-size: var(--nodedc-font-size-md); font-weight: 600; flex: none; }
|
||||
.session-overview__scene { position: relative; flex: 1; min-height: 0; overflow: hidden; }
|
||||
.session-overview__runtime { position: absolute; inset: 0; }
|
||||
.session-overview__runtime iframe { display: block; width: 100%; height: 100%; border: 0; }
|
||||
.session-overview__scene-head { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; padding-right: 12px; flex: none; }
|
||||
.session-overview__height { position: absolute; z-index: 2; top: 44px; right: 12px; bottom: 12px; width: 58px; display: flex; flex-direction: column; align-items: center; gap: 6px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); }
|
||||
.session-overview__height > .nodedc-range-scale { flex: 1; min-height: 0; }
|
||||
.session-overview__empty { display: flex; flex: 1; min-height: 160px; align-items: center; justify-content: center; flex-direction: column; gap: 14px; font-size: var(--nodedc-font-size-sm); color: var(--nodedc-text-muted); }
|
||||
.session-overview__scene .session-overview__empty { position: absolute; inset: 0; }
|
||||
.session-overview__note { padding: 8px 16px 12px; color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); flex: none; }
|
||||
.session-overview__facts { overflow: auto; min-height: 0; padding: 0 18px 12px; font-size: var(--nodedc-font-size-sm); }
|
||||
.session-overview__facts dl { margin: 0; }
|
||||
.session-overview__facts dl > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; padding: 8px 0; }
|
||||
.session-overview__facts dt { color: var(--nodedc-text-muted); }
|
||||
.session-overview__facts dd { margin: 0; text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.session-overview__facts p { color: var(--nodedc-text-muted); font-size: var(--nodedc-font-size-xs); line-height: 1.5; }
|
||||
.session-overview__chart { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.session-overview__chart svg { width: 100%; flex: 1; min-height: 0; overflow: visible; }
|
||||
.session-overview__chart text { fill: var(--nodedc-text-muted); font-size: 12px; }
|
||||
.session-overview__grid { stroke: var(--nodedc-text-muted); opacity: .2; stroke-width: 1; }
|
||||
.session-overview__series { stroke: var(--nodedc-text-primary); stroke-width: 1.5; }
|
||||
@container (max-width: 270px) { .session-overview__facts dl > div { grid-template-columns: minmax(0, 1fr); gap: 3px; } .session-overview__facts dd { text-align: left; overflow-wrap: anywhere; } }
|
||||
@@ -1,39 +1,25 @@
|
||||
import {SpatialScene, EmptySpatialStage} from '../../../../packages/spatial-ui/src';
|
||||
import {SpatialToolbarActions} from "../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
|
||||
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
|
||||
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
|
||||
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
observationSourceStatusLabel,
|
||||
} from "../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../core/observation/liveReceiverWatchdog";
|
||||
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../core/observation/viewerProfile";
|
||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
type RecordedPerceptionLoadState,
|
||||
type RecordedPointColorLoadState,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
type CapabilityStatus,
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import { sourceModeLabel } from "../presentation";
|
||||
import type { WorkspaceRendererProps } from "./contracts";
|
||||
import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
|
||||
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
|
||||
@@ -91,608 +77,6 @@ function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition;
|
||||
</section>
|
||||
);
|
||||
}
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
requestedPlaybackSeconds,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
livePerceptionLayers,
|
||||
onLivePerceptionLayersChange,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
|
||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
|
||||
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
|
||||
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
|
||||
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
// An explicit manual gRPC source has no Mission Core metrics producer. Its
|
||||
// native Rerun range is therefore the only available activity proof.
|
||||
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
|
||||
(liveRerunSource && !streamActive ? 1 : null);
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
|
||||
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
|
||||
const observationSources = state?.observationSources ?? [];
|
||||
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||
const pointCloudVisible = pointCloudSource
|
||||
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
|
||||
: Boolean(sourceUrl.trim());
|
||||
const mediaSources = observationSources.filter(
|
||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||
);
|
||||
const recordedPerceptionSupported =
|
||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||
const recordedPerceptionEnabled =
|
||||
showDetections2d || showSegmentation || showCuboids3d;
|
||||
// The native recorded camera remains the authoritative original. Only 2D
|
||||
// image-space overlays need Rerun's paired camera/world composition; 3D
|
||||
// cuboids are added directly to the stable spatial view.
|
||||
const unifiedPerception = recordedPerceptionSupported &&
|
||||
(showDetections2d || showSegmentation);
|
||||
const livePerceptionAvailable = !recordedSource && streamActive;
|
||||
const detections2dActive = recordedSource
|
||||
? showDetections2d
|
||||
: livePerceptionLayers.detections2d;
|
||||
const segmentationActive = recordedSource
|
||||
? showSegmentation
|
||||
: livePerceptionLayers.segmentation;
|
||||
const cuboids3dActive = recordedSource
|
||||
? showCuboids3d
|
||||
: livePerceptionLayers.cuboids3d;
|
||||
const visibleMediaSources = mediaSources.filter((source) =>
|
||||
observationLayout.visibleSourceIds.has(source.id) &&
|
||||
!source.id.startsWith("recorded.perception."),
|
||||
);
|
||||
const initialRecordedPlaybackStartSeconds = recordedSource
|
||||
? mediaSources.reduce<number | undefined>((earliest, source) => {
|
||||
if (
|
||||
source.id.startsWith("recorded.perception.") ||
|
||||
source.delivery?.kind !== "recorded-fmp4-manifest"
|
||||
) return earliest;
|
||||
const start = source.delivery.timelineStartSeconds;
|
||||
return earliest === undefined ? start : Math.min(earliest, start);
|
||||
}, undefined)
|
||||
: undefined;
|
||||
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
|
||||
const pointCloudFocused = Boolean(
|
||||
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pointCloudFocused) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
observationLayout.setFocusedSourceId(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
|
||||
const floatingSourceMaximized = !unifiedPerception &&
|
||||
Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
|
||||
setPerceptionLoad(next);
|
||||
if (next.phase === "unavailable" || next.phase === "error") {
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
}
|
||||
}, []);
|
||||
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
|
||||
setPointColorLoad(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPerceptionLoad({
|
||||
phase: recordedSource ? "loading" : "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
|
||||
});
|
||||
setPerceptionRetryGeneration(0);
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setRecordedViewResetGeneration(0);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [recordedSource, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
setPerceptionLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRequestedPlaybackSeconds.current = null;
|
||||
}, [sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recordedSource
|
||||
|| !playbackController
|
||||
|| requestedPlaybackSeconds === null
|
||||
|| requestedPlaybackSeconds === undefined
|
||||
|| !Number.isFinite(requestedPlaybackSeconds)
|
||||
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
|
||||
) return;
|
||||
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
|
||||
playbackController.setPlaying(false);
|
||||
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
|
||||
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
const rerunViewerProfile = recordedSource
|
||||
? recordedSessionRerunProfile({
|
||||
sourceUrl,
|
||||
artifact: recordedReplay,
|
||||
autoplayWhenReady: true,
|
||||
presentationGate: recordedSessionGate,
|
||||
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||
view: "spatial",
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: followRecordedTrajectory,
|
||||
perceptionLayers: {
|
||||
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
},
|
||||
perceptionRetryGeneration,
|
||||
lockPerceptionCameraInteraction: unifiedPerception,
|
||||
})
|
||||
: liveAcquisitionRerunProfile({
|
||||
sourceUrl,
|
||||
liveActivitySequence: livePresentationActivitySequence,
|
||||
liveStreamId: state?.spatialSource?.id ?? null,
|
||||
liveRecoveryAuthorityIdentity: streamActive
|
||||
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||
: null,
|
||||
});
|
||||
|
||||
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
|
||||
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
|
||||
toolbar={<> {recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
aria-pressed="true"
|
||||
disabled
|
||||
>
|
||||
Оригинал
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
detections2d: !livePerceptionLayers.detections2d,
|
||||
})}
|
||||
>
|
||||
Объекты 2D
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
segmentation: !livePerceptionLayers.segmentation,
|
||||
})}
|
||||
>
|
||||
Сегментация
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
cuboids3d: !livePerceptionLayers.cuboids3d,
|
||||
})}
|
||||
>
|
||||
Кубы 3D
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={followRecordedTrajectory ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={followRecordedTrajectory}
|
||||
title="Удерживать orbital-пивот на риге до повторного нажатия"
|
||||
onClick={() => setFollowRecordedTrajectory((current) => !current)}
|
||||
>
|
||||
Следовать
|
||||
</Button>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
|
||||
>
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<SpatialToolbarActions openSource={navigation.openSource} openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={<> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
</>}
|
||||
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
|
||||
model={spatialControls.model}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene"),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>):null}
|
||||
sourceControls={<> {pointCloudFocused ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-focus-exit"
|
||||
aria-label="Выйти из полноэкранного режима облака точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(null)}
|
||||
>
|
||||
<Icon name="minimize" size={16} />
|
||||
</button>
|
||||
) : !floatingSourceMaximized ? (
|
||||
<div className="scene-source-controls">
|
||||
<ObservationSourcePicker
|
||||
sources={unifiedPerception
|
||||
? observationSources.filter((source) => source.modality === "point-cloud")
|
||||
: observationSources}
|
||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||
pendingSourceIds={observationLayout.pendingSourceIds}
|
||||
onToggle={observationLayout.toggleSource}
|
||||
/>
|
||||
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-source-control"
|
||||
aria-label="Развернуть облако точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
|
||||
>
|
||||
<Icon name="expand" size={16} />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>}
|
||||
status={{label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
|
||||
metrics={<> <div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
{streamActive ? (
|
||||
<div>
|
||||
<span>AI</span>
|
||||
<strong>
|
||||
{aiLatency === null ? "—" : formatNumber(aiLatency)}
|
||||
<small>{aiLatency === null ? "" : " мс"}</small>
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}</>} overlays={<> {!pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!floatingSourceMaximized && recordedSource && (
|
||||
perceptionLoad.phase === "loading" ||
|
||||
perceptionLoad.phase === "error" ||
|
||||
pointColorLoad.phase === "loading" ||
|
||||
pointColorLoad.phase === "error"
|
||||
) ? (
|
||||
<div className="scene-operation-status-stack">
|
||||
{perceptionLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{perceptionLoad.phase === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-operation-status scene-operation-status--action"
|
||||
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
|
||||
>
|
||||
<Icon name="refresh" size={12} />
|
||||
<span>Повторить AI</span>
|
||||
</button>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "error" ? (
|
||||
<div className="scene-operation-status scene-operation-status--error" role="status">
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</>}
|
||||
navigationReady={presentedViewerStatus==='ready'} timeline={<> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
</>}
|
||||
media={<> {visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
count={visibleMediaSources.length}
|
||||
boundsRef={viewportRef}
|
||||
rect={observationLayout.windowRects[source.id]}
|
||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||
active={observationLayout.activeFloatingSourceId === source.id}
|
||||
hidden={pointCloudFocused || unifiedPerception}
|
||||
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
!observationLayout.visibleSourceIds.has(source.id) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}</>} footer={ <div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>}/>;
|
||||
}
|
||||
|
||||
function CameraSourceCard({
|
||||
source,
|
||||
focused,
|
||||
@@ -997,54 +381,6 @@ function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const steps = [
|
||||
{ id: "01", label: "Аппарат", value: "Не назначен" },
|
||||
{ id: "02", label: "Зона", value: "Не задана" },
|
||||
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
|
||||
{ id: "04", label: "Наблюдение", value: "Облако точек" },
|
||||
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
|
||||
];
|
||||
return (
|
||||
<div className="standard-workspace mission-workspace">
|
||||
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
|
||||
<div className="mission-layout">
|
||||
<GlassSurface className="mission-sequence" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
|
||||
<h2>Новая миссия</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
</header>
|
||||
<div className="mission-steps">
|
||||
{steps.map((step) => (
|
||||
<button key={step.id} type="button" disabled>
|
||||
<span>{step.id}</span>
|
||||
<div><strong>{step.label}</strong><small>{step.value}</small></div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="mission-summary" padding="lg">
|
||||
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
|
||||
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
|
||||
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
|
||||
<div className="mission-summary__checks">
|
||||
<span><i />Аппарат</span>
|
||||
<span><i />Геометрия</span>
|
||||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
|
||||
const active = definition.groups.reduce(
|
||||
@@ -1081,7 +417,7 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
switch (props.definition.kind) {
|
||||
case "spatial":
|
||||
return <SpatialWorkspace {...props} />;
|
||||
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
|
||||
case "recordings":
|
||||
return <RecordingsWorkspace {...props} />;
|
||||
case "cameras":
|
||||
@@ -1093,7 +429,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "timeline":
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
|
||||
case "catalog":
|
||||
|
||||
@@ -14,9 +14,10 @@ import type {
|
||||
} from "../core/runtime/contracts";
|
||||
import type { WorkspaceDefinition } from "../productModel";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
@@ -35,7 +36,9 @@ export interface LaboratoryViewAction {
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
launchProfile?: WorkspaceLaunchProfile;
|
||||
fleetCreateRequest?: number;
|
||||
headerToolsHost?: HTMLElement | null;
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
|
||||
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
|
||||
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
|
||||
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
|
||||
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
|
||||
import { MissionRoutePreview } from '../../components/missions/MissionRoutePreview';
|
||||
import { RegistrationScene } from '../../components/missions/RegistrationScene';
|
||||
import { PlanningProjectSettings } from '../../components/missions/PlanningProjectSettings';
|
||||
import { PlanningProjectResult } from '../../components/missions/PlanningProjectResult';
|
||||
import { PlanningProjectSelect } from '../../components/missions/PlanningProjectSelect';
|
||||
import '../../styles/session-overview.css';
|
||||
import '../../styles/mission-planner.css';
|
||||
|
||||
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
|
||||
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
||||
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
||||
const [view,setView]=useState<'cloud'|'route'>('cloud');
|
||||
const [starting,setStarting]=useState(false);
|
||||
const [pending,setPending]=useState<(()=>void)|null>(null);
|
||||
const bounds=useRef<HTMLDivElement>(null);
|
||||
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:16,width:390,height:600});
|
||||
const [maximized,setMaximized]=useState(false);
|
||||
const project=projects.detail;
|
||||
const editing=creating||project?.kind==='draft';
|
||||
const loadedDraft=useRef('');
|
||||
useEffect(()=>{
|
||||
if(project?.kind==='draft'&&loadedDraft.current!==project.key) {
|
||||
loadedDraft.current=project.key; void p.openDraft(project.id); setSettingsOpen(true);
|
||||
}
|
||||
},[project?.key]);
|
||||
const replace=(action:()=>void)=>{
|
||||
if(editing&&p.dirty&&(p.sessionId||p.name))setPending(()=>action);else action();
|
||||
};
|
||||
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
|
||||
const newProject=()=>replace(()=>{
|
||||
projects.select('');p.newDraft();setView('cloud');
|
||||
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
|
||||
});
|
||||
const start=async()=>{
|
||||
if(starting)return;setStarting(true);
|
||||
try {
|
||||
const draft=p.saved&&!p.dirty?p.saved:await p.save();
|
||||
if(!draft)return;
|
||||
const next=await live.begin(draft);
|
||||
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
|
||||
projects.refresh();
|
||||
} finally {setStarting(false);}
|
||||
};
|
||||
const tools=<div className="planning-project__header-tools">
|
||||
<PlanningProjectSelect value={projects.key} items={projects.items} disabled={starting} onChange={choose}
|
||||
onRemove={async target => {
|
||||
await projects.remove(target);
|
||||
if (target.key === projects.key) {
|
||||
setCreating(false); setSettingsOpen(false); loadedDraft.current = ''; p.newDraft();
|
||||
}
|
||||
}} />
|
||||
<IconButton label="Создать проект" disabled={starting} onClick={newProject}><Icon name="plus"/></IconButton>
|
||||
<IconButton label="Обновить проекты" loading={projects.loading} disabled={starting} onClick={()=>{projects.refresh();p.refresh();}}><Icon name="refresh"/></IconButton>
|
||||
<IconButton label="Настройки" aria-pressed={settingsOpen} onClick={()=>setSettingsOpen(v=>!v)}><Icon name="settings"/></IconButton>
|
||||
</div>;
|
||||
const zoneControls=<SegmentedControl label="Представление зоны" value={view} onChange={setView} items={[{value:'cloud',label:'Облако'},{value:'route',label:'Маршрут'}]}/>;
|
||||
return <div className="mission-planner planning-project">
|
||||
{headerToolsHost?createPortal(tools,headerToolsHost):tools}
|
||||
<div ref={bounds} className="planning-project__stage" onKeyDown={event=>{
|
||||
if(event.key==='Escape'&&!event.defaultPrevented&&settingsOpen){event.preventDefault();event.stopPropagation();setSettingsOpen(false);}
|
||||
}}>
|
||||
<GlassSurface className="planning-project__viewport session-overview__panel" radius="panel">
|
||||
{editing?<>
|
||||
{(view!=='cloud'||!p.source)&&<header className="mission-planner__viewer-head mission-planner__viewer-head--end">{zoneControls}</header>}
|
||||
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
|
||||
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
|
||||
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
|
||||
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} generation={p.source.generation} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
|
||||
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
|
||||
:project?<>
|
||||
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
|
||||
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
|
||||
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
|
||||
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись прохода сохранена отдельно от результата.</small></div>}
|
||||
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
|
||||
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
|
||||
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
|
||||
</GlassSurface>
|
||||
{settingsOpen&&<WorkspaceWindow boundsRef={bounds} rect={rect} onRectChange={setRect} maximized={maximized} onMaximizedChange={setMaximized}
|
||||
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
|
||||
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
|
||||
className="planning-project__inspector">
|
||||
{editing?<PlanningProjectSettings p={p} onStart={()=>void start()} starting={starting}/>
|
||||
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
|
||||
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
|
||||
{live.error&&editing&&<p role="alert">{live.error}</p>}
|
||||
</WorkspaceWindow>}
|
||||
</div>
|
||||
<ConfirmationModal open={!!pending} title="Закрыть настройки проекта?" description="Несохранённые изменения будут потеряны." confirmLabel="Продолжить" onClose={()=>setPending(null)} onConfirm={()=>{pending?.();setPending(null);}}/>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Checker,Icon,LoadingRegion,RangeControl,SegmentedControl} from '@nodedc/ui-react';
|
||||
import {SpatialToolbarActions} from '../../../../../packages/spatial-ui/src/SpatialToolbarActions';
|
||||
import {planningActivity,planningMatchCurrent,planningStatus} from '../../core/missions/planningPresentation';
|
||||
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||
import {PlanningLiveScene} from '../../components/missions/PlanningLiveScene';
|
||||
import {PlanningSceneToolWindow} from '../../components/missions/PlanningSceneToolWindow';
|
||||
import {SpatialWorkspace} from '../spatial/SpatialWorkspace';
|
||||
import type {WorkspaceRendererProps} from '../contracts';
|
||||
import '../../styles/mission-planner.css';
|
||||
|
||||
/** Planning supplies geometry and evidence to the ordinary scene, never replaces its camera/recording owners. */
|
||||
export function PlanningSpatialWorkspace(props:WorkspaceRendererProps){
|
||||
const p=usePlanningTest();
|
||||
const [tool,setTool]=useState<'layers'|'display'|null>(null);
|
||||
const [view,setView]=useState('3d'),[reset,setReset]=useState(0);
|
||||
const [layers,setLayers]=useState({reference:true,query:true,trajectory:true,grid:true});
|
||||
const [pointSize,setPointSize]=useState(1.8);
|
||||
const t=p.test;if(!t)return null;
|
||||
const terminal=planningTestTerminal(t),readyMatch=planningMatchCurrent(t,!!p.error);
|
||||
const status=planningStatus(t,p.error);
|
||||
const options={...layers,mode:view,point_size:pointSize,reset};
|
||||
const heightMin=t.scene_height_min_m,heightMax=t.scene_height_max_m;
|
||||
const heightBounds=typeof heightMin==='number'&&typeof heightMax==='number'&&Number.isFinite(heightMin)&&Number.isFinite(heightMax)&&heightMax>heightMin?{min:heightMin,max:heightMax}:null;
|
||||
const renderer=t.scene_available?<PlanningLiveScene runId={t.id} options={options} active={t.state==='running'} revision={t.scene_revision} heightBounds={heightBounds}/>:<LoadingRegion loading={t.state==='preparing'} label="Подготовка эталона" className="planning-live__scene"><p>{t.message}</p></LoadingRegion>;
|
||||
const toolbar=<>
|
||||
<SegmentedControl label="Вид сцены планирования" value={view} onChange={setView} items={[{value:'top',label:'Сверху'},{value:'3d',label:'3D'}]}/>
|
||||
<Button size="compact" icon={<Icon name="refresh"/>} onClick={()=>setReset(v=>v+1)}>Сброс вида</Button>
|
||||
<SpatialToolbarActions activeTool={tool} openLayers={()=>setTool(v=>v==='layers'?null:'layers')} openDisplay={()=>setTool(v=>v==='display'?null:'display')}/>
|
||||
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button size="compact" icon={<Icon name="refresh"/>} loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
|
||||
{!terminal&&<Button size="compact" loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}
|
||||
</>;
|
||||
const footer=<div className="planning-live__summary">
|
||||
<span>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</span>
|
||||
<span>Серый — эталон · цветной — новый проход · зелёный — совпавшие точки</span>
|
||||
{t.query_session_id&&<span>Запись прохода сохранена отдельно от расчёта исследования.</span>}
|
||||
{terminal&&t.result&&<span>Последний расчёт: {t.result.status==='candidate'?'кандидат совмещения':'совпадение не подтверждено'} · {(t.result.overlap*100).toFixed(1)}%.</span>}
|
||||
</div>;
|
||||
return <>
|
||||
<SpatialWorkspace {...props} recordedReplay={null} recordedSessionAdmission={null} visualProfile={{renderer,toolbar,status,footer,activity:planningActivity(t,p.error),
|
||||
metrics:<><div><span>Пройдено в исследовании</span><strong>{t.distance_m.toFixed(1)} м</strong></div>
|
||||
<div><span>Точек</span><strong>{(t.query_points??0).toLocaleString('ru-RU')}</strong></div>
|
||||
<div><span>Совпадение</span><strong>{readyMatch&&t.result?`${(t.result.overlap*100).toFixed(1)}%`:'—'}</strong></div></>,
|
||||
mediaFallback:<div className="planning-live__camera-note"><Icon name="camera"/><span>Нет активного изображения камеры</span></div>,
|
||||
tools:boundsRef=>tool&&<PlanningSceneToolWindow boundsRef={boundsRef} title={tool==='layers'?'Слои':'Отображение'} onClose={()=>setTool(null)}>
|
||||
{tool==='layers'?(['reference','query','trajectory'] as const).map((key,i)=><Checker key={key} checked={layers[key]} label={['Эталон','Новый проход','Траектории'][i]} onChange={value=>setLayers(v=>({...v,[key]:value}))}/>)
|
||||
:<><RangeControl label="Размер точки" value={pointSize} min={.5} max={12} step={.5} exactValueBounds={{min:.5,max:12}} onChange={setPointSize}/><Checker label="Сетка" checked={layers.grid} onChange={grid=>setLayers(v=>({...v,grid}))}/></>}
|
||||
</PlanningSceneToolWindow>,
|
||||
}}/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { useState } from "react";
|
||||
import { Button, GlassSurface, LoadingRegion, SplitPane } from "@nodedc/ui-react";
|
||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||
import { SessionOverviewScene } from "../../components/observation/SessionOverviewScene";
|
||||
import { SessionIntervalChart } from "../../components/observation/SessionIntervalChart";
|
||||
import "../../styles/session-overview.css";
|
||||
|
||||
const fmt = (n: number | null | undefined, digits = 0, suffix = "") => n == null || !Number.isFinite(n)
|
||||
? "—" : `${n.toLocaleString("ru-RU", { maximumFractionDigits: digits })}${suffix}`;
|
||||
const duration = (n: number | null | undefined) => n == null ? "—" : `${Math.floor(n / 60)} мин ${Math.round(n % 60)} с`;
|
||||
function initialSize(key: string, fallback: number) {
|
||||
try { const n = Number(localStorage.getItem(key)); return n >= 25 && n <= 80 ? n : fallback; } catch { return fallback; }
|
||||
}
|
||||
function usePaneSize(name: string, fallback: number) {
|
||||
const key = `missioncore.session-overview.layout.v1.${name}`;
|
||||
const [size, setSize] = useState(() => initialSize(key, fallback));
|
||||
return [size, (n: number) => { setSize(n); try { localStorage.setItem(key, String(n)); } catch { /* layout remains usable */ } }] as const;
|
||||
}
|
||||
|
||||
export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
|
||||
const { data, error, retry } = useSessionOverview(sessionId);
|
||||
const [top, setTop] = usePaneSize("top", 70);
|
||||
const [left, setLeft] = usePaneSize("left", 68);
|
||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||
const failure = error || (data?.state === "error" ? data.message : null);
|
||||
const m = data?.metrics;
|
||||
const facts: [string, string][] = [
|
||||
["Начало записи", data?.session.started_at_utc ? new Date(data.session.started_at_utc).toLocaleString("ru-RU") : "—"],
|
||||
["Длительность сессии", duration(data?.session.duration_seconds)],
|
||||
["Поток облака", duration(m?.stream_seconds)],
|
||||
["Кадры облака", fmt(m?.point_frames)],
|
||||
["Наблюдения точек", fmt(m?.point_count)],
|
||||
["Положения сканера", fmt(m?.pose_frames)],
|
||||
["Путь по траектории", fmt(m?.path_m, 2, " м")],
|
||||
["От старта до финиша", fmt(m?.start_end_m, 2, " м")],
|
||||
["Средняя частота", fmt(m?.mean_hz, 2, " Гц")],
|
||||
["Максимальный интервал", fmt(m?.interval_max_s, 3, " с")],
|
||||
["Интервалы больше секунды", fmt(m?.gaps_over_second)],
|
||||
["Пропуски номеров", fmt(m?.sequence_gaps)],
|
||||
["Ошибки чтения кадров", fmt(m?.decode_errors)],
|
||||
["Объём исходных данных", fmt(data ? data.session.total_bytes / 1_000_000 : null, 1, " МБ")],
|
||||
];
|
||||
return <div className="session-overview" aria-label="Информация о записи">
|
||||
<LoadingRegion loading={pending} label={data?.state === "queued" ? "Ожидание подготовки обзора" : "Подготовка обзора записи"} className="session-overview__loading">
|
||||
{failure ? <div className="session-overview__empty" role="alert"><span>{failure}</span><Button onClick={retry}>Повторить</Button></div> : pending ? null : <SplitPane orientation="horizontal" primarySize={top} onPrimarySizeChange={setTop} minPrimarySize={35} minSecondarySize={20}
|
||||
separatorLabel="Высота графика интервалов" className="session-overview__split"
|
||||
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
|
||||
primary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} compareVersions /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
|
||||
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
|
||||
</GlassSurface>}
|
||||
secondary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||
<h2>Сведения об исходной записи</h2><div className="session-overview__facts">
|
||||
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
|
||||
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
|
||||
</div>
|
||||
</GlassSurface>} />}
|
||||
secondary={<GlassSurface className="session-overview__panel" radius="panel"><h2>Интервалы поступления кадров</h2>
|
||||
<SessionIntervalChart chart={m?.chart ?? []} bucketSeconds={m?.chart_bucket_seconds ?? 1} />
|
||||
</GlassSurface>} />}
|
||||
</LoadingRegion>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,644 @@
|
||||
import {SpatialScene, EmptySpatialStage} from '../../../../../packages/spatial-ui/src';
|
||||
import {SpatialToolbarActions} from "../../../../../packages/spatial-ui/src/SpatialToolbarActions";
|
||||
import { type ReactNode, type RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button, Icon, IconButton } from "@nodedc/ui-react";
|
||||
import {
|
||||
ObservationMedia,
|
||||
ObservationSourcePicker,
|
||||
} from "../../components/ObservationSources";
|
||||
import { ObservationTimeline } from "../../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../../components/FloatingObservationWindow";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
RecordedCameraAdmissionState,
|
||||
} from "../../core/observation/recordedSessionAdmission";
|
||||
import { liveRerunRecoveryAuthorityIdentity } from "../../core/observation/liveReceiverWatchdog";
|
||||
import { liveAcquisitionRerunProfile, recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
||||
import {
|
||||
RerunViewport,
|
||||
isRecordedPlaybackPresentationReady,
|
||||
rerunPresentationStatus,
|
||||
type RerunPlaybackController,
|
||||
type RerunPlaybackState,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
type RecordedPerceptionLoadState,
|
||||
type RecordedPointColorLoadState,
|
||||
} from "../../components/RerunViewport";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../../presentation";
|
||||
import type { WorkspaceRendererProps } from "../contracts";
|
||||
export interface SpatialWorkspaceProfile {
|
||||
renderer: ReactNode;
|
||||
toolbar: ReactNode;
|
||||
status: {label: string; tone: 'neutral'|'success'|'warning'|'danger'; message?: string; pulse?: boolean};
|
||||
metrics: ReactNode;
|
||||
footer: ReactNode;
|
||||
sourceControls?: ReactNode;
|
||||
mediaFallback?: ReactNode;
|
||||
tools?: (boundsRef: RefObject<HTMLDivElement|null>) => ReactNode;
|
||||
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
|
||||
}
|
||||
export function SpatialWorkspace({
|
||||
launchProfile = 'direct',
|
||||
state,
|
||||
sourceUrl,
|
||||
requestedPlaybackSeconds,
|
||||
recordedReplay,
|
||||
recordedSessionAdmission,
|
||||
sceneSettings,
|
||||
accumulationSeconds,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
livePerceptionLayers,
|
||||
onLivePerceptionLayersChange,
|
||||
observationLayout,
|
||||
navigation,
|
||||
spatialControls,
|
||||
visualProfile,
|
||||
}: WorkspaceRendererProps & { visualProfile?: SpatialWorkspaceProfile }) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const [playbackState, setPlaybackState] = useState<RerunPlaybackState | null>(null);
|
||||
const [playbackController, setPlaybackController] = useState<RerunPlaybackController | null>(null);
|
||||
const lastRequestedPlaybackSeconds = useRef<number | null>(null);
|
||||
const [recordedViewResetGeneration, setRecordedViewResetGeneration] = useState<0 | 1>(0);
|
||||
const [followRecordedTrajectory, setFollowRecordedTrajectory] = useState(false);
|
||||
const [perceptionLoad, setPerceptionLoad] = useState<RecordedPerceptionLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [perceptionRetryGeneration, setPerceptionRetryGeneration] = useState(0);
|
||||
const [pointColorLoad, setPointColorLoad] = useState<RecordedPointColorLoadState>({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
const [showDetections2d, setShowDetections2d] = useState(false);
|
||||
const [showSegmentation, setShowSegmentation] = useState(false);
|
||||
const [showCuboids3d, setShowCuboids3d] = useState(false);
|
||||
const recordedSource = !visualProfile && (Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl));
|
||||
const liveRerunSource = !recordedSource && /^rerun\+https?:\/\//i.test(sourceUrl.trim());
|
||||
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
|
||||
? recordedSessionAdmission?.phase ?? "loading"
|
||||
: "ready";
|
||||
const recordedPlaybackReady = !recordedSource ||
|
||||
(recordedSessionGate === "ready" &&
|
||||
isRecordedPlaybackPresentationReady(viewerStatus, playbackState));
|
||||
const streamActive = state?.sourceMode === "live" || state?.sourceMode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
// An explicit manual gRPC source has no Mission Core metrics producer. Its
|
||||
// native Rerun range is therefore the only available activity proof.
|
||||
const livePresentationActivitySequence = metrics?.publishedFrameCount ??
|
||||
(liveRerunSource && !streamActive ? 1 : null);
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frameRateHz);
|
||||
const points = finiteMetric(metrics?.pointCount);
|
||||
const aiLatency = finiteMetric(metrics?.aiLatencyMs);
|
||||
const aiFrameRate = finiteMetric(metrics?.aiFrameRateHz);
|
||||
const observationSources = (state?.observationSources ?? []).filter(source => !visualProfile || source.transport !== "recording");
|
||||
const pointCloudSource = observationSources.find((source) => source.modality === "point-cloud");
|
||||
const pointCloudVisible = pointCloudSource
|
||||
? observationLayout.visibleSourceIds.has(pointCloudSource.id)
|
||||
: Boolean(sourceUrl.trim());
|
||||
const mediaSources = observationSources.filter(
|
||||
(source) => source.capabilities.overlay && source.modality !== "point-cloud",
|
||||
);
|
||||
const recordedPerceptionSupported =
|
||||
recordedSource && perceptionLoad.phase !== "unavailable";
|
||||
const recordedPerceptionLoading = recordedSource && perceptionLoad.phase === "loading";
|
||||
const recordedPerceptionEnabled =
|
||||
showDetections2d || showSegmentation || showCuboids3d;
|
||||
// The native recorded camera remains the authoritative original. Only 2D
|
||||
// image-space overlays need Rerun's paired camera/world composition; 3D
|
||||
// cuboids are added directly to the stable spatial view.
|
||||
const unifiedPerception = recordedPerceptionSupported &&
|
||||
(showDetections2d || showSegmentation);
|
||||
const livePerceptionAvailable = !recordedSource && streamActive;
|
||||
const detections2dActive = recordedSource
|
||||
? showDetections2d
|
||||
: livePerceptionLayers.detections2d;
|
||||
const segmentationActive = recordedSource
|
||||
? showSegmentation
|
||||
: livePerceptionLayers.segmentation;
|
||||
const cuboids3dActive = recordedSource
|
||||
? showCuboids3d
|
||||
: livePerceptionLayers.cuboids3d;
|
||||
const visibleMediaSources = mediaSources.filter((source) =>
|
||||
observationLayout.visibleSourceIds.has(source.id) &&
|
||||
!source.id.startsWith("recorded.perception."),
|
||||
);
|
||||
const initialRecordedPlaybackStartSeconds = recordedSource
|
||||
? mediaSources.reduce<number | undefined>((earliest, source) => {
|
||||
if (
|
||||
source.id.startsWith("recorded.perception.") ||
|
||||
source.delivery?.kind !== "recorded-fmp4-manifest"
|
||||
) return earliest;
|
||||
const start = source.delivery.timelineStartSeconds;
|
||||
return earliest === undefined ? start : Math.min(earliest, start);
|
||||
}, undefined)
|
||||
: undefined;
|
||||
const presentedMediaSourceCount = unifiedPerception ? 0 : visibleMediaSources.length;
|
||||
const pointCloudFocused = Boolean(
|
||||
pointCloudSource && observationLayout.focusedSourceId === pointCloudSource.id,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!pointCloudFocused) return;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
observationLayout.setFocusedSourceId(null);
|
||||
};
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [observationLayout.setFocusedSourceId, pointCloudFocused]);
|
||||
const floatingSourceMaximized = !unifiedPerception &&
|
||||
Boolean(observationLayout.maximizedFloatingSourceId);
|
||||
const timeline = state?.observationTimeline;
|
||||
const viewportRef = useRef<HTMLDivElement>(null);
|
||||
const intentionalSourceEnd = !recordedSource && state?.sourceMode === "idle" && ["awaiting_external_stop", "stopping", "finalizing", "completed",].includes(state?.acquisition?.state ?? "");
|
||||
const presentedViewerStatus = intentionalSourceEnd
|
||||
? "idle"
|
||||
: rerunPresentationStatus(
|
||||
viewerStatus,
|
||||
recordedSessionGate,
|
||||
recordedSource,
|
||||
);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
if (recordedSessionAdmission) {
|
||||
recordedSessionAdmission.reportSpatial(
|
||||
recordedSessionAdmission.key,
|
||||
status === "ready" ? "ready" : status === "error" ? "error" : "loading",
|
||||
);
|
||||
}
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportSpatial]);
|
||||
const onRecordedAdmissionChange = useCallback((
|
||||
sourceId: string,
|
||||
next: RecordedCameraAdmissionState,
|
||||
) => {
|
||||
if (!recordedSessionAdmission) return;
|
||||
if (next.admissionKey !== recordedSessionAdmission.key) return;
|
||||
recordedSessionAdmission.reportCamera(recordedSessionAdmission.key, sourceId, next);
|
||||
}, [recordedSessionAdmission?.key, recordedSessionAdmission?.reportCamera]);
|
||||
const shouldPrepareRecordedSource = useCallback((sourceId: string) => {
|
||||
if (!recordedSessionAdmission) return false;
|
||||
return recordedSessionAdmission.activeCameraSourceIds.has(sourceId) ||
|
||||
["ready", "error"].includes(recordedSessionAdmission.cameras[sourceId]?.phase ?? "loading");
|
||||
}, [recordedSessionAdmission]);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
const onPlaybackChange = useCallback(
|
||||
(next: RerunPlaybackState | null) => setPlaybackState(next),
|
||||
[],
|
||||
);
|
||||
const onPlaybackControllerChange = useCallback(
|
||||
(next: RerunPlaybackController | null) => setPlaybackController(next),
|
||||
[],
|
||||
);
|
||||
const onPerceptionLoadChange = useCallback((next: RecordedPerceptionLoadState) => {
|
||||
setPerceptionLoad(next);
|
||||
if (next.phase === "unavailable" || next.phase === "error") {
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
}
|
||||
}, []);
|
||||
const onPointColorLoadChange = useCallback((next: RecordedPointColorLoadState) => {
|
||||
setPointColorLoad(next);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setPerceptionLoad({
|
||||
phase: recordedSource ? "loading" : "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: recordedSource ? "Ожидаем канал AI-слоёв." : "",
|
||||
});
|
||||
setPerceptionRetryGeneration(0);
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setRecordedViewResetGeneration(0);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [recordedSource, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pointCloudVisible && sourceUrl.trim()) return;
|
||||
setViewerStatus("idle");
|
||||
setViewerMessage("");
|
||||
setSelection(null);
|
||||
setPlaybackState(null);
|
||||
setPlaybackController(null);
|
||||
setPerceptionLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
setShowDetections2d(false);
|
||||
setShowSegmentation(false);
|
||||
setShowCuboids3d(false);
|
||||
setFollowRecordedTrajectory(false);
|
||||
setPointColorLoad({
|
||||
phase: "idle",
|
||||
receivedBytes: 0,
|
||||
totalBytes: null,
|
||||
progress: null,
|
||||
message: "",
|
||||
});
|
||||
}, [pointCloudVisible, sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
lastRequestedPlaybackSeconds.current = null;
|
||||
}, [sourceUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
!recordedSource
|
||||
|| !playbackController
|
||||
|| requestedPlaybackSeconds === null
|
||||
|| requestedPlaybackSeconds === undefined
|
||||
|| !Number.isFinite(requestedPlaybackSeconds)
|
||||
|| lastRequestedPlaybackSeconds.current === requestedPlaybackSeconds
|
||||
) return;
|
||||
lastRequestedPlaybackSeconds.current = requestedPlaybackSeconds;
|
||||
playbackController.setPlaying(false);
|
||||
playbackController.seek(Math.round(requestedPlaybackSeconds * 1_000_000_000));
|
||||
}, [playbackController, recordedSource, requestedPlaybackSeconds]);
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const publishViewportSize = () => {
|
||||
const bounds = viewport.getBoundingClientRect();
|
||||
if (bounds.width < 1 || bounds.height < 1) return;
|
||||
observationLayout.setViewportSize({
|
||||
width: bounds.width,
|
||||
height: bounds.height,
|
||||
});
|
||||
};
|
||||
publishViewportSize();
|
||||
const observer = new ResizeObserver(publishViewportSize);
|
||||
observer.observe(viewport);
|
||||
return () => observer.disconnect();
|
||||
}, [observationLayout.setViewportSize]);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: intentionalSourceEnd ? "Источник отключён" : "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[presentedViewerStatus];
|
||||
|
||||
const viewerStatusTone = presentedViewerStatus === "ready"
|
||||
? "success"
|
||||
: presentedViewerStatus === "error"
|
||||
? "danger"
|
||||
: "neutral";
|
||||
const rerunViewerProfile = recordedSource
|
||||
? recordedSessionRerunProfile({
|
||||
sourceUrl,
|
||||
artifact: recordedReplay,
|
||||
autoplayWhenReady: true,
|
||||
presentationGate: recordedSessionGate,
|
||||
expectedTimelineStartSeconds: state?.observationTimeline?.range?.startSeconds,
|
||||
expectedTimelineEndSeconds: state?.observationTimeline?.range?.endSeconds,
|
||||
initialPlaybackStartSeconds: initialRecordedPlaybackStartSeconds,
|
||||
view: "spatial",
|
||||
viewResetGeneration: recordedViewResetGeneration,
|
||||
followTrajectory: followRecordedTrajectory,
|
||||
perceptionLayers: {
|
||||
enabled: recordedPerceptionSupported && recordedPerceptionEnabled,
|
||||
detections2d: showDetections2d,
|
||||
segmentation: showSegmentation,
|
||||
cuboids3d: showCuboids3d,
|
||||
},
|
||||
perceptionRetryGeneration,
|
||||
lockPerceptionCameraInteraction: unifiedPerception,
|
||||
})
|
||||
: liveAcquisitionRerunProfile({
|
||||
sourceUrl,
|
||||
liveActivitySequence: livePresentationActivitySequence,
|
||||
liveStreamId: state?.spatialSource?.id ?? null,
|
||||
liveRecoveryAuthorityIdentity: streamActive
|
||||
? liveRerunRecoveryAuthorityIdentity(pointCloudSource, state?.spatialSource)
|
||||
: null,
|
||||
});
|
||||
|
||||
return <SpatialScene viewportRef={viewportRef} focused={pointCloudFocused||floatingSourceMaximized}
|
||||
primaryFocused={pointCloudFocused} mediaMaximized={floatingSourceMaximized}
|
||||
toolbar={visualProfile?.toolbar ?? <> {recordedPerceptionSupported || livePerceptionAvailable ? (
|
||||
<div className="spatial-toolbar__view-switch" role="group" aria-label="Слои распознавания сцены">
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
aria-pressed="true"
|
||||
disabled
|
||||
>
|
||||
Оригинал
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={detections2dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={detections2dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowDetections2d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
detections2d: !livePerceptionLayers.detections2d,
|
||||
})}
|
||||
>
|
||||
Объекты 2D
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={segmentationActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="image" />}
|
||||
aria-pressed={segmentationActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowSegmentation((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
segmentation: !livePerceptionLayers.segmentation,
|
||||
})}
|
||||
>
|
||||
Сегментация
|
||||
</Button>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={cuboids3dActive ? "primary" : "secondary"}
|
||||
icon={<Icon name="apps" />}
|
||||
aria-pressed={cuboids3dActive}
|
||||
disabled={recordedPerceptionLoading}
|
||||
onClick={() => recordedSource
|
||||
? setShowCuboids3d((current) => !current)
|
||||
: onLivePerceptionLayersChange({
|
||||
...livePerceptionLayers,
|
||||
cuboids3d: !livePerceptionLayers.cuboids3d,
|
||||
})}
|
||||
>
|
||||
Кубы 3D
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant={followRecordedTrajectory ? "primary" : "secondary"}
|
||||
icon={<Icon name="target" />}
|
||||
aria-pressed={followRecordedTrajectory}
|
||||
title="Удерживать orbital-пивот на риге до повторного нажатия"
|
||||
onClick={() => setFollowRecordedTrajectory((current) => !current)}
|
||||
>
|
||||
Следовать
|
||||
</Button>
|
||||
) : null}
|
||||
{recordedSource && presentedViewerStatus === "ready" ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={() => setRecordedViewResetGeneration((current) => current === 0 ? 1 : 0)}
|
||||
>
|
||||
Сброс вида
|
||||
</Button>
|
||||
) : null}
|
||||
<SpatialToolbarActions openLayers={navigation.openLayers} openDisplay={navigation.openDisplay}/></>} renderer={visualProfile?.renderer ?? <> {sourceUrl.trim() && pointCloudVisible && !intentionalSourceEnd ? (
|
||||
<RerunViewport
|
||||
profile={rerunViewerProfile}
|
||||
sceneSettings={sceneSettings}
|
||||
onPerceptionLoadChange={onPerceptionLoadChange}
|
||||
onPointColorLoadChange={onPointColorLoadChange}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlaybackControllerChange={onPlaybackControllerChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
</>}
|
||||
deviceControls={spatialControls&&!recordedSource?( <spatialControls.View
|
||||
model={spatialControls.model}
|
||||
spatialActivity={visualProfile?.activity}
|
||||
host={{
|
||||
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
|
||||
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
|
||||
}}
|
||||
/>):null}
|
||||
sourceControls={visualProfile?.sourceControls ?? <> {pointCloudFocused ? (
|
||||
<IconButton
|
||||
className="scene-focus-exit"
|
||||
label="Выйти из полноэкранного режима облака точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(null)}
|
||||
>
|
||||
<Icon name="minimize" size={16} />
|
||||
</IconButton>
|
||||
) : !floatingSourceMaximized ? (
|
||||
<div className="scene-source-controls">
|
||||
<ObservationSourcePicker
|
||||
sources={unifiedPerception
|
||||
? observationSources.filter((source) => source.modality === "point-cloud")
|
||||
: observationSources}
|
||||
visibleSourceIds={observationLayout.visibleSourceIds}
|
||||
pendingSourceIds={observationLayout.pendingSourceIds}
|
||||
onToggle={observationLayout.toggleSource}
|
||||
/>
|
||||
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && (visualProfile || sourceUrl.trim()) ? (
|
||||
<IconButton
|
||||
className="scene-source-control"
|
||||
label="Развернуть облако точек"
|
||||
onClick={() => observationLayout.setFocusedSourceId(pointCloudSource.id)}
|
||||
>
|
||||
<Icon name="expand" size={16} />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>}
|
||||
status={visualProfile?.status ?? {label:viewerStatusLabel,tone:viewerStatusTone,message:!intentionalSourceEnd?viewerMessage:undefined}}
|
||||
metrics={visualProfile?.metrics ?? <> <div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
{streamActive ? (
|
||||
<div>
|
||||
<span>AI</span>
|
||||
<strong>
|
||||
{aiLatency === null ? "—" : formatNumber(aiLatency)}
|
||||
<small>{aiLatency === null ? "" : " мс"}</small>
|
||||
{aiFrameRate === null ? null : <small> · {formatNumber(aiFrameRate)} Гц</small>}
|
||||
</strong>
|
||||
</div>
|
||||
) : null}</>} overlays={<> {!visualProfile && !pointCloudFocused && !floatingSourceMaximized && state?.sourceMode && state.sourceMode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.sourceMode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!pointCloudFocused && !floatingSourceMaximized && selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!floatingSourceMaximized && recordedSource && (
|
||||
perceptionLoad.phase === "loading" ||
|
||||
perceptionLoad.phase === "error" ||
|
||||
pointColorLoad.phase === "loading" ||
|
||||
pointColorLoad.phase === "error"
|
||||
) ? (
|
||||
<div className="scene-operation-status-stack">
|
||||
{perceptionLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{perceptionLoad.message || "Готовим AI-слои."}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{perceptionLoad.phase === "error" ? (
|
||||
<button
|
||||
type="button"
|
||||
className="scene-operation-status scene-operation-status--action"
|
||||
onClick={() => setPerceptionRetryGeneration((generation) => generation + 1)}
|
||||
>
|
||||
<Icon name="refresh" size={12} />
|
||||
<span>Повторить AI</span>
|
||||
</button>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "loading" ? (
|
||||
<div className="scene-operation-status" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{pointColorLoad.phase === "error" ? (
|
||||
<div className="scene-operation-status scene-operation-status--error" role="status">
|
||||
<span>{pointColorLoad.message}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
</>}
|
||||
timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
mode={recordedSource && playbackState?.rangeNs
|
||||
? "recorded"
|
||||
: timeline?.mode}
|
||||
seekable={recordedSource && playbackState?.rangeNs
|
||||
? true
|
||||
: timeline?.seekable}
|
||||
synchronization={timeline?.synchronization}
|
||||
rangeNs={playbackState?.rangeNs}
|
||||
currentNs={playbackState?.currentNs}
|
||||
playing={playbackState?.playing}
|
||||
onSeek={playbackController?.seek}
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
accumulationMaxSeconds={sceneSettings.accumulationMaxSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
className="scene-timeline"
|
||||
/>
|
||||
) : null}
|
||||
</>}
|
||||
media={<>{visibleMediaSources.length === 0 ? visualProfile?.mediaFallback : null} {visibleMediaSources.map((source, index) => (
|
||||
<FloatingObservationWindow
|
||||
key={source.id}
|
||||
source={source}
|
||||
index={index}
|
||||
count={visibleMediaSources.length}
|
||||
boundsRef={viewportRef}
|
||||
rect={observationLayout.windowRects[source.id]}
|
||||
maximized={observationLayout.maximizedFloatingSourceId === source.id}
|
||||
active={observationLayout.activeFloatingSourceId === source.id}
|
||||
hidden={pointCloudFocused || unifiedPerception}
|
||||
onRectChange={(rect) => observationLayout.setWindowRect(source.id, rect)}
|
||||
onMaximizedChange={(maximized) =>
|
||||
observationLayout.setFloatingMaximized(source.id, maximized)}
|
||||
onActivate={() => observationLayout.activateFloatingSource(source.id)}
|
||||
playback={recordedSource && playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: playbackState.playing,
|
||||
} : null}
|
||||
prepareRecorded={!recordedSource || shouldPrepareRecordedSource(source.id)}
|
||||
recordedSessionGate={recordedSessionGate}
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
onClose={() => {
|
||||
if (observationLayout.pendingSourceIds.has(source.id)) return;
|
||||
observationLayout.setFloatingMaximized(source.id, false);
|
||||
void observationLayout.hideSource(source.id);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{visualProfile?.tools?.(viewportRef)}
|
||||
{recordedSource ? (
|
||||
<div className="recorded-session-preloaders" aria-hidden="true">
|
||||
{mediaSources.filter((source) => (
|
||||
source.delivery?.kind === "recorded-fmp4-manifest" &&
|
||||
!observationLayout.visibleSourceIds.has(source.id) &&
|
||||
shouldPrepareRecordedSource(source.id)
|
||||
)).map((source) => (
|
||||
<ObservationMedia
|
||||
key={source.id}
|
||||
source={source}
|
||||
playback={playbackState ? {
|
||||
currentSeconds: playbackState.currentNs / 1_000_000_000,
|
||||
playing: false,
|
||||
} : null}
|
||||
prepareRecorded
|
||||
recordedSessionGate="loading"
|
||||
recordedAdmissionKey={recordedSessionAdmission?.key ?? null}
|
||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : null}</>} footer={visualProfile?.footer ?? <div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state={detections2dActive ? "ready" : "contract"} />Объекты 2D</span>
|
||||
<span><i data-state={segmentationActive ? "ready" : "contract"} />Сегментация</span>
|
||||
<span><i data-state={cuboids3dActive ? "ready" : "contract"} />Кубы 3D</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>}/>;
|
||||
}
|
||||
@@ -7673,6 +7673,26 @@ test("spatial controls explain the bounded K1 calibration wait before point data
|
||||
}
|
||||
});
|
||||
|
||||
test("planning status extends only its current authoritative acquiring session", () => {
|
||||
const state = runtimeState();
|
||||
const spatialActivity = {sessionId: "data-session-001", label: "Привязка к эталону", detail: "Ожидание на месте.", busy: true};
|
||||
const render = (current, activity = spatialActivity, controllerPatch = {}) => renderToStaticMarkup(createElement(K1SpatialControlsView, {
|
||||
controller: {...acquisitionController(current), ...controllerPatch}, spatialActivity: activity,
|
||||
}));
|
||||
assert.match(render(state), /Привязка к эталону/);
|
||||
assert.match(render(state), /Ожидание на месте/);
|
||||
assert.doesNotMatch(render(state, {...spatialActivity, sessionId: "other-capture"}), /Привязка к эталону/);
|
||||
for (const acquisitionState of ["awaiting_external_start", "starting", "awaiting_external_stop", "stopping", "finalizing"]) {
|
||||
const current = structuredClone(state); current.acquisition.state = acquisitionState;
|
||||
assert.doesNotMatch(render(current), /Привязка к эталону/);
|
||||
}
|
||||
const lost = structuredClone(state); lost.connection_supervisor = supervisor({control: true, data: false, dataPlaneState: "stalled"});
|
||||
assert.doesNotMatch(render(lost), /Привязка к эталону/);
|
||||
assert.doesNotMatch(render(state, spatialActivity, {physicalStopInFlight: true}), /Привязка к эталону/);
|
||||
const cleanup = structuredClone(state); cleanup.acquisition.cleanup_pending = true;
|
||||
assert.doesNotMatch(render(cleanup), /Привязка к эталону/);
|
||||
});
|
||||
|
||||
test("contour health never promotes selection or replay metrics to live authority", () => {
|
||||
const selectedOnly = {
|
||||
phase: "starting",
|
||||
|
||||
@@ -804,7 +804,7 @@ test("E40 reports historical visible evaluation with bounded camera-LiDAR case r
|
||||
});
|
||||
|
||||
test("the primary point-cloud viewer restores from fullscreen on Escape", async () => {
|
||||
const workspacesSource = await readFile(workspacesUrl, "utf8");
|
||||
const workspacesSource = await readFile(new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url), "utf8");
|
||||
|
||||
assert.match(workspacesSource, /if \(!pointCloudFocused\) return;/);
|
||||
assert.match(workspacesSource, /event\.key !== "Escape"/);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
let server, api;
|
||||
before(async () => { server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } }); api = await server.ssrLoadModule('/src/core/missions/planner.ts'); });
|
||||
after(async () => { await server?.close(); });
|
||||
const source = { schema_version:'missioncore.planning-source/v1', session_id:'A', generation:'a'.repeat(64), units:'m', poses:Array.from({length:5}, (_, i) => ({index:i, position:[i*3,i*4,0], distance_m:i*5})) };
|
||||
test('route reverses source order without changing geometry or source', () => {
|
||||
const result = api.selectedPoses(source, 1, 3, 'reverse');
|
||||
assert.deepEqual(result.map(p => p.index), [3,2,1]); assert.equal(api.routeLength(result), 10); assert.equal(source.poses[0].index,0);
|
||||
});
|
||||
test('bounded section uses travelled distance, including out-and-back', () => {
|
||||
assert.equal(api.endAtDistance(source, 1, 7),3); assert.equal(api.endAtDistance(source, 1, 30),4); assert.deepEqual(api.selectedPoses(source,4,2,'forward'),[]);
|
||||
});
|
||||
test('catalog excludes derived LAB parent and nonspatial sessions from zone binding', () => {
|
||||
const item={replayable:true,modalities:['point-cloud','trajectory']}; assert.equal(api.canSelectSession(item),true);
|
||||
assert.equal(api.canSelectSession({...item,lab:{}}),false); assert.equal(api.canSelectSession({...item,modalities:['video']}),false);
|
||||
});
|
||||
test('source contract rejects a different session and nonfinite coordinates', () => {
|
||||
assert.equal(api.validatePlanningSource(source,'A'),source); assert.throws(() => api.validatePlanningSource(source,'B'));
|
||||
assert.throws(() => api.validatePlanningSource({...source,poses:[{index:0,position:[0,0,NaN],distance_m:0},source.poses[1]]},'A'));
|
||||
});
|
||||
|
||||
test('meter input selects the correct ordered pose and respects the selected branch', () => {
|
||||
assert.equal(api.indexAtDistance(source, 10), 2);
|
||||
assert.equal(api.indexAtDistance(source, 10, 3), 3);
|
||||
assert.equal(api.indexAtDistance(source, 100), 4);
|
||||
});
|
||||
|
||||
test('live distance has no upper cap and endpoint selection does not overshoot typed metres', () => {
|
||||
for (const n of [3, 30, 50, 100, 200, 300, 10000]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), true);
|
||||
for (const n of [2.9, NaN, Infinity]) assert.equal(api.canStartPlanningRoute(n, 'scanner'), false);
|
||||
assert.equal(api.canStartPlanningRoute(50, 'recording'), false);
|
||||
assert.equal(api.indexAtDistance(source, 9.9, 1, 4, true), 1);
|
||||
assert.equal(api.indexAtDistance(source, 10, 1, 4, true), 2);
|
||||
assert.equal(api.indexAtDistance(source, 1, 1, 4, true), 1);
|
||||
});
|
||||
|
||||
test('planning match never promotes stopped or stale evidence', async()=>{
|
||||
const {planningMatchCurrent}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',tracking_state:'tracking',stale:false,frame_age_s:0,result_age_s:2,result:{status:'candidate'}};
|
||||
assert.equal(planningMatchCurrent(t),true);
|
||||
for(const patch of [{state:'completed'},{state:'waiting'},{tracking_state:'acquiring'},{tracking_state:'lost'},{tracking_state:undefined},{stale:true},{frame_age_s:8},{result_age_s:8},{result_age_s:null},{result:{status:'rejected'}}])assert.equal(planningMatchCurrent({...t,...patch}),false);
|
||||
assert.equal(planningMatchCurrent(t,true),false);
|
||||
});
|
||||
|
||||
test('planning failure is visible even when its last sample is stale', async()=>{
|
||||
const {planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'error',message:'Разрыв координат',stale:true,frame_age_s:null,result_age_s:null,result:null};
|
||||
assert.deepEqual(planningStatus(t),{label:'Совмещение остановлено',tone:'danger',message:'Разрыв координат',pulse:true});
|
||||
assert.equal(planningStatus({...t,state:'waiting'}).label,'Ожидание данных');
|
||||
});
|
||||
|
||||
test('planning activity continues scanner preparation without presenting a prior as tracking', async()=>{
|
||||
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',query_session_id:'B',tracking_state:'acquiring',stale:false,frame_age_s:0,result_age_s:null,result:null,message:'Ожидание на месте.'};
|
||||
assert.equal(planningActivity({...t,planning_phase:'waiting-cloud'}),undefined);
|
||||
for(const [planning_phase,label] of [['collecting','Накопление данных'],['searching','Поиск положения на маршруте'],['refreshing','Подтверждение привязки'],['validating','Подтверждение привязки']]){
|
||||
assert.deepEqual(planningActivity({...t,planning_phase}),{sessionId:'B',label,detail:'',busy:true});
|
||||
assert.equal(planningStatus({...t,planning_phase}).tone,'neutral');
|
||||
}
|
||||
const live={...t,planning_phase:'tracking',tracking_state:'tracking',result_age_s:0,result:{status:'candidate'},message:'Можно начинать проверочный проход.'};
|
||||
assert.equal(planningActivity(live).busy,false);
|
||||
assert.equal(planningStatus(live).tone,'success');
|
||||
const stale=planningStatus({...live,frame_age_s:9});
|
||||
assert.equal(stale.label,'Привязка потеряна');assert.doesNotMatch(stale.message,/Можно начинать/);
|
||||
assert.equal(planningActivity({...t,planning_phase:'searching'},'Нет связи').busy,false);
|
||||
assert.equal(planningActivity({...live,query_session_id:null}),undefined);
|
||||
assert.equal(planningActivity({...live,state:'completed'}),undefined);
|
||||
const recovery=planningStatus({...t,planning_phase:'recovering',tracking_established:true});
|
||||
assert.equal(recovery.pulse,true);
|
||||
assert.equal(recovery.tone,'danger');
|
||||
assert.match(recovery.message,/запись продолжается/);
|
||||
});
|
||||
|
||||
test('project archive restores exact run identity and never promotes a failed live probe', async()=>{
|
||||
const {defaultPlanningProject,planningProjectStatus}=await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
|
||||
const items=[{key:'live:failed',kind:'live',state:'error',result_status:null},{key:'recorded:second',kind:'recorded',state:'ready',result_status:'candidate'},{key:'recorded:first',kind:'recorded',state:'ready',result_status:'candidate'}];
|
||||
assert.equal(defaultPlanningProject(items,null),'recorded:second');
|
||||
assert.equal(defaultPlanningProject(items,'recorded:first'),'recorded:first');
|
||||
assert.equal(defaultPlanningProject(items,'missing'),'recorded:second');
|
||||
assert.equal(planningProjectStatus(items[0]),'Без результата совмещения');
|
||||
assert.equal(defaultPlanningProject([],null),'');
|
||||
});
|
||||
|
||||
|
||||
test('failed initial binding never claims previously established tracking was lost', async()=>{
|
||||
const {planningStatus,planningActivity}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',query_session_id:'B',planning_phase:'lost',tracking_state:'lost',tracking_established:false,
|
||||
stale:true,frame_age_s:null,result_age_s:null,result:null,message:'Начальный поиск не завершён.'};
|
||||
const failed=planningStatus(t);
|
||||
assert.equal(failed.label,'Маршрут не синхронизирован');
|
||||
assert.equal(failed.message,'Начальный поиск не завершён.');
|
||||
assert.deepEqual(planningActivity(t),{sessionId:'B',label:failed.label,detail:failed.message,busy:false});
|
||||
assert.equal(planningStatus({...t,tracking_established:true}).label,'Привязка потеряна');
|
||||
assert.equal(planningStatus({...t,state:'completed'}).label,'Исследование завершено');
|
||||
});
|
||||
|
||||
test('freshness of displayed alignment fences green independently of the fit age',async()=>{
|
||||
const {planningMatchCurrent,planningStatus}=await server.ssrLoadModule('/src/core/missions/planningPresentation.ts');
|
||||
const t={state:'running',tracking_state:'tracking',planning_phase:'tracking',tracking_established:true,
|
||||
stale:false,frame_age_s:.2,result_age_s:4,result:{status:'candidate'},message:'Привязка подтверждена.',presentation_state:'live'};
|
||||
assert.equal(planningMatchCurrent(t),true);
|
||||
assert.equal(planningMatchCurrent({...t,presentation_state:'historical'}),false);
|
||||
assert.equal(planningMatchCurrent({...t,frame_age_s:2.1}),false);
|
||||
assert.match(planningStatus({...t,presentation_state:'historical'}).message,/последней принятой/);
|
||||
assert.match(planningStatus({...t,state:'completed',presentation_state:'historical'}).message,/не текущее положение/);
|
||||
});
|
||||
@@ -184,6 +184,15 @@ function preparation(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("replay preserves an optional corrected-map identity and rejects malformed versions", () => {
|
||||
const decoded = decodeObservationSessionReplay(replay({ map_generation: "b".repeat(64) }));
|
||||
assert.equal(decoded.mapGeneration, "b".repeat(64));
|
||||
assert.equal(decodeObservationSessionReplay(replay()).mapGeneration, undefined);
|
||||
for (const value of [null, "latest", "../private", 123]) {
|
||||
assert.throws(() => decodeObservationSessionReplay(replay({ map_generation: value })), /версию/);
|
||||
}
|
||||
});
|
||||
|
||||
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
@@ -412,7 +421,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
assert.doesNotMatch(laboratorySource, /fetchAdvancedLaboratoryResults/);
|
||||
});
|
||||
|
||||
test("recording preparation statuses share the viewer's left alignment", async () => {
|
||||
test("recording preparation statuses occupy the viewer's upper-right corner", async () => {
|
||||
const spatialStyles = await readFile(
|
||||
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
||||
"utf8",
|
||||
@@ -422,9 +431,10 @@ test("recording preparation statuses share the viewer's left alignment", async (
|
||||
spatialStyles.indexOf(".scene-operation-status {"),
|
||||
);
|
||||
|
||||
assert.match(statusStack, /left:\s*0\.85rem/);
|
||||
assert.match(statusStack, /justify-items:\s*start/);
|
||||
assert.doesNotMatch(statusStack, /right:/);
|
||||
assert.match(statusStack, /right:\s*0\.85rem/);
|
||||
assert.match(statusStack, /top:\s*0\.85rem/);
|
||||
assert.match(statusStack, /justify-items:\s*end/);
|
||||
assert.doesNotMatch(statusStack, /left:|bottom:/);
|
||||
});
|
||||
|
||||
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
||||
@@ -1066,7 +1076,7 @@ test("recording preparation never presents phase heartbeats as fake percentages"
|
||||
|
||||
test("unified recorded AI view keeps the raw camera mounted but does not cover overlays", async () => {
|
||||
const workspaceSource = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
|
||||
@@ -150,14 +150,15 @@ test("follow toggles retain the current recorded camera journal", () => {
|
||||
assert.notEqual(afterReset, before);
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
test("observation camera windows tile from the bottom-left above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
const right = initialObservationWindowRect(1, 2, bounds);
|
||||
|
||||
assert.equal(left.y, right.y);
|
||||
assert.ok(left.x + left.width < right.x);
|
||||
assert.equal(right.x + right.width, bounds.width - 18);
|
||||
assert.equal(left.x, 18);
|
||||
assert.ok(right.x + right.width <= bounds.width - 18);
|
||||
assert.ok(right.y + right.height <= bounds.height - 64);
|
||||
});
|
||||
|
||||
@@ -323,10 +324,14 @@ test("recorded observation timeline rejects empty and non-finite ranges", () =>
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 999);
|
||||
assert.equal(normalizeAccumulationSeconds(1800), 1800);
|
||||
assert.equal(normalizeAccumulationSeconds(9999), 9999);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
assert.equal(formatAccumulationDuration(1800), "30 мин");
|
||||
assert.equal(formatAccumulationDuration(125), "2 мин 5 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
@@ -606,6 +611,32 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded tracking quantizes the native fractional-nanosecond cursor without accepting invalid times", async () => {
|
||||
const bodies = [];
|
||||
const request = currentTimeNs => fetchRecordedBlueprintRrd(
|
||||
"/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{accumulationSeconds: 10, showGrid: true, showPoints: true,
|
||||
showTrajectory: true, pointSize: 0.5, colorMode: "intensity",
|
||||
palette: "turbo", customColor: "#ffffff"},
|
||||
{applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001"},
|
||||
{origin: "http://127.0.0.1:8000", blueprintSessionId: "a".repeat(32),
|
||||
cameraEye: {position: [3, 4, 5], lookTarget: [1, 2, 0], eyeUp: [0, 0, 1]},
|
||||
eyeRelativeToTracking: true, currentTimeNs,
|
||||
fetcher: async (_input, init) => {
|
||||
bodies.push(JSON.parse(init.body));
|
||||
return new Response(new Uint8Array([0x52, 0x52, 0x46, 0x32]), {
|
||||
headers: {"Content-Type": "application/vnd.rerun.rrd"},
|
||||
});
|
||||
}},
|
||||
);
|
||||
await request(536_460_021_972.65625);
|
||||
assert.equal(bodies[0].current_time_ns, 536_460_021_973);
|
||||
for (const invalid of [-0.1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
await assert.rejects(request(invalid), /Unsafe recorded blueprint request/);
|
||||
}
|
||||
assert.equal(bodies.length, 1);
|
||||
});
|
||||
|
||||
test("recorded point colors use one strict same-origin component overlay", async () => {
|
||||
const endpoint = resolveRecordedPointColorsUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
|
||||
@@ -27,7 +27,7 @@ async function read(relativePath) {
|
||||
test("Observatory is the third independent Polygon workspace", () => {
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
productModel.workspaceById("observatory"),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {test} from 'node:test';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
test('planning live scene reuses the canonical vertical height range inside the existing stage',()=>{
|
||||
const source=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
|
||||
assert.match(source,/RangeControl orientation="vertical" limitSide="left" label="Срез"/);
|
||||
assert.match(source,/className="session-overview__height"/);
|
||||
assert.match(source,/ceiling_m:ceilingRef\.current/);
|
||||
assert.match(source,/formatLimit=\{value=>value\.toFixed\(1\)\.replace\('\.',','\)\}/);
|
||||
assert.match(source,/heightBounds=null/);
|
||||
assert.match(source,/const CLIP_CEILING_M=80/);
|
||||
assert.match(source,/max:CLIP_CEILING_M/);
|
||||
assert.match(source,/setBounds\(next\);setCeiling\(null\);/);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
let server,createReporter;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({createPlanningPresentationReporter:createReporter}=await server.ssrLoadModule(
|
||||
'/src/core/missions/planningPresentationTelemetry.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
const settle=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
test('presentation observations batch separately from the scene channel and retain the proxy boundary',async()=>{
|
||||
let timer,posted;
|
||||
const reporter=createReporter({url:'/observations',schedule:callback=>{timer=callback;return 1;},cancel:()=>{},
|
||||
fetcher:async(url,init)=>{posted={url,init};return new Response(null,{status:204});},
|
||||
});
|
||||
reporter.record({presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
|
||||
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12'},
|
||||
{rerunAdmissionMs:2,firstAnimationFrameMs:12,secondAnimationFrameMs:28});
|
||||
assert.ok(timer);timer();await settle();
|
||||
assert.equal(posted.url,'/observations');
|
||||
const body=JSON.parse(posted.init.body);
|
||||
assert.equal(body.schema_version,'missioncore.planning-browser-presentation/v1');
|
||||
assert.deepEqual(body.samples,[{cloud_revision:3,cloud_sequence:4,
|
||||
display_epoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',request_ms:40,
|
||||
rerun_admission_ms:2,first_animation_frame_ms:12,second_animation_frame_ms:28,
|
||||
frame_timeout:false,source_to_second_animation_frame_upper_bound_ms:270}]);
|
||||
reporter.dispose();
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { createServer } from 'vite';
|
||||
let server, api;
|
||||
before(async () => {
|
||||
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
|
||||
api = await server.ssrLoadModule('/src/core/missions/planningProjects.ts');
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
const item = {key: 'live:a', id: 'a', name: 'same name', kind: 'live', state: 'completed', revision: 2};
|
||||
|
||||
test('only terminal runs or drafts admit catalog deletion', () => {
|
||||
for (const state of ['completed', 'cancelled', 'error', 'interrupted']) assert.equal(api.planningProjectDeletable({...item, state}), true);
|
||||
for (const state of ['running', 'waiting', 'preparing', 'new-unknown']) assert.equal(api.planningProjectDeletable({...item, state}), false);
|
||||
assert.equal(api.planningProjectDeletable({...item, kind: 'draft', state: 'draft'}), true);
|
||||
assert.equal(api.planningProjectDeletable({...item, kind: 'recorded', state: 'ready'}), true);
|
||||
});
|
||||
|
||||
test('delete sends exact kind/id/revision and requires a matching receipt', async t => {
|
||||
const calls = [];
|
||||
let receipt = {key: item.key, deleted: true};
|
||||
t.mock.method(globalThis, 'fetch', async (...args) => { calls.push(args); return {ok: true, json: async () => receipt}; });
|
||||
await api.deletePlanningProject(item);
|
||||
assert.equal(calls[0][0], '/api/v1/mission-planner/projects/live/a');
|
||||
assert.equal(calls[0][1].method, 'DELETE');
|
||||
assert.deepEqual(JSON.parse(calls[0][1].body), {revision: 2});
|
||||
receipt = {key: 'live:another-same-name', deleted: true};
|
||||
await assert.rejects(api.deletePlanningProject(item), /не подтверждено/);
|
||||
const count = calls.length;
|
||||
await assert.rejects(api.deletePlanningProject({...item, state: 'running'}), /завершите/);
|
||||
assert.equal(calls.length, count);
|
||||
});
|
||||
|
||||
test('server refusal stays an error, never a successful deletion', async t => {
|
||||
t.mock.method(globalThis, 'fetch', async () => ({ok: false, json: async () => ({detail: 'Проект изменён.'})}));
|
||||
await assert.rejects(api.deletePlanningProject(item), /Проект изменён/);
|
||||
});
|
||||
|
||||
test('UI uses canonical row actions and modal, and stale loads cannot resurrect a deleted item', async () => {
|
||||
const component = await readFile(new URL('../src/components/missions/PlanningProjectSelect.tsx', import.meta.url), 'utf8');
|
||||
const hook = await readFile(new URL('../src/core/missions/usePlanningProjects.ts', import.meta.url), 'utf8');
|
||||
assert.match(component, /<Select/); assert.match(component, /<ConfirmationModal/);
|
||||
assert.match(component, /disabled: !planningProjectDeletable\(item\)/);
|
||||
assert.match(component, /await onRemove\(target\); setTarget\(null\)/);
|
||||
assert.match(component, /<ToastStack/);
|
||||
assert.match(hook, /removedKeys.current.has\(item.key\)/);
|
||||
assert.match(hook, /removedKeys.current.has\(key\)/);
|
||||
assert.match(hook, /if \(selectedKey.current === project.key\) select\(''\)/);
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
import {readFileSync} from 'node:fs';
|
||||
let server,start;
|
||||
before(async()=>{server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});({startPlanningSceneStream:start}=await server.ssrLoadModule('/src/core/missions/planningSceneStream.ts'));});
|
||||
after(async()=>{await server?.close();});
|
||||
const settle=()=>new Promise(resolve=>setImmediate(resolve));
|
||||
|
||||
function fixture(){
|
||||
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
|
||||
const timers=new Map(),requests=[],applied=[],errors=[];
|
||||
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
|
||||
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
|
||||
fetcher:(url,init)=>new Promise((resolve,reject)=>requests.push({url,init,resolve,reject})),
|
||||
apply:bytes=>applied.push(bytes),error:()=>errors.push(true)});
|
||||
return {timers,requests,applied,errors,stop,setState:value=>{state=value;},setClock:value=>{clock=value;},
|
||||
next:()=>{const [id,timer]=[...timers].find(([,t])=>t.ms!==2500);timers.delete(id);timer.fn();},
|
||||
answer:(index,cursor='next',status=200)=>requests[index].resolve(new Response(status===204?null:new Uint8Array([1,2]),{status,headers:{'X-Planning-Scene-Cursor':cursor}}))};
|
||||
}
|
||||
|
||||
test('one pending fetch, native cadence, no-op response and cancellation',async()=>{
|
||||
const f=fixture();assert.equal(f.requests.length,1);
|
||||
assert.equal(f.timers.size,1); // deadline only: no overlapping polling interval
|
||||
f.setClock(35);f.answer(0);await settle();
|
||||
assert.equal(f.applied.length,1);assert.equal([...f.timers.values()][0].ms,65);
|
||||
f.next();assert.match(f.requests[1].url,/cursor=next/);
|
||||
f.answer(1,'next',204);await settle();assert.equal(f.applied.length,1);
|
||||
f.next();f.stop();assert.equal(f.requests[2].init.signal.aborted,true);
|
||||
f.answer(2);await settle();assert.equal(f.applied.length,1);assert.equal(f.timers.size,0);
|
||||
});
|
||||
|
||||
test('a changed mode or ended run discards the in-flight response and rebases',async()=>{
|
||||
for(const next of [{active:true,revision:2,options:{mode:'top'}},{active:false,revision:2,options:{mode:'3d'}}]){
|
||||
const f=fixture();f.setState(next);f.answer(0);await settle();
|
||||
assert.equal(f.applied.length,0);f.next();assert.match(f.requests[1].url,/base=true/);
|
||||
f.answer(1);await settle();assert.equal(f.applied.length,1);f.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('failure hides stale evidence, repairs geometry but retains admitted camera cursor',async()=>{
|
||||
const f=fixture();f.answer(0);await settle();f.next();
|
||||
f.requests[1].reject(new Error('offline'));await settle();
|
||||
assert.equal(f.errors.length,1);assert.equal([...f.timers.values()][0].ms,500);
|
||||
f.next();assert.match(f.requests[2].url,/base=true/);assert.match(f.requests[2].url,/cursor=next/);
|
||||
f.stop();f.answer(2);await settle();assert.equal(f.applied.length,1);
|
||||
});
|
||||
|
||||
test('display changes and discarded responses retain camera identity; reset intent reaches server',async()=>{
|
||||
const f=fixture();f.answer(0,'admitted-camera');await settle();f.next();
|
||||
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:0}});
|
||||
f.answer(1,'discarded-camera');await settle();
|
||||
assert.equal(f.applied.length,1);f.next();
|
||||
assert.match(f.requests[2].url,/cursor=admitted-camera/);
|
||||
assert.match(f.requests[2].url,/base=true/);
|
||||
assert.match(f.requests[2].url,/reset=0/);
|
||||
f.answer(2,'clipped');await settle();
|
||||
f.setState({active:true,revision:1,options:{mode:'3d',ceiling_m:3,reset:1}});
|
||||
f.next();assert.match(f.requests[3].url,/reset=1/);
|
||||
f.answer(3,'reset');await settle();f.stop();
|
||||
});
|
||||
|
||||
test('unchanged terminal views stop transfer but notice a later revision',async()=>{
|
||||
const f=fixture();f.setState({active:false,revision:2,options:{mode:'3d'}});
|
||||
f.answer(0);await settle();f.next();f.answer(1);await settle();f.next();
|
||||
assert.equal(f.requests.length,2);
|
||||
f.setState({active:false,revision:3,options:{mode:'3d'}});f.next();
|
||||
assert.equal(f.requests.length,3);f.stop();f.answer(2);await settle();
|
||||
});
|
||||
|
||||
test('cloud or fit expiring during delivery cannot revive green',async()=>{
|
||||
for(const [cloud,fit] of [['1.8','1'],['.1','7.8'],['invalid','1']]){
|
||||
const f=fixture();f.setClock(300);
|
||||
f.requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
|
||||
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
|
||||
'X-Planning-Cloud-Age':cloud,'X-Planning-Fit-Age':fit}}));
|
||||
await settle();assert.equal(f.applied.length,0);assert.equal(f.errors.length,1);f.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('a live response waits for bounded browser delivery and passes its exact receipt identity',async()=>{
|
||||
let release,delivery;
|
||||
let sequence=0,state={active:true,revision:1,options:{mode:'3d'}},clock=0;
|
||||
const timers=new Map(),requests=[];
|
||||
const stop=start({url:'/scene',snapshot:()=>state,now:()=>clock,
|
||||
schedule:(fn,ms)=>{const id=++sequence;timers.set(id,{fn,ms});return id;},cancel:id=>timers.delete(id),
|
||||
fetcher:(url,init)=>new Promise(resolve=>requests.push({url,init,resolve})),
|
||||
apply:(_bytes,value)=>{delivery=value;return new Promise(resolve=>{release=resolve;});},error:assert.fail,
|
||||
});
|
||||
clock=40;
|
||||
requests[0].resolve(new Response(new Uint8Array([1]),{headers:{
|
||||
'X-Planning-Scene-Cursor':'next','X-Planning-Presentation':'live',
|
||||
'X-Planning-Cloud-Age':'.2','X-Planning-Fit-Age':'.1',
|
||||
'X-Planning-Cloud-Revision':'3','X-Planning-Cloud-Sequence':'4',
|
||||
'X-Planning-Display-Epoch':'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
|
||||
'X-Planning-Height-Min':'-1.2','X-Planning-Height-Max':'51.5',
|
||||
}}));
|
||||
await settle();
|
||||
assert.deepEqual(delivery,{presentation:'live',cloudAgeMs:200,fitAgeMs:100,requestMs:40,
|
||||
cloudRevision:3,cloudSequence:4,displayEpoch:'5d8c5d13-dfde-4ab6-9da0-475ab1f22c12',
|
||||
heightMinM:-1.2,heightMaxM:51.5});
|
||||
assert.equal(timers.size,1);
|
||||
assert.equal([...timers.values()][0].ms,2500);
|
||||
release();await settle();
|
||||
assert.equal([...timers.values()][0].ms,60);
|
||||
stop();
|
||||
});
|
||||
|
||||
test('profile scene can expand after native capture ends and retains Escape',()=>{
|
||||
const source=readFileSync(new URL('../src/workspaces/spatial/SpatialWorkspace.tsx',import.meta.url),'utf8');
|
||||
assert.match(source,/pointCloudVisible && \(visualProfile \|\| sourceUrl.trim\(\)\)/);
|
||||
assert.match(source,/<IconButton\s+className="scene-source-control"\s+label="Развернуть облако точек"/);
|
||||
assert.match(source,/event.key !== "Escape"/);
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {readFileSync} from 'node:fs';
|
||||
|
||||
let server,Tool,Actions;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({PlanningSceneToolWindow:Tool}=await server.ssrLoadModule('/src/components/missions/PlanningSceneToolWindow.tsx'));
|
||||
({SpatialToolbarActions:Actions}=await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialToolbarActions.tsx',import.meta.url).pathname));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
|
||||
test('scene tools use the bounded modeless window with move, resize and expand controls',()=>{
|
||||
const html=renderToStaticMarkup(createElement(Tool,{boundsRef:{current:null},title:'Слои',onClose:()=>{}},'CONTROLS'));
|
||||
assert.match(html,/nodedc-workspace-window/);
|
||||
assert.match(html,/aria-modal="false"/);
|
||||
assert.match(html,/Переместить инструмент/);
|
||||
assert.match(html,/Изменить размер инструмента/);
|
||||
assert.match(html,/Развернуть инструмент/);
|
||||
assert.doesNotMatch(html,/nodedc-overlay/);
|
||||
});
|
||||
|
||||
test('spatial toolbar omits source and duplicate planning navigation',()=>{
|
||||
const html=renderToStaticMarkup(createElement(Actions,{openLayers:()=>{},openDisplay:()=>{},activeTool:'layers'}));
|
||||
assert.match(html,/Слои/);assert.match(html,/Отображение/);
|
||||
assert.match(html,/aria-pressed="true"/);assert.doesNotMatch(html,/Движок|Планирование/);
|
||||
const workspace=readFileSync(new URL('../src/workspaces/missions/PlanningSpatialWorkspace.tsx',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(workspace,/<Window\s|openSource=|openView\('mission-planner'\)/);
|
||||
assert.match(workspace,/tools:boundsRef=>tool&&<PlanningSceneToolWindow/);
|
||||
const viewer=readFileSync(new URL('../src/components/missions/PlanningLiveScene.tsx',import.meta.url),'utf8');
|
||||
assert.match(viewer,/\},\[runId,retry\]\)/); // Presentation never owns viewer lifetime.
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import React from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {createServer} from 'vite';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
|
||||
let server,usePlanner,Settings;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
({useMissionPlanner:usePlanner}=await server.ssrLoadModule('/src/core/missions/useMissionPlanner.ts'));
|
||||
({PlanningProjectSettings:Settings}=await server.ssrLoadModule('/src/components/missions/PlanningProjectSettings.tsx'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
|
||||
// Bounded hook dispatcher: execute the real state/effect transitions, without
|
||||
// mounting another browser or replacing the production hook with a fake.
|
||||
function harness(){
|
||||
const slots=[];let index=0,pending=[];
|
||||
const hooks={
|
||||
useState(initial){const i=index++;if(!slots[i])slots[i]={value:initial};return [slots[i].value,v=>{slots[i].value=typeof v==='function'?v(slots[i].value):v;}];},
|
||||
useEffect(fn,deps){const i=index++,old=slots[i];if(!old||deps.some((d,n)=>!Object.is(d,old.deps[n])))pending.push(()=>{old?.cleanup?.();slots[i]={deps,cleanup:fn()};});},
|
||||
useMemo(fn){return fn();},useCallback(fn){return fn;},
|
||||
};
|
||||
const render=()=>{index=0;const internal=React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,old=internal.H;internal.H=hooks;try{return usePlanner();}finally{internal.H=old;}};
|
||||
return {render,async settle(){let state;for(let n=0;n<5;n++){state=render();const effects=pending;pending=[];effects.forEach(fn=>fn());await new Promise(resolve=>setImmediate(resolve));}return render();},close(){slots.forEach(s=>s.cleanup?.());}};
|
||||
}
|
||||
const source={schema_version:'missioncore.planning-source/v1',session_id:'ring',generation:'a'.repeat(64),label:'Ring',units:'m',path_m:580,
|
||||
poses:Array.from({length:60},(_,index)=>({index,position:[index*10,0,0],distance_m:index*10}))};
|
||||
|
||||
test('same recording reselection cannot collapse the reference or disable a named project',async()=>{
|
||||
const oldFetch=globalThis.fetch,requests=[];const h=harness();
|
||||
globalThis.fetch=async(url,options={})=>{
|
||||
requests.push({url,body:options.body&&JSON.parse(options.body)});
|
||||
if(options.method==='POST')return {ok:true,json:async()=>({id:'draft',revision:1,name:'Seam',zone:{session_id:'ring',generation:source.generation},route:{start_index:0,end_index:59,direction:'forward'}})};
|
||||
return {ok:true,json:async()=>url.includes('/sources/')?source:{items:[],next_cursor:null}};
|
||||
};
|
||||
try{
|
||||
let p=await h.settle();p.chooseSource('ring');p.setName('Seam');p=await h.settle();
|
||||
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
|
||||
for(let i=0;i<4;i++){p.chooseSource('ring');p=await h.settle();assert.equal(p.poses.length,60);assert.equal(p.ready,true);}
|
||||
await p.save();const body=requests.find(r=>r.body)?.body;
|
||||
assert.equal(body.whole_recording,true);assert.equal(body.start_index,undefined);assert.equal(body.end_index,undefined);
|
||||
assert.ok(requests.some(r=>r.url.includes('scope=standalone')));
|
||||
p=h.render();p.newDraft();p=await h.settle();p.chooseSource('ring');p.setName('Next');p=await h.settle();
|
||||
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
|
||||
assert.equal(p.setStart,undefined);assert.equal(p.setEnd,undefined);
|
||||
}finally{h.close();globalThis.fetch=oldFetch;}
|
||||
});
|
||||
|
||||
test('product removes reference crop controls and Data requests independent captures',async()=>{
|
||||
const settings=await readFile(new URL('../src/components/missions/PlanningProjectSettings.tsx',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(settings,/Участок эталона|Начало участка|Конец участка|30 м от начала участка|p\.set(Start|End)/);
|
||||
assert.match(settings,/Вся запись/);
|
||||
const selector=await readFile(new URL('../src/components/ObservationSessionSelect.tsx',import.meta.url),'utf8');
|
||||
assert.match(selector,/scope: "standalone"/);
|
||||
});
|
||||
|
||||
test('planner offers one direct live start without a repeated-pass mode or catalogue',async()=>{
|
||||
const p={name:'Ring',poses:source.poses,source,sessionId:'ring',options:[{value:'ring',label:'Ring'}],direction:'forward',ready:true};
|
||||
const render=(overrides={},starting=false)=>renderToStaticMarkup(React.createElement(Settings,{p:{...p,...overrides},starting,onStart:()=>{}}));
|
||||
const ready=render();
|
||||
assert.doesNotMatch(ready,/Повторный проход|Из записи|Источник повторного прохода|Повторная запись|После запуска/);
|
||||
const startButton=html=>[...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(m=>m[0]).find(button=>button.includes('Начать новый проход'));
|
||||
assert.ok(startButton(ready));
|
||||
assert.doesNotMatch(startButton(ready),/disabled=/);
|
||||
for(const state of [{ready:false},{busy:true},{poses:source.poses.slice(0,1)}])assert.match(startButton(render(state)),/disabled=/);
|
||||
assert.match(startButton(render({},true)),/disabled=/);
|
||||
let started=0;
|
||||
const tree=Settings({p,starting:false,onStart:()=>{started++;}});
|
||||
tree.props.children[1].props.children[1].props.onClick();
|
||||
assert.equal(started,1);
|
||||
const workspace=await readFile(new URL('../src/workspaces/missions/MissionPlannerWorkspace.tsx',import.meta.url),'utf8');
|
||||
assert.doesNotMatch(workspace,/useRegistrationTest|setMode|t\.run\(/);
|
||||
assert.match(workspace,/live\.begin\(draft\)/);
|
||||
});
|
||||
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
|
||||
assert.equal(workspaceById("datasets").kind, "datasets");
|
||||
assert.deepEqual(
|
||||
workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
|
||||
);
|
||||
assert.equal(
|
||||
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
|
||||
|
||||
@@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => {
|
||||
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
|
||||
assert.deepEqual(
|
||||
productModel.workspacesForRoot("polygon").map(({ id }) => id),
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene"],
|
||||
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
|
||||
);
|
||||
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
|
||||
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);
|
||||
|
||||
@@ -295,7 +295,7 @@ test("loading and error overlays fully conceal recorded camera pixels", async ()
|
||||
|
||||
test("point-cloud fullscreen keeps the admitted recorded camera worker mounted", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(source, /\{visibleMediaSources\.map\(\(source, index\) => \(/);
|
||||
|
||||
@@ -1,101 +1,42 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createRecordedRerunCameraJournal, initialEye;
|
||||
let server, readNativeRerunCameraEye;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
const module = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts");
|
||||
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
|
||||
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
|
||||
({ readNativeRerunCameraEye } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
}
|
||||
|
||||
class FakeTarget {
|
||||
listeners = new Map();
|
||||
emitted = [];
|
||||
addEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener); this.listeners.set(type, listeners);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
this.emitted.push(event);
|
||||
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
|
||||
return true;
|
||||
test("camera snapshot copies exact native pose, without approximating input", () => {
|
||||
const native = { position: [287, -74, 8], lookTarget: [286, -72, 0], eyeUp: [0, 0, 1] };
|
||||
const eye = readNativeRerunCameraEye(native);
|
||||
assert.deepEqual(eye, native);
|
||||
native.position[0] = 999;
|
||||
assert.equal(eye.position[0], 287);
|
||||
assert.equal(readNativeRerunCameraEye(null), null);
|
||||
});
|
||||
|
||||
test("invalid native pose fails closed instead of substituting a guessed camera", () => {
|
||||
for (const value of [{}, "pose", { position: [NaN, 0, 1], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1] }]) {
|
||||
assert.throws(() => readNativeRerunCameraEye(value), /Invalid native Rerun camera/);
|
||||
}
|
||||
}
|
||||
|
||||
const radius = (eye) => Math.hypot(
|
||||
eye.position[0] - eye.lookTarget[0],
|
||||
eye.position[1] - eye.lookTarget[1],
|
||||
eye.position[2] - eye.lookTarget[2],
|
||||
);
|
||||
|
||||
test("recorded orbital eye tracks only 3D viewport navigation", () => {
|
||||
const scope = new FakeTarget();
|
||||
const timers = [];
|
||||
Object.assign(scope, {
|
||||
WheelEvent: FakeEvent,
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
setTimeout(callback) { timers.push(callback); return timers.length; },
|
||||
});
|
||||
const canvas = new FakeTarget();
|
||||
Object.assign(canvas, {
|
||||
clientHeight: 200,
|
||||
isConnected: true,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
|
||||
}),
|
||||
});
|
||||
const journal = createRecordedRerunCameraJournal(canvas, scope);
|
||||
journal.configure(initialEye, 0.46);
|
||||
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
|
||||
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
|
||||
assert.deepEqual(journal.current(), initialEye);
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
|
||||
const rotated = journal.current();
|
||||
assert.notDeepEqual(rotated.position, initialEye.position);
|
||||
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
|
||||
|
||||
journal.setMaxOrbitalRadius(40);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
|
||||
|
||||
journal.configure(rotated, 0.46);
|
||||
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
const zoomed = journal.current();
|
||||
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
|
||||
|
||||
const snapshot = journal.current();
|
||||
journal.setSpatialViewportStart(0.8);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
}));
|
||||
assert.deepEqual(journal.current(), snapshot);
|
||||
journal.dispose();
|
||||
});
|
||||
|
||||
test("iframe owner reads the native camera and installs no shadow input listeners", () => {
|
||||
const owner = readFileSync(new URL("../src/components/rerun/recordedRerunOwner.ts", import.meta.url), "utf8");
|
||||
const camera = readFileSync(new URL("../src/components/rerun/recordedRerunCameraJournal.ts", import.meta.url), "utf8");
|
||||
assert.match(owner, /get_camera_eye\?\.\(\)/);
|
||||
assert.doesNotMatch(owner + camera, /createRecordedRerunCameraJournal|addEventListener|Math\.exp/);
|
||||
});
|
||||
|
||||
test("display blueprint activation carries the current native eye, not the embedded preset", () => {
|
||||
const viewport = readFileSync(new URL("../src/components/RerunViewport.tsx", import.meta.url), "utf8");
|
||||
assert.match(viewport, /!enablingFollow \? active\.getCameraEye\?\.\(\) \?\? undefined : undefined/);
|
||||
assert.match(viewport, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,/);
|
||||
assert.match(viewport, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,17 @@ function bridge(native, mount = {}) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
test("camera snapshot crosses the disposable realm as copied primitive data", () => {
|
||||
const eye = { position: [300, 7, 8], lookTarget: [298, 9, 0], eyeUp: [0, 0, 1] };
|
||||
const { facade, dispose } = bridge({ stop() {}, get_camera_eye: () => eye });
|
||||
const received = facade.get_camera_eye();
|
||||
assert.deepEqual(received, eye);
|
||||
assert.notEqual(received, eye);
|
||||
assert.notEqual(received.position, eye.position);
|
||||
dispose();
|
||||
assert.throws(() => facade.get_camera_eye(), /disposed/);
|
||||
});
|
||||
|
||||
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
||||
const f = fixture(t, { stopFails: true });
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
|
||||
@@ -251,6 +251,10 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.match(source, /recordedPerceptionLayers\.costmap,/);
|
||||
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
|
||||
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
|
||||
// Ordinary archives need activation too, not only the LAB costmap path.
|
||||
assert.match(source, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,[\s\S]*?\n\s*true,\s*\);/);
|
||||
// Display changes must not trigger a full-file camera-bounds scan.
|
||||
assert.match(source, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||
assert.match(
|
||||
@@ -320,7 +324,7 @@ test("one live document owns one native Rerun receiver", async () => {
|
||||
|
||||
test("raw replay exercises the same streaming receiver lifecycle as a live scan", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
@@ -343,7 +347,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
||||
);
|
||||
assert.match(
|
||||
source,
|
||||
/const recordedSource = Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
|
||||
/const recordedSource = !visualProfile && \(Boolean\(recordedReplay\) \|\| \/\\\.rrd/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
@@ -353,7 +357,7 @@ test("raw replay exercises the same streaming receiver lifecycle as a live scan"
|
||||
|
||||
test("pending K1 STOP keeps the live Rerun source mounted until local capture ends", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../src/workspaces/Workspaces.tsx", import.meta.url),
|
||||
new URL("../src/workspaces/spatial/SpatialWorkspace.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
assert.match(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -7,13 +8,41 @@ const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
|
||||
test("Mission Core pins Rerun 0.36.3 with the bounded native navigation installer", () => {
|
||||
const application = readJson(resolve(root, "package.json"));
|
||||
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||
|
||||
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||
assert.equal(installed.version, "0.36.3");
|
||||
assert.equal(application.scripts.postinstall, undefined);
|
||||
assert.equal(application.scripts.postinstall, "node scripts/install-rerun-navigation.mjs");
|
||||
assert.equal(application.scripts.prebuild, application.scripts.postinstall);
|
||||
});
|
||||
|
||||
test("native navigation artifacts and source patch match their provenance", () => {
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = readJson(resolve(vendorRoot, "navigation-build.json"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
assert.equal(manifest.upstreamVersion, "0.36.3");
|
||||
assert.equal(manifest.upstreamCommit, "6ded109d33c549e98185f7c95fa8009d44e4adef");
|
||||
assert.equal(sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))), manifest.patchSha256);
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
if (!identity.artifact) continue;
|
||||
assert.equal(sha(readFileSync(resolve(vendorRoot, identity.artifact))), identity.sha256, name);
|
||||
assert.equal(sha(readFileSync(resolve(packageRoot, name))), identity.sha256, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("generated JS and native WASM share one ABI including the camera snapshot", () => {
|
||||
const glue = readFileSync(resolve(packageRoot, "re_viewer.js"), "utf8");
|
||||
const wasm = new WebAssembly.Module(readFileSync(resolve(packageRoot, "re_viewer_bg.wasm")));
|
||||
const names = new Set(WebAssembly.Module.exports(wasm).map(item => item.name));
|
||||
assert.ok(names.has("webhandle_nodedc_camera_eye"));
|
||||
for (const [, name] of glue.matchAll(/\bwasm\.([a-zA-Z_$][\w$]*)/g)) {
|
||||
assert.ok(names.has(name), `Missing WASM export: ${name}`);
|
||||
}
|
||||
assert.match(readFileSync(resolve(packageRoot, "index.js"), "utf8"), /this\.#handle\.nodedc_camera_eye\(\)/);
|
||||
assert.match(readFileSync(resolve(packageRoot, "index.d.ts"), "utf8"), /get_camera_eye\(\)/);
|
||||
assert.match(readFileSync(resolve(packageRoot, "re_viewer.d.ts"), "utf8"), /nodedc_camera_eye\(\)/);
|
||||
});
|
||||
|
||||
test("the active application never imports or installs the archived vendor fork", () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before, after, test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
let server, profile, points, defaults, Controls, Timeline;
|
||||
before(async () => {
|
||||
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
|
||||
profile = await server.ssrLoadModule('/src/core/observation/sessionDisplayProfile.ts');
|
||||
points = await server.ssrLoadModule('/src/core/observation/recordedPointDisplay.ts');
|
||||
defaults = (await server.ssrLoadModule('/src/sceneSettings.ts')).defaultSceneSettings;
|
||||
({SceneDisplayControls: Controls} = await server.ssrLoadModule('../../packages/spatial-ui/src/SceneDisplayControls.tsx'));
|
||||
({ObservationTimeline: Timeline} = await server.ssrLoadModule('/src/components/ObservationTimeline.tsx'));
|
||||
});
|
||||
after(async () => {await server?.close();});
|
||||
|
||||
const document = (settings, id='session-a') => ({schema_version: 'missioncore.session-display-profile/v1',
|
||||
session_id: id, scene_settings: profile.encodeSessionDisplay(settings)});
|
||||
|
||||
test('session profile round trips all display settings and fractional decimation', () => {
|
||||
const settings = {...defaults, accumulationMaxSeconds: 600, accumulationSeconds: 590, pointDecimationPercent: 49.5};
|
||||
assert.deepEqual(profile.decodeSessionDisplay(document(settings), 'session-a'), settings);
|
||||
assert.throws(() => profile.decodeSessionDisplay(document(settings), 'session-b'), /другой записи/);
|
||||
for (const percent of [-1, 100.1, NaN, Infinity]) {
|
||||
assert.throws(() => profile.decodeSessionDisplay(document({...settings, pointDecimationPercent: percent}), 'session-a'));
|
||||
}
|
||||
for (const percent of [0, 50, 100]) assert.equal(profile.decodeSessionDisplay(document({...settings,
|
||||
pointDecimationPercent: percent}), 'session-a').pointDecimationPercent, percent);
|
||||
assert.equal(defaults.accumulationMaxSeconds, 180);
|
||||
});
|
||||
|
||||
test('timeline uses per-session range instead of a fixed duration cap', () => {
|
||||
const html = renderToStaticMarkup(createElement(Timeline, {accumulationSeconds: 47,
|
||||
accumulationMaxSeconds: 600, onAccumulationChange: () => {}}));
|
||||
assert.match(html, /max="600"/);
|
||||
});
|
||||
|
||||
test('display exposes precise thinning only for saved playback', () => {
|
||||
const props = {displayDraft: defaults, stageDisplayPatch: () => {}, commitDisplayPatch: () => {}, flushDisplaySettings: () => {}};
|
||||
assert.match(renderToStaticMarkup(createElement(Controls, {...props, replayPresented: true})), /Прореживание облака/);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(Controls, props)), /Прореживание облака/);
|
||||
});
|
||||
|
||||
test('open Display previews 100%, 0% and accumulation; close alone persists the latest draft', async () => {
|
||||
// Execute the actual composition callbacks with deterministic timers. This
|
||||
// catches the former open-inspector early return, not just helper behavior.
|
||||
const source = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile('App.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const callbacks = new Map();
|
||||
const names = ['stageDisplayPatch', 'flushDisplaySettings', 'commitDisplaySettings'];
|
||||
let close;
|
||||
function visit(node) {
|
||||
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(ast))) {
|
||||
callbacks.set(node.name.getText(ast), node.initializer.getText(ast));
|
||||
}
|
||||
if (ts.isBinaryExpression(node) && node.left.getText(ast) === 'closeDisplayRef.current') close = node.getText(ast);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
assert.equal(callbacks.size, names.length); assert.ok(close);
|
||||
const applied = [], saved = [], timers = new Map(); let timerId = 0;
|
||||
const context = {
|
||||
useCallback: fn => fn, displayWindowOpenRef: {current: true}, replayActiveRef: {current: true},
|
||||
displayDraftRef: {current: {...defaults, pointDecimationPercent: 86}}, setDisplayDraft() {},
|
||||
viewerSettingsCommitTimerRef: {current: null}, viewerSettingsQuietPeriodMs: 750,
|
||||
sceneSettingsCommitterRef: {current: {enqueue: value => applied.push(value)}}, closeDisplayRef: {current: null},
|
||||
sessionDisplayProfile: {edited() {}, save: value => saved.push(value)},
|
||||
window: {setTimeout: fn => {timers.set(++timerId, fn); return timerId;}, clearTimeout: id => timers.delete(id)},
|
||||
};
|
||||
const code = [...callbacks].map(([name, init]) => `const ${name} = ${init};`).join('\n') +
|
||||
`\n${close}; ({stageDisplayPatch, flushDisplaySettings, close: closeDisplayRef.current});`;
|
||||
const actions = runInNewContext(ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2022}}).outputText, context);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 100});
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 100);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 0});
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 0);
|
||||
actions.stageDisplayPatch({accumulationSeconds: 180});
|
||||
actions.flushDisplaySettings();
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 180);
|
||||
actions.stageDisplayPatch({accumulationSeconds: 10});
|
||||
actions.flushDisplaySettings();
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 10);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 49.5});
|
||||
assert.equal(timers.size, 1);
|
||||
const pending = [...timers.values()][0]; timers.clear(); pending();
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 49.5);
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 10);
|
||||
assert.equal(saved.length, 0);
|
||||
actions.close();
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].pointDecimationPercent, 49.5);
|
||||
assert.equal(saved[0].accumulationSeconds, 10);
|
||||
});
|
||||
|
||||
test('display stream validates fragmented header and sends bounded chunks', async () => {
|
||||
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
|
||||
try {
|
||||
const wire = [78,80,68,49,8,0,0,0,82,82,70,50,1,2,3,4,0,0,0,0];
|
||||
const chunks = [new Uint8Array(wire.slice(0,1)), new Uint8Array(wire.slice(1,7)),
|
||||
new Uint8Array(wire.slice(7,10)), new Uint8Array(wire.slice(10))];
|
||||
const sent = []; let request;
|
||||
const fetcher = async (url, options) => {request = {url, ...options}; return new Response(new ReadableStream({
|
||||
start(controller) {chunks.forEach(chunk => controller.enqueue(chunk)); controller.close();},
|
||||
}), {headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}});};
|
||||
const total = await points.streamRecordedPointDisplay('/api/v1/observation-sessions/session-a/blueprint.rrd',
|
||||
{...defaults, pointDecimationPercent: 49.5}, {applicationId: 'app', recordingId: 'rec'}, 'a'.repeat(32),
|
||||
new AbortController().signal, chunk => sent.push(...chunk), fetcher, 'a'.repeat(64));
|
||||
assert.equal(total, 20); assert.deepEqual(sent, [82,82,70,50,1,2,3,4]);
|
||||
assert.equal(JSON.parse(request.body).point_decimation_percent, 49.5);
|
||||
assert.equal(JSON.parse(request.body).source_generation, 'a'.repeat(64));
|
||||
assert.match(request.url, /point-display\.rrd$/);
|
||||
} finally {globalThis.window = previous;}
|
||||
});
|
||||
|
||||
test('invalid, cross-origin and canceled streams never become active', async () => {
|
||||
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
|
||||
try {
|
||||
const call = (url, signal, fetcher) => points.streamRecordedPointDisplay(url, {...defaults, pointDecimationPercent: 50},
|
||||
{applicationId: 'app', recordingId: 'rec'}, 'b'.repeat(32), signal, () => assert.fail('must not publish'), fetcher);
|
||||
const url = '/api/v1/observation-sessions/session-a/blueprint.rrd';
|
||||
await assert.rejects(call('https://elsewhere.test'+url, new AbortController().signal, () => assert.fail('must not fetch')));
|
||||
await assert.rejects(call(url, new AbortController().signal, async () => new Response('bad!', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Некорректный поток/);
|
||||
await assert.rejects(call(url, new AbortController().signal, async () => new Response('NPD1', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /не завершён/);
|
||||
const abort = new AbortController(); abort.abort();
|
||||
await assert.rejects(call(url, abort.signal, async () => new Response('RRF2', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Aborted/);
|
||||
} finally {globalThis.window = previous;}
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
let server, api;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
|
||||
api = await server.ssrLoadModule('/src/core/observation/sessionOverview.ts');
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
test('height slice delegates endpoint labels and unit-bearing value to the shared range', async () => {
|
||||
const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /RangeControl orientation="vertical" limitSide="left" label="Срез"/);
|
||||
assert.match(source, /formatLimit=\{value => value\.toFixed\(1\)\.replace\('\.', ','\)\}/);
|
||||
assert.match(source, /formatValue=\{value => `\$\{value\.toFixed\(1\)\.replace\('\.', ','\)\} м`\}/);
|
||||
assert.doesNotMatch(source, /<span>\{(?:high|low)\.toFixed\(1\)\} м<\/span>/);
|
||||
});
|
||||
test('interval chart retains the largest pause and rejects invalid values', () => {
|
||||
const result = api.overviewChartPoints([[0, .1], [30, 1.2], [60, .1], [NaN, 5]], 100, 50);
|
||||
assert.equal(result.xmax, 60);
|
||||
assert.equal(result.ymax, 1.32);
|
||||
assert.equal(result.points.split(' ').length, 3);
|
||||
assert.doesNotMatch(result.points, /NaN/);
|
||||
});
|
||||
test('session overview rejects a result belonging to a different session', async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = async () => ({ ok: true, json: async () => ({ schema_version: 'missioncore.session-overview/v1', state: 'ready', session: { session_id: 'other' } }) });
|
||||
try { await assert.rejects(api.fetchSessionOverview('selected', new AbortController().signal), /некорректные/); }
|
||||
finally { globalThis.fetch = original; }
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createServer } from 'vite';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
let server, api;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
|
||||
api = await server.ssrLoadModule('/src/core/observation/sessionOverviewSpatial.ts');
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
test('comparison pins identity and changes geometry without requesting a camera reset', async () => {
|
||||
const oldFetch = globalThis.fetch, oldWindow = globalThis.window;
|
||||
const requests = [];
|
||||
globalThis.window = { location: { origin: 'http://localhost:8000' } };
|
||||
globalThis.fetch = async (url, options) => {
|
||||
requests.push({ url: String(url), body: JSON.parse(options.body) });
|
||||
return { ok: true, arrayBuffer: async () => new ArrayBuffer(8), headers: new Headers({ 'X-Overview-Visible-Points': '42' }) };
|
||||
};
|
||||
try {
|
||||
const source = '/api/v1/observation-sessions/example/overview/scene.rrd?generation=' + 'a'.repeat(64);
|
||||
const signal = new AbortController().signal;
|
||||
for (const representation of ['original', 'corrected', 'original']) {
|
||||
const result = await api.updateOverviewSpatial(source, 80, null, 1.5, signal, 'b'.repeat(64), representation);
|
||||
assert.equal(result.eye, null);
|
||||
assert.equal(result.visiblePoints, 42);
|
||||
}
|
||||
assert.deepEqual(requests.map(r => r.body.representation), ['original', 'corrected', 'original']);
|
||||
for (const request of requests) {
|
||||
assert.equal(request.body.mode, null);
|
||||
assert.equal(request.body.comparison_generation, 'b'.repeat(64));
|
||||
assert.equal(request.body.generation, 'a'.repeat(64));
|
||||
assert.ok(request.url.endsWith('/overview/spatial'));
|
||||
}
|
||||
await assert.rejects(api.updateOverviewSpatial(source, null, null, 1, signal, null, 'corrected'), /недоступна/);
|
||||
assert.equal(requests.length, 3);
|
||||
} finally { globalThis.fetch = oldFetch; globalThis.window = oldWindow; }
|
||||
});
|
||||
test('comparison control is opt-in while default geometry and pinned planner versions are shared', async () => {
|
||||
const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8');
|
||||
assert.match(source, /compareVersions=false/);
|
||||
assert.match(source, /}, \[sourceUrl, retry\]\);/);
|
||||
assert.match(source, /appliedMode.current === mode \? null : mode/);
|
||||
assert.doesNotMatch(source, /key=\{representation\}|setRetry\([^\n]*representation/);
|
||||
assert.match(source, /label="Версия облака"/);
|
||||
assert.match(source, /setAppliedRepresentation\(comparison \? representation/);
|
||||
assert.match(source, /setRepresentation\(data.default_representation/);
|
||||
assert.match(source, /const comparison = metadata\?\.comparison/);
|
||||
assert.match(source, /compareVersions && comparison/);
|
||||
const planner = await readFile(new URL('../src/components/missions/MissionZonePreview.tsx', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(planner, /compareVersions/);
|
||||
assert.match(planner, /reference_generation=\$\{encodeURIComponent\(generation\)\}/);
|
||||
const logic = await readFile(new URL('../src/core/missions/useMissionPlanner.ts', import.meta.url), 'utf8');
|
||||
assert.match(logic, /setPinnedGeneration\(next.zone.generation\)/);
|
||||
assert.match(logic, /setPinnedGeneration\(null\)/);
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { before, after, test } from 'node:test';
|
||||
import { createElement } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { createServer } from 'vite';
|
||||
|
||||
let server, SpatialScene;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
|
||||
({ SpatialScene } = await server.ssrLoadModule(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url).pathname));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
|
||||
viewportRef: { current: null }, primaryFocused: focused, toolbar: null,
|
||||
renderer: createElement('div', { 'data-testid': 'renderer' }, 'RENDERER'),
|
||||
sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'),
|
||||
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
|
||||
metrics: createElement('div', null, 'METRICS'),
|
||||
}));
|
||||
|
||||
test('scene tools, visual engine and metrics share one top-left flow in that order', () => {
|
||||
const markup = render();
|
||||
const stack = markup.indexOf('class="scene-information"');
|
||||
const controls = markup.indexOf('SOURCE_CONTROLS');
|
||||
const status = markup.indexOf('ВИЗУАЛЬНЫЙ ДВИЖОК');
|
||||
const metrics = markup.indexOf('METRICS');
|
||||
assert.ok(stack < controls && controls < status && status < metrics);
|
||||
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
|
||||
assert.doesNotMatch(markup, /scene-status--top-left/);
|
||||
});
|
||||
|
||||
test('focus exit remains viewport-owned outside the hidden information stack', () => {
|
||||
const markup = render(true);
|
||||
assert.ok(markup.indexOf('scene-focus-exit') < markup.indexOf('scene-information'));
|
||||
assert.match(markup, /class="scene-information" aria-hidden="true"/);
|
||||
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
|
||||
});
|
||||
|
||||
test('normal and expanded scenes retain the renderer without mouse-navigation copy', async () => {
|
||||
for (const focused of [false, true]) {
|
||||
const markup = render(focused);
|
||||
assert.match(markup, /data-testid="renderer">RENDERER/);
|
||||
assert.doesNotMatch(markup, /scene-navigation-hint|Навигация по 3D-сцене|ЛКМ|ПКМ|Колесо/);
|
||||
}
|
||||
const source = await readFile(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url), 'utf8');
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/observation.css', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /navigationReady|scene-navigation-hint/);
|
||||
assert.doesNotMatch(css, /scene-navigation-hint/);
|
||||
});
|
||||
|
||||
test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => {
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
|
||||
const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
|
||||
const calibration = await readFile(new URL('../../../plugins/xgrids-k1/frontend/src/components/K1SpatialSession.css', import.meta.url), 'utf8');
|
||||
assert.match(css, /\.scene-information \{[^}]*position: absolute;[^}]*display: grid;/);
|
||||
assert.match(css, /\.scene-information > \.scene-source-controls \{ position: static; pointer-events: auto;/);
|
||||
assert.match(css, /\.scene-information\[aria-hidden="true"\] \{ display: none;/);
|
||||
assert.doesNotMatch(css.match(/\.scene-metrics \{[^}]*\}/)?.[0] ?? '', /top:|right:|position: absolute/);
|
||||
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
|
||||
assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/);
|
||||
});
|
||||
|
||||
test('preparation status shares the source controls top axis on the opposite side', async () => {
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
|
||||
const stack = css.match(/\.scene-operation-status-stack \{[^}]*\}/)?.[0] ?? '';
|
||||
assert.match(stack, /top: 0\.85rem/);
|
||||
assert.match(stack, /right: 0\.85rem/);
|
||||
assert.match(stack, /min-height: 2\.75rem/);
|
||||
assert.match(stack, /align-content: center/);
|
||||
assert.doesNotMatch(stack, /left:|bottom:/);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import React, {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {readFileSync} from 'node:fs';
|
||||
import {before, after, test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
let server, resolve, awaitsCapture, Guard, Provider, Device, Host;
|
||||
before(async () => {
|
||||
server = await createServer({appType:'custom', logLevel:'silent', server:{middlewareMode:true}});
|
||||
({workspaceLaunchProfile:resolve} = await server.ssrLoadModule('/src/core/observation/workspaceLaunch.ts'));
|
||||
({planningAwaitsCapture:awaitsCapture, PlanningTestProvider:Provider} = await server.ssrLoadModule('/src/core/missions/PlanningTestContext.tsx'));
|
||||
({PlanningCaptureGuard:Guard} = await server.ssrLoadModule('/src/components/missions/PlanningCaptureGuard.tsx'));
|
||||
({DeviceWorkspace:Device} = await server.ssrLoadModule('/src/workspaces/DeviceWorkspace.tsx'));
|
||||
({DevicePluginHostProvider:Host} = await server.ssrLoadModule('/src/core/device-plugins/DevicePluginHost.tsx'));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
test('direct entry overrides an earlier planning profile for both shared surfaces', () => {
|
||||
for (const kind of ['device','spatial']) {
|
||||
assert.equal(resolve('planning',kind),'direct');
|
||||
assert.equal(resolve('direct',kind),'direct');
|
||||
assert.equal(resolve('direct',kind,'planning'),'planning');
|
||||
assert.equal(resolve('planning',kind,'planning'),'planning');
|
||||
}
|
||||
assert.equal(resolve('planning','missions'),'planning');
|
||||
assert.equal(resolve('direct','missions'),'direct');
|
||||
});
|
||||
|
||||
test('a prepared consumer cannot silently claim a direct capture; old/bound runs do not block it', () => {
|
||||
assert.equal(awaitsCapture(null),false);
|
||||
for (const state of ['completed','cancelled','interrupted','error']) {
|
||||
for (const query_session_id of [null,'recording']) assert.equal(awaitsCapture({state,query_session_id}),false);
|
||||
}
|
||||
for (const state of ['preparing','waiting','running']) {
|
||||
assert.equal(awaitsCapture({state,query_session_id:null}),true);
|
||||
assert.equal(awaitsCapture({state,query_session_id:'recording'}),false);
|
||||
}
|
||||
});
|
||||
|
||||
function withHooks(hooks, fn) {
|
||||
// Same bounded pre-effect harness used by observatoryHooks.test.mjs.
|
||||
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
|
||||
const previous = internals.H;
|
||||
internals.H = hooks;
|
||||
try { return fn(); } finally { internals.H = previous; }
|
||||
}
|
||||
|
||||
test('retained completed run renders the ordinary connection, without issuing any commands', () => {
|
||||
let calls = 0;
|
||||
const child = createElement('div',null,'ORDINARY DEVICE');
|
||||
const tree = withHooks({useContext:()=>({test:{state:'completed',query_session_id:'old'},finish:()=>{calls++;}})},
|
||||
() => Guard({enabled:true,onResume:()=>{calls++;},children:child}));
|
||||
assert.match(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
|
||||
assert.equal(calls,0);
|
||||
});
|
||||
|
||||
test('pending run handoff is explicit and only finishes the research, not the scanner', () => {
|
||||
let stopped = 0, resumed = 0;
|
||||
const context = {test:{state:'waiting',query_session_id:null,draft:{name:'PENDING'}},busy:false,error:null,finish:()=>{stopped++;}};
|
||||
const child = createElement('div',null,'ORDINARY DEVICE');
|
||||
const tree = withHooks({useContext:()=>context}, () => Guard({enabled:true,onResume:()=>{resumed++;},children:child}));
|
||||
assert.doesNotMatch(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
|
||||
assert.equal(stopped,0);
|
||||
const actions = tree.props.children.at(-1).props.children;
|
||||
actions[0].props.onClick(); assert.equal(stopped,1); assert.equal(resumed,0);
|
||||
actions[1].props.onClick(); assert.equal(resumed,1);
|
||||
const planningTree = withHooks({useContext:()=>context}, () => Guard({enabled:false,onResume:()=>{},children:child}));
|
||||
assert.match(renderToStaticMarkup(planningTree),/ORDINARY DEVICE/);
|
||||
});
|
||||
|
||||
test('generic device catalog renders without any PlanningTestProvider', () => {
|
||||
const html = renderToStaticMarkup(createElement(Host,{plugins:[]},
|
||||
createElement(Device,{onOpenSpatialScene:()=>{},onActivateAutomaticSpatialSource:()=>{}})));
|
||||
assert.match(html,/Выберите модель устройства/);
|
||||
assert.doesNotMatch(html,/Профиль · Планирование|Подключение сканера · Планирование/);
|
||||
});
|
||||
|
||||
test('failed planner selection does not grant a successful launch', async () => {
|
||||
const writes = [];
|
||||
const context = withHooks({
|
||||
useState:initial=>[typeof initial==='function'?initial():initial,value=>writes.push(value)],
|
||||
useRef:value=>({current:value}),useCallback:fn=>fn,useEffect:()=>{},
|
||||
}, () => Provider({children:null}).props.value);
|
||||
const originalFetch = globalThis.fetch;
|
||||
try {
|
||||
globalThis.fetch = async () => new Response(JSON.stringify({detail:'Выбранный проход недоступен'}),{status:409});
|
||||
assert.equal(await context.select('missing'),false);
|
||||
assert.ok(writes.includes('Выбранный проход недоступен'));
|
||||
assert.equal('selected' in context,false);
|
||||
assert.equal('resume' in context,false);
|
||||
} finally { globalThis.fetch = originalFetch; }
|
||||
});
|
||||
|
||||
test('composition owns explicit profile propagation; server polling cannot restore it', () => {
|
||||
const source = path => readFileSync(new URL('../src/'+path,import.meta.url),'utf8');
|
||||
assert.doesNotMatch(source('workspaces/DeviceWorkspace.tsx'),/Planning|missions\//);
|
||||
assert.doesNotMatch(source('core/missions/PlanningTestContext.tsx'),/dismissed|sessionStorage|launchProfile/);
|
||||
assert.match(source('App.tsx'),/useState<WorkspaceLaunchProfile>\('direct'\)/);
|
||||
assert.match(source('App.tsx'),/openView\("spatial-scene", 'planning'\)/);
|
||||
assert.match(source('App.tsx'),/openView\("spatial-scene", 'direct'\)/);
|
||||
assert.match(source('workspaces/spatial/SpatialWorkspace.tsx'),/openView\("spatial-scene", launchProfile\)/);
|
||||
assert.match(source('workspaces/Workspaces.tsx'),/props\.launchProfile === 'planning'/);
|
||||
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/openView\('local-device','planning'\)/);
|
||||
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/if\(await live.select\(project.id\)\)openView/);
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,25 @@
|
||||
Copyright (c) 2022 Rerun Technologies AB <opensource@rerun.io>
|
||||
|
||||
Permission is hereby granted, free of charge, to any
|
||||
person obtaining a copy of this software and associated
|
||||
documentation files (the "Software"), to deal in the
|
||||
Software without restriction, including without
|
||||
limitation the rights to use, copy, modify, merge,
|
||||
publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software
|
||||
is furnished to do so, subject to the following
|
||||
conditions:
|
||||
|
||||
The above copyright notice and this permission notice
|
||||
shall be included in all copies or substantial portions
|
||||
of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||
DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,361 @@
|
||||
--- a/crates/viewer/re_view_spatial/src/eye.rs
|
||||
+++ b/crates/viewer/re_view_spatial/src/eye.rs
|
||||
@@ -2,7 +2,7 @@
|
||||
use glam::{Mat4, Quat, Vec3, vec3};
|
||||
use macaw::IsoTransform;
|
||||
use re_log_types::EntityPath;
|
||||
-use re_sdk_types::blueprint::archetypes::EyeControls3D;
|
||||
+use re_sdk_types::blueprint::archetypes::{EyeControls3D, LineGrid3D};
|
||||
use re_sdk_types::blueprint::components::{AngularSpeed, Eye3DKind};
|
||||
use re_sdk_types::components::{LinearSpeed, Position3D, Vector3D};
|
||||
use re_view::controls::{
|
||||
@@ -214,6 +214,8 @@
|
||||
pub last_look_target: Option<Vec3>,
|
||||
pub last_orbit_radius: Option<f32>,
|
||||
pub last_eye_up: Option<Vec3>,
|
||||
+ /// NODE.DC: time of the last rendered eye, for the read-only WebViewer snapshot.
|
||||
+ pub last_render_time: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, re_byte_size::SizeBytes)]
|
||||
@@ -241,6 +243,7 @@
|
||||
fov_y: Option<f32>,
|
||||
|
||||
did_interact: bool,
|
||||
+ grid_plane: macaw::Plane3,
|
||||
}
|
||||
|
||||
impl EyeController {
|
||||
@@ -250,16 +253,8 @@
|
||||
/// Avoids breaking the view by zooming in too far.
|
||||
pub const MIN_ORBIT_DISTANCE: f32 = Eye::PERSPECTIVE_NEAR_PLANE * 2.0;
|
||||
|
||||
- /// Cap on the orbital camera radius, as a multiple of the scene bounding box diagonal.
|
||||
- ///
|
||||
- /// Only applied when scroll-to-zoom wants to grow the radius further. Zoom-in, rotate,
|
||||
- /// pan, WASD, and every other form of motion are left alone — this is intentionally a
|
||||
- /// local restriction on orbital zoom-out only, not a general movement envelope. The 2D
|
||||
- /// view has its own, separate zoom-out cap (see `ui_2d::MAX_ZOOM_OUT_FACTOR`).
|
||||
- ///
|
||||
- /// If the radius already exceeds this (e.g. right after loading) the current radius is
|
||||
- /// used as the cap instead, so the camera isn't pulled back in.
|
||||
- const MAX_ORBITAL_ZOOM_OUT_FACTOR: f32 = 5.0;
|
||||
+ // NODE.DC: finite arithmetic guard only, independent of route/scene bounds.
|
||||
+ const MAX_ORBITAL_RADIUS: f32 = 1.0e17;
|
||||
|
||||
fn get_eye(&self) -> Eye {
|
||||
Eye {
|
||||
@@ -317,6 +312,10 @@
|
||||
.0,
|
||||
);
|
||||
|
||||
+ let grid = ViewProperty::from_archetype::<LineGrid3D>(ctx);
|
||||
+ let grid_plane = grid.component_or_fallback::<re_sdk_types::components::Plane3D>(
|
||||
+ ctx, LineGrid3D::descriptor_plane().component,
|
||||
+ )?;
|
||||
Ok(Self {
|
||||
pos,
|
||||
look_target,
|
||||
@@ -325,6 +324,7 @@
|
||||
eye_up,
|
||||
did_interact: false,
|
||||
fov_y,
|
||||
+ grid_plane: grid_plane.into(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -482,12 +482,45 @@
|
||||
let up = rot * Vec3::Y;
|
||||
let right = rot * -Vec3::X; // TODO(emilk): why do we need a negation here? O.o
|
||||
|
||||
+ // Mission Core's map grid is XY. Screen-plane pan otherwise lifts the
|
||||
+ // orbit target into the air, leaving scroll-to-target stranded there.
|
||||
+ // First-person navigation intentionally retains upstream 3D movement.
|
||||
+ let (right, up) = if self.kind == Eye3DKind::Orbital {
|
||||
+ let normal = self.grid_plane.normal;
|
||||
+ let project = |v: Vec3| v - normal * normal.dot(v);
|
||||
+ let right = project(right).normalize_or(project(Vec3::X).normalize_or(Vec3::Y));
|
||||
+ let up = project(up).normalize_or(normal.cross(-right));
|
||||
+ (right, up)
|
||||
+ } else {
|
||||
+ (right, up)
|
||||
+ };
|
||||
let translate = delta_in_view.x * right + delta_in_view.y * up;
|
||||
|
||||
self.pos += translate;
|
||||
self.look_target += translate;
|
||||
}
|
||||
|
||||
+ /// Translate the whole rig, not its orientation/radius, onto the grid.
|
||||
+ /// Called only by deliberate navigation while not following an entity.
|
||||
+ fn anchor_orbit_to_grid(&mut self) {
|
||||
+ if self.kind == Eye3DKind::Orbital {
|
||||
+ let correction = self.grid_plane.normal
|
||||
+ * (self.grid_plane.d - self.grid_plane.normal.dot(self.look_target));
|
||||
+ self.pos += correction;
|
||||
+ self.look_target += correction;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ fn zoom_orbit(&mut self, zoom_factor: f32) {
|
||||
+ if !zoom_factor.is_finite() || zoom_factor <= 0.0 {
|
||||
+ return;
|
||||
+ }
|
||||
+ let new_radius = (self.radius() / zoom_factor)
|
||||
+ .clamp(Self::MIN_ORBIT_DISTANCE, Self::MAX_ORBITAL_RADIUS);
|
||||
+ self.pos = self.look_target - self.fwd() * new_radius;
|
||||
+ self.did_interact = true;
|
||||
+ }
|
||||
+
|
||||
fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) {
|
||||
if response.drag_delta().length() > drag_threshold {
|
||||
let roll = response.dragged_by(ROLL_MOUSE)
|
||||
@@ -513,7 +546,7 @@
|
||||
}
|
||||
|
||||
/// Handle zoom/scroll input.
|
||||
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
|
||||
+ fn handle_zoom(&mut self, egui_ctx: &egui::Context, _scene_bounding_box: &macaw::BoundingBox) {
|
||||
let zoom_factor = egui_ctx.input(|input| {
|
||||
// egui's default horizontal_scroll_modifier is shift, which is also our speed-up modifier.
|
||||
// This means that a user who wants to speed up scroll-to-zoom will generate a horizontal scroll delta.
|
||||
@@ -528,22 +561,7 @@
|
||||
|
||||
match self.kind {
|
||||
Eye3DKind::Orbital => {
|
||||
- let radius = self.pos.distance(self.look_target);
|
||||
-
|
||||
- // Cap zoom-out against the scene bounding box. If we're already past the cap
|
||||
- // (e.g. right after loading) use the current radius instead — no snap-back.
|
||||
- let max_radius = max_orbital_radius(scene_bounding_box).max(radius);
|
||||
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
|
||||
-
|
||||
- // The user may be scrolling to move the camera closer, but are not realizing
|
||||
- // the radius is now tiny.
|
||||
- // TODO(emilk): inform the users somehow that scrolling won't help, and that they should use WSAD instead.
|
||||
- // It might be tempting to start moving the camera here on scroll, but that would is bad for other reasons.
|
||||
-
|
||||
- if f32::MIN_POSITIVE < new_radius {
|
||||
- self.pos = self.look_target - self.fwd() * new_radius;
|
||||
- self.did_interact = true;
|
||||
- }
|
||||
+ self.zoom_orbit(zoom_factor);
|
||||
}
|
||||
Eye3DKind::FirstPerson => {
|
||||
// Move along the forward axis when zooming in first person mode.
|
||||
@@ -707,25 +725,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
-/// Cap on the orbital zoom-out radius, derived from the scene bounding box diagonal.
|
||||
-///
|
||||
-/// Returns `1.0e17` (a large fallback that avoids infinities downstream) when no usable scene
|
||||
-/// bounding box is available.
|
||||
-fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 {
|
||||
- // `1.0e17` fallback is chosen with generous margin of an observed crash due to infinity.
|
||||
- let fallback = 1.0e17;
|
||||
-
|
||||
- if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
|
||||
- return fallback;
|
||||
- }
|
||||
- let scene_diagonal = scene_bounding_box.size().length();
|
||||
- if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
|
||||
- return fallback;
|
||||
- }
|
||||
- (scene_diagonal * EyeController::MAX_ORBITAL_ZOOM_OUT_FACTOR)
|
||||
- .max(EyeController::MIN_ORBIT_DISTANCE)
|
||||
-}
|
||||
-
|
||||
pub fn find_camera(cameras: &[PinholeWrapper], needle: &EntityPath) -> Option<Eye> {
|
||||
let mut found_camera = None;
|
||||
|
||||
@@ -744,6 +743,103 @@
|
||||
|
||||
fn ease_out(t: f32) -> f32 {
|
||||
1. - (1. - t) * (1. - t)
|
||||
+}
|
||||
+
|
||||
+#[cfg(test)]
|
||||
+mod nodedc_navigation_tests {
|
||||
+ use super::*;
|
||||
+
|
||||
+ fn controller() -> EyeController {
|
||||
+ EyeController {
|
||||
+ pos: vec3(305.0, -80.0, 22.0),
|
||||
+ look_target: vec3(290.0, -60.0, 4.0),
|
||||
+ kind: Eye3DKind::Orbital,
|
||||
+ speed: 30.0,
|
||||
+ eye_up: Vec3::Z,
|
||||
+ fov_y: Some(Eye::DEFAULT_FOV_Y),
|
||||
+ did_interact: false,
|
||||
+ grid_plane: macaw::Plane3 { normal: Vec3::Z, d: 0.0 },
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn pan_is_on_grid_and_moves_the_whole_rig() {
|
||||
+ let mut c = controller();
|
||||
+ let offset = c.pos - c.look_target;
|
||||
+ c.anchor_orbit_to_grid();
|
||||
+ for _ in 0..100 {
|
||||
+ c.translate(egui::vec2(0.5, 0.7));
|
||||
+ }
|
||||
+ assert_eq!(c.look_target.z, 0.0);
|
||||
+ assert!((c.pos - c.look_target - offset).length() < 0.001);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn rotation_keeps_selected_pivot_and_radius() {
|
||||
+ let mut c = controller();
|
||||
+ c.anchor_orbit_to_grid();
|
||||
+ let target = c.look_target;
|
||||
+ let radius = c.radius();
|
||||
+ for _ in 0..200 {
|
||||
+ c.rotate(egui::vec2(2.0, 0.1));
|
||||
+ }
|
||||
+ assert_eq!(c.look_target, target);
|
||||
+ assert!((c.radius() - radius).abs() < 0.01);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn zoom_passes_map_scale_without_moving_pivot() {
|
||||
+ let mut c = controller();
|
||||
+ c.anchor_orbit_to_grid();
|
||||
+ let target = c.look_target;
|
||||
+ c.zoom_orbit(0.001);
|
||||
+ assert!(c.radius() > 20_000.0);
|
||||
+ for _ in 0..12 {
|
||||
+ c.zoom_orbit(4.0);
|
||||
+ }
|
||||
+ assert!(c.radius() < 0.03);
|
||||
+ assert_eq!(c.look_target, target);
|
||||
+ assert!(c.get_eye().world_from_rub_view.translation().is_finite());
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn vertical_view_pan_stays_finite_on_grid() {
|
||||
+ let mut c = controller();
|
||||
+ c.pos = vec3(0.0, 0.0, 30.0);
|
||||
+ c.look_target = Vec3::ZERO;
|
||||
+ c.eye_up = Vec3::Y;
|
||||
+ c.translate(egui::vec2(1.0, 1.0));
|
||||
+ assert_eq!(c.look_target.z, 0.0);
|
||||
+ assert!(c.pos.is_finite());
|
||||
+ assert!(c.look_target.length() > 1.0);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn navigation_respects_the_actual_grid_plane() {
|
||||
+ let mut c = controller();
|
||||
+ c.grid_plane = macaw::Plane3 { normal: Vec3::Y, d: 3.0 };
|
||||
+ c.anchor_orbit_to_grid();
|
||||
+ let target = c.look_target;
|
||||
+ c.translate(egui::vec2(2.0, 4.0));
|
||||
+ c.rotate(egui::vec2(10.0, 3.0));
|
||||
+ assert_eq!(c.look_target.y, 3.0);
|
||||
+ assert_ne!(c.look_target, target);
|
||||
+ }
|
||||
+
|
||||
+ #[test]
|
||||
+ fn invalid_zoom_is_ignored_and_first_person_is_unchanged() {
|
||||
+ let mut c = controller();
|
||||
+ let before = c.pos;
|
||||
+ for factor in [0.0, -1.0, f32::NAN, f32::INFINITY] {
|
||||
+ c.zoom_orbit(factor);
|
||||
+ assert_eq!(c.pos, before);
|
||||
+ }
|
||||
+ c.kind = Eye3DKind::FirstPerson;
|
||||
+ c.anchor_orbit_to_grid();
|
||||
+ assert_eq!(c.look_target.z, 4.0);
|
||||
+ c.translate(egui::vec2(0.0, 1.0));
|
||||
+ assert_ne!(c.look_target.z, 4.0);
|
||||
+ }
|
||||
}
|
||||
|
||||
impl EyeState {
|
||||
@@ -804,9 +900,32 @@
|
||||
}
|
||||
}
|
||||
|
||||
+ // Anchor before the first paint, not on the first orbit drag. This
|
||||
+ // avoids a one-off camera jump when the scene-bounds fallback is high.
|
||||
+ if tracking_entity.is_none() && eye_controller.kind == Eye3DKind::Orbital
|
||||
+ && (eye_controller.grid_plane.normal.dot(eye_controller.look_target)
|
||||
+ - eye_controller.grid_plane.d).abs() > 1.0e-5
|
||||
+ {
|
||||
+ eye_controller.anchor_orbit_to_grid();
|
||||
+ eye_controller.did_interact = true;
|
||||
+ }
|
||||
+
|
||||
// Handle spinning before inputs because some inputs depend on view direction.
|
||||
self.handle_spinning(ctx, eye_property, &mut eye_controller)?;
|
||||
|
||||
+ // NODE.DC: pin manual map navigation to the XY grid. Tracking and
|
||||
+ // explicit camera presets retain their own target until manual input.
|
||||
+ let manual_input = response.drag_delta().length_sq() > 0.0
|
||||
+ || (response.hovered() && response.ctx.input(|i| {
|
||||
+ i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() != 1.0
|
||||
+ }));
|
||||
+ if manual_input {
|
||||
+ self.stop_interpolation();
|
||||
+ if tracking_entity.is_none() {
|
||||
+ eye_controller.anchor_orbit_to_grid();
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
// We do input before tracking entity, because the input can cause the eye
|
||||
// to stop tracking.
|
||||
let gamepad_navigation_status = eye_controller.handle_input(
|
||||
@@ -1241,6 +1360,7 @@
|
||||
};
|
||||
|
||||
self.last_eye = Some(eye);
|
||||
+ self.last_render_time = ctx.egui_ctx().time();
|
||||
|
||||
Ok(eye)
|
||||
}
|
||||
--- a/crates/viewer/re_viewer_context/src/view/view_states.rs
|
||||
+++ b/crates/viewer/re_viewer_context/src/view/view_states.rs
|
||||
@@ -173,6 +173,13 @@
|
||||
}
|
||||
|
||||
impl ViewStates {
|
||||
+ /// NODE.DC: read-only per-recording state access for native camera snapshots.
|
||||
+ pub fn states_for_store<'a>(&'a self, store_id: &'a StoreId) -> impl Iterator<Item = &'a dyn ViewState> {
|
||||
+ self.states.iter().filter_map(move |((id, _), state)| {
|
||||
+ (id == store_id).then_some(state.as_ref())
|
||||
+ })
|
||||
+ }
|
||||
+
|
||||
pub fn get(&self, store_id: &StoreId, view_id: ViewId) -> Option<&dyn ViewState> {
|
||||
self.states
|
||||
.get(&(store_id.clone(), view_id))
|
||||
--- a/crates/viewer/re_viewer/src/web.rs
|
||||
+++ b/crates/viewer/re_viewer/src/web.rs
|
||||
@@ -384,6 +384,26 @@
|
||||
let recording = hub.entity_db(recording_id)?;
|
||||
|
||||
Some(recording.store_id().recording_id().to_string())
|
||||
+ }
|
||||
+
|
||||
+ /// NODE.DC navigation/v1. Last rendered native eye, never a replay of DOM deltas.
|
||||
+ /// Each Mission Core iframe admits one active 3D pane. Timestamp selection
|
||||
+ /// excludes states retained for a previously selected/reset pane.
|
||||
+ #[wasm_bindgen]
|
||||
+ pub fn nodedc_camera_eye(&self) -> Option<String> {
|
||||
+ let app = self.runner.app_mut::<crate::App>()?;
|
||||
+ let store_id = app.active_recording_id()?;
|
||||
+ let eye_state = app.state.view_states.states_for_store(store_id)
|
||||
+ .filter_map(|state| state.as_any().downcast_ref::<re_view_spatial::SpatialViewState>())
|
||||
+ .map(|state| &state.state_3d.eye_state)
|
||||
+ .filter(|state| state.last_eye.is_some() && state.last_look_target.is_some())
|
||||
+ .max_by(|a, b| a.last_render_time.total_cmp(&b.last_render_time))?;
|
||||
+ let eye = eye_state.last_eye?;
|
||||
+ Some(serde_json::json!({
|
||||
+ "position": eye.pos_in_world().to_array(),
|
||||
+ "lookTarget": eye_state.last_look_target?.to_array(),
|
||||
+ "eyeUp": eye_state.last_eye_up?.to_array(),
|
||||
+ }).to_string())
|
||||
}
|
||||
|
||||
//TODO(#10737): we should refer to logical recordings using store id (recording id is ambiguous)
|
||||
@@ -0,0 +1,44 @@
|
||||
# Rerun 0.36.3 · NODE.DC navigation/v1
|
||||
|
||||
This is the bounded native camera amendment in ADR 0052, not the archived
|
||||
0.34.1 renderer fork. `navigation-build.json` binds the exact upstream commit,
|
||||
portable source patch and paired generated runtime. Keep the upstream licenses.
|
||||
|
||||
## Rebuild
|
||||
|
||||
1. Download the source archive for the manifest's `upstreamCommit` from
|
||||
`https://codeload.github.com/rerun-io/rerun/tar.gz/<commit>` and verify its hash.
|
||||
2. Extract into a new temporary directory and run `git apply --check` followed
|
||||
by `git apply` with `NODEDC_NAVIGATION.patch` at the extracted root.
|
||||
3. Pack that root as `patched-source.tar.gz`. On macOS use
|
||||
`COPYFILE_DISABLE=1 tar --no-mac-metadata --exclude='._*'`; AppleDouble files
|
||||
are not shaders and must not enter the build.
|
||||
4. On Worker006, create a temporary container from the pinned Rust image in
|
||||
the manifest, with 4 CPUs, 16 GB memory, no GPU, no ports and no private data.
|
||||
Use the `ndc-` name and NODE.DC ownership labels required by AGENTS.md.
|
||||
Copy the archive to `/work/patched-source.tar.gz` and the repository's
|
||||
`scripts/build-rerun-navigation.sh` to `/work/build-rerun-navigation.sh`.
|
||||
Entrypoint: `bash /work/build-rerun-navigation.sh`.
|
||||
5. Require the native navigation tests to pass. The upstream
|
||||
`rerun_js/web-viewer/build-wasm.mjs --mode release` produces the matching
|
||||
`re_viewer.js`, `re_viewer.d.ts` and `re_viewer_bg.wasm`. Copy all three,
|
||||
never only WASM, to the artifact names in this directory's manifest.
|
||||
6. Recheck source patch, build script and generated file hashes. Update the
|
||||
manifest, run the installer twice (idempotency), vendor ABI checks, frontend
|
||||
contracts/build, then real product-browser QA. Remove the temporary build
|
||||
container after copying artifacts and evidence.
|
||||
|
||||
The source archive and build cache are not runtime dependencies. No compiler,
|
||||
Docker or hardware access is needed on a clean operator install: the existing
|
||||
exact npm package plus `postinstall` installs the audited paired artifacts.
|
||||
An unfamiliar package or artifact is rejected before any file is changed.
|
||||
|
||||
## Upgrade / rollback
|
||||
|
||||
Rebase against the next exact upstream source, not against minified JS. Retest
|
||||
grid-plane pan, fixed-pivot orbit, close/far zoom, explicit reset/top/follow,
|
||||
display updates, normal/expanded views and disposable-iframe cleanup.
|
||||
|
||||
To roll back, disable `postinstall`/`prebuild`, restore the official exact npm
|
||||
package and rebuild. Missing snapshots are represented as null, not simulated
|
||||
camera state. Recordings, corrected maps and scanner sessions are unchanged.
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"schema": "nodedc.rerun-navigation/v1",
|
||||
"upstreamVersion": "0.36.3",
|
||||
"upstreamCommit": "6ded109d33c549e98185f7c95fa8009d44e4adef",
|
||||
"upstreamArchiveSha256": "3f92aef3f33f63c79b275b5c66a65d73192e75967cbcd3d1f16c75010996f896",
|
||||
"patchSha256": "a893a4b4e7523cca88284316c88b930c3b91ba973ed3f954c9ca632f71252e64",
|
||||
"cargoLockSha256": "74cdf9c3c8c5dbe5a368f1a9a58b011e2a45720843b30e552bc7b1d8ac21d791",
|
||||
"buildScriptSha256": "e3cbd5da889403eda3ffba7c020b6075bdfb025b823d1bf503b639f59688135a",
|
||||
"buildDate": "2026-09-21",
|
||||
"rustImage": "rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1",
|
||||
"binaryenVersion": "117",
|
||||
"binaryenArchiveSha256": "3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212",
|
||||
"wasmBindgenVersion": "0.2.126",
|
||||
"nativeTestsPassed": 6,
|
||||
"files": {
|
||||
"index.js": { "upstreamSha256": "009aa5f66c8674ea268a91926c00e2f82cc7a7b7e5535056d93bd4656d6f6812" },
|
||||
"index.ts": { "upstreamSha256": "087b25170f720a28aa69c411903133286238900050ac775d2cc511b919846696" },
|
||||
"index.d.ts": { "upstreamSha256": "649637dad4e50ef4f2310fc1920b580d16832226a0dda5238355059bf167efba" },
|
||||
"re_viewer.js": {
|
||||
"upstreamSha256": "d2a94836dd0e6de40538c4d657cfb423b1803e354e6ee0cd550735eb5aaab09b",
|
||||
"artifact": "re_viewer.nodedc.js",
|
||||
"sha256": "610d89abffcc5c329797e77794ae2ab5b426c80a5b23fd24064c7d47c6583d3a"
|
||||
},
|
||||
"re_viewer.d.ts": {
|
||||
"upstreamSha256": "9a77a4e1e1aa8311185a3f275bf326a8fff2844e468e561c59d5f0ee7aa6debe",
|
||||
"artifact": "re_viewer.nodedc.d.ts",
|
||||
"sha256": "cfbf010d66560af919aa68d255a6ddc7120829f8ee8f27a41f56d943cd54e598"
|
||||
},
|
||||
"re_viewer_bg.wasm": {
|
||||
"upstreamSha256": "02d6d9c3a569d3faceb01cf89104342d970fc85e7c31a08dedd357b6c7104d2f",
|
||||
"artifact": "re_viewer_bg.nodedc.wasm",
|
||||
"sha256": "2b661de545ac90bb950eda1a28fc5a7c3ce4ca7f5bb24894789acf7589495185"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
|
||||
declare namespace wasm_bindgen {
|
||||
/* tslint:disable */
|
||||
/* eslint-disable */
|
||||
/**
|
||||
* The `ReadableStreamType` enum.
|
||||
*
|
||||
* *This API requires the following crate features to be activated: `ReadableStreamType`*
|
||||
*/
|
||||
|
||||
export type ReadableStreamType = "bytes";
|
||||
|
||||
export class IntoUnderlyingByteSource {
|
||||
private constructor();
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
cancel(): void;
|
||||
pull(controller: ReadableByteStreamController): Promise<any>;
|
||||
start(controller: ReadableByteStreamController): void;
|
||||
readonly autoAllocateChunkSize: number;
|
||||
readonly type: ReadableStreamType;
|
||||
}
|
||||
|
||||
export class IntoUnderlyingSink {
|
||||
private constructor();
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
abort(reason: any): Promise<any>;
|
||||
close(): Promise<any>;
|
||||
write(chunk: any): Promise<any>;
|
||||
}
|
||||
|
||||
export class IntoUnderlyingSource {
|
||||
private constructor();
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
cancel(): void;
|
||||
pull(controller: ReadableStreamDefaultController): Promise<any>;
|
||||
}
|
||||
|
||||
export class WebHandle {
|
||||
free(): void;
|
||||
[Symbol.dispose](): void;
|
||||
/**
|
||||
* Add a new receiver streaming data from the given url.
|
||||
*
|
||||
* Websocket streams are always opened in `Following` mode.
|
||||
*
|
||||
* It is an error to open a channel twice with the same id.
|
||||
*/
|
||||
add_receiver(url: string): void;
|
||||
/**
|
||||
* Close an existing channel for streaming data.
|
||||
*
|
||||
* No-op if the channel is already closed.
|
||||
*/
|
||||
close_channel(id: string): void;
|
||||
destroy(): void;
|
||||
get_active_recording_id(): string | undefined;
|
||||
get_active_timeline(recording_id: string): string | undefined;
|
||||
get_playing(recording_id: string): boolean | undefined;
|
||||
get_time_for_timeline(recording_id: string, timeline_name: string): number | undefined;
|
||||
get_timeline_time_range(recording_id: string, timeline_name: string): any;
|
||||
has_panicked(): boolean;
|
||||
constructor(app_options: any);
|
||||
/**
|
||||
* NODE.DC navigation/v1. Last rendered native eye, never a replay of DOM deltas.
|
||||
* Each Mission Core iframe admits one active 3D pane. Timestamp selection
|
||||
* excludes states retained for a previously selected/reset pane.
|
||||
*/
|
||||
nodedc_camera_eye(): string | undefined;
|
||||
/**
|
||||
* Open a new channel for streaming data.
|
||||
*
|
||||
* It is an error to open a channel twice with the same id.
|
||||
*/
|
||||
open_channel(id: string, channel_name: string): void;
|
||||
override_panel_state(panel: string, state?: string | null): void;
|
||||
panic_callstack(): string | undefined;
|
||||
panic_message(): string | undefined;
|
||||
remove_receiver(url: string): void;
|
||||
/**
|
||||
* Add an rrd to the viewer directly from a byte array.
|
||||
*/
|
||||
send_rrd_to_channel(id: string, data: Uint8Array): void;
|
||||
send_table_to_channel(id: string, data: Uint8Array): void;
|
||||
set_active_recording_id(recording_id: string): void;
|
||||
/**
|
||||
* Set the active timeline.
|
||||
*
|
||||
* This does nothing if the timeline can't be found.
|
||||
*/
|
||||
set_active_timeline(recording_id: string, timeline_name: string): void;
|
||||
set_credentials(access_token: string, email: string): void;
|
||||
set_playing(recording_id: string, value: boolean): void;
|
||||
set_time_for_timeline(recording_id: string, timeline_name: string, time: number): void;
|
||||
start(canvas: any): Promise<void>;
|
||||
toggle_panel_overrides(value?: boolean | null): void;
|
||||
}
|
||||
|
||||
}
|
||||
declare type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
|
||||
|
||||
declare interface InitOutput {
|
||||
readonly memory: WebAssembly.Memory;
|
||||
readonly __wbg_webhandle_free: (a: number, b: number) => void;
|
||||
readonly webhandle_add_receiver: (a: number, b: number, c: number) => void;
|
||||
readonly webhandle_close_channel: (a: number, b: number, c: number) => void;
|
||||
readonly webhandle_destroy: (a: number) => void;
|
||||
readonly webhandle_get_active_recording_id: (a: number) => [number, number];
|
||||
readonly webhandle_get_active_timeline: (a: number, b: number, c: number) => [number, number];
|
||||
readonly webhandle_get_playing: (a: number, b: number, c: number) => number;
|
||||
readonly webhandle_get_time_for_timeline: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
||||
readonly webhandle_get_timeline_time_range: (a: number, b: number, c: number, d: number, e: number) => any;
|
||||
readonly webhandle_has_panicked: (a: number) => number;
|
||||
readonly webhandle_new: (a: any) => [number, number, number];
|
||||
readonly webhandle_nodedc_camera_eye: (a: number) => [number, number];
|
||||
readonly webhandle_open_channel: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly webhandle_override_panel_state: (a: number, b: number, c: number, d: number, e: number) => [number, number];
|
||||
readonly webhandle_panic_callstack: (a: number) => [number, number];
|
||||
readonly webhandle_panic_message: (a: number) => [number, number];
|
||||
readonly webhandle_remove_receiver: (a: number, b: number, c: number) => void;
|
||||
readonly webhandle_send_rrd_to_channel: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly webhandle_send_table_to_channel: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly webhandle_set_active_recording_id: (a: number, b: number, c: number) => void;
|
||||
readonly webhandle_set_active_timeline: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly webhandle_set_credentials: (a: number, b: number, c: number, d: number, e: number) => void;
|
||||
readonly webhandle_set_playing: (a: number, b: number, c: number, d: number) => void;
|
||||
readonly webhandle_set_time_for_timeline: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
|
||||
readonly webhandle_start: (a: number, b: any) => any;
|
||||
readonly webhandle_toggle_panel_overrides: (a: number, b: number) => void;
|
||||
readonly rust_lz4_wasm_shim_calloc: (a: number, b: number) => number;
|
||||
readonly rust_lz4_wasm_shim_free: (a: number) => void;
|
||||
readonly rust_lz4_wasm_shim_malloc: (a: number) => number;
|
||||
readonly rust_lz4_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
|
||||
readonly rust_lz4_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
|
||||
readonly rust_lz4_wasm_shim_memmove: (a: number, b: number, c: number) => number;
|
||||
readonly rust_lz4_wasm_shim_memset: (a: number, b: number, c: number) => number;
|
||||
readonly rust_zstd_wasm_shim_calloc: (a: number, b: number) => number;
|
||||
readonly rust_zstd_wasm_shim_free: (a: number) => void;
|
||||
readonly rust_zstd_wasm_shim_malloc: (a: number) => number;
|
||||
readonly rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
|
||||
readonly rust_zstd_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
|
||||
readonly rust_zstd_wasm_shim_memmove: (a: number, b: number, c: number) => number;
|
||||
readonly rust_zstd_wasm_shim_memset: (a: number, b: number, c: number) => number;
|
||||
readonly rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
|
||||
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
|
||||
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
|
||||
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
|
||||
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
|
||||
readonly intounderlyingbytesource_cancel: (a: number) => void;
|
||||
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
|
||||
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
|
||||
readonly intounderlyingbytesource_type: (a: number) => number;
|
||||
readonly intounderlyingsink_abort: (a: number, b: any) => any;
|
||||
readonly intounderlyingsink_close: (a: number) => any;
|
||||
readonly intounderlyingsink_write: (a: number, b: any) => any;
|
||||
readonly intounderlyingsource_cancel: (a: number) => void;
|
||||
readonly intounderlyingsource_pull: (a: number, b: any) => any;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__ha28703b0fc0ac5f5: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hd1708d5debff0eb7: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4_10: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4_11: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h68d110bc138a9729: (a: number, b: number, c: any) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h4a090e5af75dc439: (a: number, b: number, c: any, d: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h72e0675ea71ceaf9: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h76e373640bdbaace: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h912ec1a6c04fd19b: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h76e373640bdbaace_4: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__ha366fcce789d0db1: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h3d976baa4adbebda: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h912ec1a6c04fd19b_9: (a: number, b: number, c: any) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__hbf8a291ae3f8f46d: (a: number, b: number) => [number, number];
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h0e2ab714131e0149: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h2a38a854c15eed3d: (a: number, b: number) => void;
|
||||
readonly wasm_bindgen__convert__closures_____invoke__h46084f6dced1097a: (a: number, b: number) => void;
|
||||
readonly __wbindgen_malloc: (a: number, b: number) => number;
|
||||
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
|
||||
readonly __externref_table_alloc: () => number;
|
||||
readonly __wbindgen_externrefs: WebAssembly.Table;
|
||||
readonly __wbindgen_exn_store: (a: number) => void;
|
||||
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
|
||||
readonly __wbindgen_destroy_closure: (a: number, b: number) => void;
|
||||
readonly __externref_table_dealloc: (a: number) => void;
|
||||
readonly __wbindgen_start: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
|
||||
* for everything else, calls `WebAssembly.instantiate` directly.
|
||||
*
|
||||
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
|
||||
*
|
||||
* @returns {Promise<InitOutput>}
|
||||
*/
|
||||
declare function wasm_bindgen (module_or_path: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
|
||||
|
||||
export type WebHandle = wasm_bindgen.WebHandle;
|
||||
export default function(): wasm_bindgen;
|
||||
File diff suppressed because it is too large
Load Diff
BIN
Binary file not shown.
@@ -6,9 +6,11 @@ Status: accepted, 2026-07-26
|
||||
|
||||
Mission Core has two different catalogs over related evidence:
|
||||
|
||||
- **Data → Sessions and records** contains only original physical captures.
|
||||
- **Data → Sessions and records** contains independent original physical captures.
|
||||
Examples: `RAVNOVES00`, `TEST007`, `TEST009`. A source record is immutable
|
||||
evidence received from a device or recording adapter.
|
||||
evidence received from a device or recording adapter. Per the 2026-09-21
|
||||
owner decision, physical passes acquired by the planner are shown with their
|
||||
studies in **LAB → Planner**, not in Data or the reference selector.
|
||||
- **Test contour → Laboratory contours** contains derived experimental runs.
|
||||
Examples: LAB E24, E25, E26, E28 and E29. A LAB run references an original
|
||||
record and never becomes another original capture.
|
||||
@@ -16,7 +18,10 @@ Mission Core has two different catalogs over related evidence:
|
||||
The backend may keep both entities in one durable SQLite catalog, but every
|
||||
consumer must request an explicit catalog scope:
|
||||
|
||||
- `scope=source` for original records;
|
||||
- `scope=standalone` for Data and planner reference choices: original records
|
||||
excluding explicit planner-acquisition bindings;
|
||||
- `scope=source` for all original records, including planner passes needed for
|
||||
recorded comparisons and scientific inspection;
|
||||
- `scope=laboratory` for LAB projections;
|
||||
- `scope=all` only for internal joins that must resolve both a derivative and
|
||||
its source.
|
||||
@@ -24,6 +29,12 @@ consumer must request an explicit catalog scope:
|
||||
Deleting, renaming or moving source payloads to make the UI look clean is
|
||||
forbidden. Product separation is expressed by typed projections.
|
||||
|
||||
A planner pass remains a physical source, not a synthetic LAB projection.
|
||||
Classification uses exact run/session acquisition bindings, never names or
|
||||
registration success. Hiding it from Data does not remove its raw evidence or
|
||||
its historical planning report. See
|
||||
[the full-reference/catalog audit](audits/2026-09-21-whole-reference-and-capture-catalogs.md).
|
||||
|
||||
## Current reference-source policy
|
||||
|
||||
RAVNOVES00 is the sole active physical reference source for the current
|
||||
|
||||
@@ -113,6 +113,45 @@ Owns shell-level orchestration: selected root/workspace, global panels, runtime
|
||||
providers, and passing typed controllers to a workspace. It must not absorb
|
||||
domain API calls, per-LAB renderers, or new visual primitives.
|
||||
|
||||
## Shared LAB launch profiles — 2026-09-21
|
||||
|
||||
Test devices and Spatial scene are shared infrastructure, not children of the
|
||||
planner. `DeviceWorkspace` imports only the generic device host and its UI
|
||||
contracts; it must render without a `PlanningTestProvider`.
|
||||
|
||||
- Direct navigation to either surface selects the `direct` launch profile.
|
||||
A page reload also starts with `direct`, even if the server retains a completed
|
||||
or interrupted planning run. Model selection remains the device host's state.
|
||||
- A successful planner start or explicit reopen passes `planning` through
|
||||
`WorkspaceNavigation.openView`. The shell composes `PlanningConnectionWindow`
|
||||
only for that entry. Device connection and spatial-control callbacks preserve
|
||||
the originating profile when opening the scene.
|
||||
- `PlanningTestProvider` owns run data and polling, not navigation selection.
|
||||
Reading `/live-tests/active` must never change the launch profile. An unsuccessful
|
||||
explicit run selection must not open a stale run's connection or scene.
|
||||
- A nonterminal planning consumer that has not bound a query session still waits
|
||||
for the next capture. The composition-level `PlanningCaptureGuard` requires an
|
||||
explicit return to that study or completion of the study before direct capture;
|
||||
it does not send scanner, network, or recording commands. It waits for a terminal
|
||||
server state, not merely acknowledgment of the stop request. Completed runs and
|
||||
runs already bound to a recording do not claim a later direct launch.
|
||||
|
||||
Changing presentation does not delete evidence, end acquisition, or restart an
|
||||
experiment. The planner continues to consume an explicitly started recording;
|
||||
ordinary acquisition requires neither a reference route nor a planner draft.
|
||||
|
||||
Regression coverage: `test/workspaceLaunch.test.mjs`, including independent model
|
||||
catalog rendering, explicit handoff, terminal-run isolation, failed selection,
|
||||
and both shared-scene entry paths.
|
||||
|
||||
Acceptance on the canonical operator service `127.0.0.1:8000`: architecture
|
||||
checks, TypeScript, all 882 frontend tests and production build passed. In-app
|
||||
browser QA confirmed direct entry with retained completed evidence, catalog →
|
||||
XGRIDS connection, the unchanged connection-method selector and Escape,
|
||||
normal/expanded layouts, ordinary scene entry, and planner → direct-device
|
||||
navigation. No BLE discovery, provisioning, acquisition or new field run was
|
||||
performed during this UI acceptance.
|
||||
|
||||
## CSS ownership
|
||||
|
||||
CSS follows the same feature boundary:
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Планировщик: зона и маршрут из сохранённой записи
|
||||
|
||||
Текущее решение владельца от 2026-09-11: исследование перенесено в **LAB → Планировщик**, декоративный аппарат убран. «Тестирование» открывает подключение K1 с сохраняемым профилем планирования; новый проход сопоставляется с фиксированным эталоном в пространственной сцене. Сравнение сохранённых записей доступно отдельно. Реализация, ограничения и актуальный сценарий: [профиль планирования](audits/2026-09-11-planning-live-profile.md). Ниже сохранён исходный поэтапный план; прежнее размещение в «Миссиях» и последовательность запуска офлайн-проверки заменены этим решением.
|
||||
|
||||
Дата: 2026-09-11. Статус: P1 реализован на каноническом 8000; выбор интервала/направления и просмотр позиции из P2/P3 работают. Произвольные правки точек, автоматическое воспроизведение и расчёт локализации ещё не реализованы. Карточка реализации — [MISSIONCOR-81](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-81). Продуктовая область и три узла заданы владельцем. Исследовательская основа — [MISSIONCOR-79](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-79). Готовый обзор записей — [MISSIONCOR-80](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-80).
|
||||
|
||||
## Решение
|
||||
|
||||
Развивать существующее окно «Миссии → Планировщик». Первый результат — сохранённый черновик с зоной из каталога данных и редактируемым маршрутом. Аппарат остаётся неназначенным и не блокирует работу. Сохранение черновика не зависит от подключения контроллера, роутера или шасси.
|
||||
|
||||
Интерфейс и эксперимент развиваются небольшими законченными этапами. Предпросмотр маршрута проверяет геометрию и работу редактора. Совмещение независимых записей проверяет определение места. Это разные результаты, и их статусы отображаются отдельно.
|
||||
|
||||
## Что подтверждено в текущем коде
|
||||
|
||||
- Статический `MissionWorkspace` заменён отдельным `workspaces/missions/MissionPlannerWorkspace.tsx`. Три блока, общий каталог, выбор участка в метрах, направление, просмотр и серверное сохранение/повторное открытие работают. Данные находятся в `data_dir/missions/mission-drafts.sqlite3`, подготовленная траектория — в `data_dir/planning-sources`. Подробности: `docs/audits/2026-09-11-mission-planner.md`.
|
||||
- `productModel.ts`: существующий workspace `mission-planner` внутри `missions`; отдельный новый корневой раздел не требуется.
|
||||
- `web/session_api.py`: общий каталог записей, detail, cursor pagination, scope all/source/laboratory. Использовать этот каталог, а не отдельный список импортированных вручную файлов.
|
||||
- Готовый `SessionOverviewService` выдаёт ограниченный RRD, статистику, кэш и проверяет соответствие исходнику. Его можно использовать для просмотра зоны. Обзорная выборка не является картой локализации.
|
||||
- Rerun 0.36.3 в установленном `@rerun-io/web-viewer/index.d.ts` описывает `selection_change`, entity/instance и необязательную `position`. Это основание для отдельной небольшой проверки выбора точек; готового редактора маршрута из этого не следует.
|
||||
|
||||
## Продуктовая композиция
|
||||
|
||||
Оператор открывает черновик, выбирает обследованную зону, намечает путь и сохраняет результат для проверки. Основная сущность — версия черновика миссии. В центре располагается облако с траекторией; слева — три узла; справа — параметры выбранного узла. Границы панелей изменяются, в узком окне параметры переходят в канонический inspector.
|
||||
|
||||
| Узел | Первый прототип | Действия |
|
||||
| --- | --- | --- |
|
||||
| Аппарат | «Не назначен», информационная строка | Подключение и команды не добавляются; узел не входит в обязательную готовность черновика |
|
||||
| Зона | Именованная область с выбранной версией источника | «Из сессий и записей», просмотр облака, замена источника с явным пересмотром маршрута |
|
||||
| Маршрут | Упорядоченная полилиния в системе координат зоны | Создать из траектории, выбрать начало/конец и направление, редактировать точки, сохранить |
|
||||
|
||||
Узлы «Наблюдение» и «Завершение» убираются из этой композиции. Также убираются макетные 0/5 и «Интерфейс готов». Верхние действия: название черновика, сохранить; действия маршрута: «Из траектории», «Предпросмотр», пауза и позиция на линии. Настоящий запуск аппарата в этот этап не входит.
|
||||
|
||||
Выбранная композиция — редактор в существующем планировщике с пространственным представлением. Альтернатива — создавать миссию непосредственно в разделе данных: она смешивает неизменяемые записи с редактируемыми заданиями. Второй вариант — сразу универсальный граф произвольных узлов: он добавляет редактор соединений, не решая первый сценарий из трёх известных узлов. Первый прототип использует фиксированные зависимости «зона → маршрут» без свободного графа.
|
||||
|
||||
Это изменение состава существующего workspace по прямому запросу владельца. Используются `ApplicationPanel`, `GlassSurface`, `Button`, `Select`/`Dropdown`, `SegmentedControl`, `SplitPane`, `Inspector`, `RangeControl`, `LoadingRegion` и канонические иконки. Новые навигационные корни не нужны. Не добавлять локальные кнопки, переключатели или копии компонентов; потенциальные пробелы сначала проверять в Design Guideline.
|
||||
|
||||
## Зона и происхождение данных
|
||||
|
||||
1. «Из сессий и записей» открывает общий каталог с именем, датой, длительностью и доступными каналами. Все категории можно просмотреть; каталог имеет пагинацию и поиск по загруженным страницам либо серверный поиск, без ограничения первыми 100 строками.
|
||||
2. Источники без метрического облака видны, но не могут стать облаком зоны; причина указана в строке. Наличие видео или общей отметки replayable само по себе недостаточно.
|
||||
3. Первый вариант зоны ссылается на одну завершённую исходную сессию. Производные LAB-результаты допускаются только после проверки их собственных координат и происхождения, без неявного наследования родительской геометрии. Несколько произвольных сессий автоматически не склеиваются.
|
||||
4. В черновике сохраняются ID и версия источника, система координат/единицы и версия подготовки. Облако не копируется при каждом сохранении. Изменившийся или удалённый источник переводит связь в «Источник недоступен»; сам черновик и точки маршрута сохраняются.
|
||||
5. По умолчанию зона охватывает выбранную запись. Ограничение геометрической областью — отдельное поле ROI; для первой проверки достаточно явно выбранного короткого участка маршрута. Не считать bbox дальних точек границей разрешённого движения.
|
||||
6. «Сверху», «3D» и визуальный срез переиспользуются. Высотный срез остаётся настройкой просмотра: он не превращается автоматически в маску локализации, удаления препятствий или разрешённую высоту проезда.
|
||||
|
||||
У K1 здесь локальные метрические координаты. Назначать географическую позицию на Cesium по умолчанию нельзя. Для привязки к карте потребуется отдельное подтверждённое преобразование; наличие приблизительной точки Arnavi этого не заменяет.
|
||||
|
||||
## Маршрут
|
||||
|
||||
Записанная траектория сканера — источник черновика пути, а не подтверждённый путь центра шасси. Хранить оригинальные poses отдельно от редактируемой полилинии. Для пути аппарата позднее понадобятся монтажное преобразование, опорная точка и габариты.
|
||||
|
||||
Первый полный сценарий:
|
||||
|
||||
1. Выбрать зону, нажать «Из траектории».
|
||||
2. Выбрать интервал исходной траектории по времени/номеру позиции; увидеть начало и конец. Для прохода туда–обратно выбирать нужную ветвь по порядку записи, а не по ближайшей точке в пространстве.
|
||||
3. Получить ограниченный набор редактируемых точек с сохранённой связью с исходными индексами. Прореживание имеет явно заданную максимальную геометрическую ошибку; допуски выбираются на первой пробе, а не скрываются в коде.
|
||||
4. Выбирать точку, исправлять координаты, вставлять/удалять, менять направление, отменять правку. Исходную траекторию показывать более тонкой линией.
|
||||
5. Проверить порядок, конечность координат, совпадение frame, нулевые сегменты, выход из заданного ROI и скачки. Сохранить черновик. Отсутствие аппарата не блокирует сохранение.
|
||||
6. «Предпросмотр» перемещает условный маркер по выбранной линии. Если используется записанное время, это явно просмотр записи; если задана скорость маркера, это кинематический просмотр, не симуляция сцепления или объезда.
|
||||
|
||||
Для выбора/добавления точек в Rerun сначала проверить `selection_change` на известной синтетической геометрии: координаты, instance ID, преобразование view → zone, переключение видов и срез. Клик по облаку может попасть в дерево или стену; такие XYZ нельзя молча считать поверхностью дороги. Первое редактирование доступно также через список и поля координат. Полноценное перетаскивание и рисование по свободному месту добавляются после проверки проекции и явного определения плоскости редактирования; обходить canvas DOM-хаками не следует.
|
||||
|
||||
## Минимальные данные и API
|
||||
|
||||
Предлагаемые контракты, ещё не реализованные:
|
||||
|
||||
| Сущность | Содержание |
|
||||
| --- | --- |
|
||||
| MissionDraft | ID, имя, revision, vehicle=null, zoneRef, routeRef, время сохранения |
|
||||
| ZoneRevision | ID/version, sourceRef, frame/units, необязательный ROI, состояние доступности производных |
|
||||
| RouteRevision | ID/version, zoneRevision, ordered points/segments, start/end/direction, происхождение и правки |
|
||||
| PlanningSource | ограниченное облако для просмотра, индекс поз с временем и исходными frame IDs, доступные действия |
|
||||
| LocalizationRun | фиксированные map/query версии, диапазоны, начальная гипотеза, параметры, результаты и ограничения |
|
||||
|
||||
Общая библиотека записей остаётся источником. API подготовки зоны/траектории получает ID записи, не произвольный путь файловой системы. Новый индекс поз извлекается из исходных данных один раз и кэшируется: обзорный RRD с прореженными позами не объявляется полным исходником маршрута.
|
||||
|
||||
Хранилище черновиков — на backend с атомарным сохранением и проверкой revision (конфликт двух редакторов возвращается явно). LocalStorage подходит только для расположения панелей. На текущем шаге данные доступны тому Mission Core, который обслуживает существующий каталог; перенос автономного пакета на борт будет отдельной операцией. Будущий пакет содержит фиксированные версии карты/маршрута/индексов и может работать без NAS во время поездки.
|
||||
|
||||
Модули: `core/missions`, `components/missions`, `workspaces/missions`, отдельные backend contracts/store/router. `Workspaces.tsx` только делегирует новому workspace, `App` содержит минимальное подключение. Runtime совмещения не исполняется в React. Не расширять центральный файл макетов логикой нового редактора.
|
||||
|
||||
## Порядок реализации и проверки
|
||||
|
||||
| Этап | Работающий результат | Проверка |
|
||||
| --- | --- | --- |
|
||||
| P1. Черновик и зона | Три узла, сохранение без аппарата, общий каталог, выбранная сессия в центре | Выбрать разные записи, сохранить, перезагрузить; тот же источник и состояние; отсутствующий источник не уничтожает черновик |
|
||||
| P2. Маршрут | Из траектории → интервал/направление → точки → редактирование → сохранение | Отдельно прямая/обратная ветвь, отмена, повторное открытие, другая зона, конфликт версий |
|
||||
| P3. Предпросмотр | Маркер, пауза, позиция, выделенный текущий сегмент | Предсказуемый порядок на петле и развороте, отсутствие обращения к управляющим API |
|
||||
| R1. Первая регистрация | Ограниченная пара подкарта/запрос и отчёт о совмещении | Синтетическое известное преобразование; затем разные части одной реальной записи, результат помечен внутренней диагностикой |
|
||||
| R2. Независимый проход | Оценка места новой сессии в фиксированном эталоне | Позиция/курс, ложные совпадения, неоднозначные места, устаревшие данные, запуск не из начала |
|
||||
| P4. Подключение результата | В планировщике видны источник теста и оценённый маркер/отказ | Статус локализации относится к конкретным map/run версиям, не к факту открытия красивого облака |
|
||||
|
||||
P1–P3 и R1 используют общий контракт ZoneRevision/RouteRevision. Их можно разрабатывать по очереди, не ожидая готовности железа. Полный навигационный стек, объезд, энергетика и команды ходовой части не являются зависимостями этих этапов.
|
||||
|
||||
## Самый короткий полезный эксперимент
|
||||
|
||||
Для уже собранной записи A выбрать 20–30 м с выраженной статичной геометрией и отдельный сложный участок. Сохранить номера/время кадров. Эталон строить только из выбранных кадров прямого прохода, запрос — из других кадров обратного. Общая система SLAM и возможная коррекция K1 связывают эти части: даже хороший результат остаётся внутренним тестом, а не доказательством независимой локализации.
|
||||
|
||||
Сначала проверить единицы, оси и преобразования на синтетических точках; не применять pose повторно к точкам, уже находящимся в системе K1. Потом проверить близкую начальную гипотезу, небольшой искусственный сдвиг и неверный участок. Не начинать с обещания «сам найдётся в 20 м».
|
||||
|
||||
CPU baseline — GICP/VGICP с заранее подготовленными индексами участков карты; [small_gicp](https://github.com/koide3/small_gicp) предоставляет эти методы и отдельную подготовку/повторное использование индексов. Ограничить точки и число потоков, выполнять один короткий прогон за раз. Точные voxel/окно/порог соответствий задаются в manifest опыта; не использовать визуальную выборку или срез как скрытые параметры расчёта.
|
||||
|
||||
Измерять преобразование, остаточную ошибку и покрытие, число соответствий, согласованность нескольких окон, ложное принятие неверного места, возраст входа, время расчёта и RSS. Флаг converged не равен правильному месту. Для независимого испытания нужен новый проект B; эталон A фиксируется. На момент t алгоритму доступно только прошлое/текущее окно B. Использование K1-поз B допускается для локального накопления, но готовое совмещение A↔B или будущие кадры не подаются как подсказка.
|
||||
|
||||
Локальная регистрация и поиск без начального положения — отдельные задачи. [Open3D](https://www.open3d.org/docs/release/tutorial/pipelines/global_registration.html) показывает грубую глобальную инициализацию с дальнейшим локальным уточнением. Для первого испытания оператор выбирает область старта и направление. Позже проверяются ошибки начального положения 1/3/5/10/20 м и разные курсы; искусственный сдвиг гипотезы не заменяет физический старт в новом месте.
|
||||
|
||||
После появления метрического LAS из исходного проекта A сравнить две карты — подготовленную из записанного потока и обработанную LAS — на одном неизменяемом B. Проверить преобразование LAS↔траектория, единицы и версии. Цвет необязателен для первого геометрического baseline, Gaussian не нужен.
|
||||
|
||||
## Критерии и ограничения следующего этапа
|
||||
|
||||
- P1 принимается по восстановлению черновика и правильному происхождению зоны, P2 — по сохранению и редактированию пути, P3 — по воспроизводимому просмотру. Это можно доказать сейчас без шасси.
|
||||
- Для R1 принимается только корректность обработки и ограниченного совмещения. Для R2 заранее фиксируется набор независимых контрольных мест и точность их измерения. Без внешнего reference нельзя заявлять сантиметровую точность.
|
||||
- При нескольких правдоподобных местах или недостаточной геометрии результат «Требуется уточнение» предпочтительнее принудительного выбора. Начало маршрута и начало координат карты не обязаны совпадать. После локализации выбирается разрешённый вход в конкретный сегмент; автоматический подъезд из произвольного места не предполагается.
|
||||
- Один успешный прогон не доказывает достаточность Mini. Хранение, cold/warm подготовка, peak RSS и p95 времени совмещения измеряются отдельно; работа вместе с живыми камерами — на целевом борту после появления конфигурации. Локальный Mac не используется для нагрузочного теста.
|
||||
- В режиме среза скрытое дерево остаётся в исходных данных. Облако не доказывает свободное пространство, а маршрут ручного сканера — проходимость гусеницы. Проверка препятствий и кинематики остаётся последующим этапом.
|
||||
|
||||
## Следующее конкретное действие
|
||||
|
||||
Проверить сохранённый 30-метровый участок через действующий планировщик. Затем выполнить R1: синтетическое известное преобразование, ограниченная проба совмещения и проверка неверного участка; сохранить отдельный воспроизводимый отчёт. Дальше — независимый проход B в новом проекте K1 на том же 20–30-метровом участке. Сначала сравнение A/B по записи, затем такой же расчёт на живом потоке. Текущая кнопка «Проверить маршрут» проверяет исходные файлы и последовательность пути; она не определяет положение сканера.
|
||||
@@ -3,6 +3,10 @@
|
||||
Date: 2026-08-30
|
||||
Status: accepted; RAVNOVES004TREE is the first migrated full-route LAB
|
||||
|
||||
Navigation amendment, 2026-09-21: [ADR 0052](0052-native-rerun-grid-navigation.md)
|
||||
records the owner-authorized, bounded native-camera patch. The historical
|
||||
decision below remains intact; single renderer/clock/data ownership still applies.
|
||||
|
||||
Implementation audit, 2026-09-05: see the
|
||||
[complete customization inventory](../audits/2026-09-05-rerun-customization-inventory.md)
|
||||
and [upstream provenance evidence](../audits/2026-09-05-rerun-upstream-evidence.json).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR 0052: Native Rerun grid navigation
|
||||
|
||||
Date: 2026-09-21
|
||||
Status: implemented and installed; checks and manual acceptance bounds are in the navigation audit
|
||||
|
||||
## Decision and authority
|
||||
|
||||
The owner accepted the restored recorded cloud quality, then explicitly asked
|
||||
to finish navigation: pan the pivot on the grid, orbit around that fixed pivot,
|
||||
and zoom independently of the route extent. OPS is updated only after completion.
|
||||
|
||||
This is a bounded amendment to ADR 0045's **unmodified web viewer** requirement.
|
||||
Rerun SDK, recording format, data, timeline, native renderer and single-viewer
|
||||
ownership remain unchanged. The web viewer stays on upstream **0.36.3**, with
|
||||
one reproducible, hash-checked source patch. The archived 0.34.1 fork is not
|
||||
reactivated. No second camera/renderer, pointer injection or HTTP request per
|
||||
gesture is introduced.
|
||||
|
||||
## Camera contract
|
||||
|
||||
- The native orbital pivot lies on the native `LineGrid3D` plane (XY in the
|
||||
product's world frame). Pan translates the whole camera rig along that plane;
|
||||
orbital rotation leaves the pivot fixed. Scene changes cannot choose a new
|
||||
pivot after operator navigation. The guide grid is not a measured terrain model.
|
||||
- Initial fallback height is projected before rendering, not at the start of
|
||||
the first rotation. Active interpolation stops on manual navigation.
|
||||
- Zoom changes distance to the pivot. The scene-diagonal zoom-out cap is removed.
|
||||
Upstream's 0.02 m collision-with-pivot guard and a finite 1e17 arithmetic guard
|
||||
remain; these are not route-distance limits. Passing through the pivot would
|
||||
reverse orbital direction, so it is deliberately not implemented.
|
||||
- Explicit tracking remains distinct from fixed-pivot inspection. While following,
|
||||
Rerun may move the target with the tracked entity. First-person movement keeps
|
||||
upstream behavior. Reset and plan/3D switches remain explicit preset actions.
|
||||
- A read-only native snapshot supplies the last rendered eye to the existing
|
||||
iframe facade. The approximate DOM-input camera journal is removed. Snapshots
|
||||
cross the disposable iframe as copied primitive JSON only; no native handle or
|
||||
listener escapes its lifetime. With no rendered 3D frame, the snapshot is null.
|
||||
- Display-only blueprint activation carries that actual native eye. Omitting
|
||||
eye fields resurrects the incoming store's startup camera, as reproduced in
|
||||
browser QA; stable view identity alone is insufficient. This is one snapshot
|
||||
per settings update, not a second input controller or HTTP per gesture.
|
||||
- Mission Core admits one active spatial pane per iframe. The snapshot selects
|
||||
the last rendered spatial state of the active recording, ignoring retained
|
||||
states from earlier views. Multiple simultaneous 3D panes would require an
|
||||
explicit view-id argument before that product composition is admitted.
|
||||
|
||||
## Build, upgrade and rollback
|
||||
|
||||
`vendor/rerun-web-viewer-0.36.3/NODEDC_NAVIGATION.patch` is applied to the pinned
|
||||
upstream commit. `scripts/build-rerun-navigation.sh` runs in a bounded temporary
|
||||
Worker006 container, not on the 18 GB operator Mac. It tests native camera math,
|
||||
uses Rerun's own release/WebAssembly builder and matching JS transformation,
|
||||
and emits the paired runtime and declarations. No scanner data enters the build.
|
||||
|
||||
`navigation-build.json` binds upstream, source patch, compiler image and artifact
|
||||
hashes. The installer validates every input before modifying any package file,
|
||||
and rejects an unfamiliar SDK version or wrapper. Fresh installs and production
|
||||
builds use that same installer once the candidate is accepted.
|
||||
|
||||
An upgrade requires rebasing this patch, native tests, JS/WASM ABI checks,
|
||||
frontend contracts and real-browser navigation/regression QA. Do not copy the
|
||||
old WASM into a newer SDK. To roll back, disable the installer hooks and restore
|
||||
the exact official 0.36.3 package; the facade treats a missing native snapshot as
|
||||
unavailable, never fabricates an operator eye. Raw/corrected recordings do not
|
||||
need to be regenerated.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Required: plane/pan/orbit/zoom invariants, different grid plane, top-down and
|
||||
first-person regression; exact native snapshot and realm cleanup; normal and
|
||||
expanded product views, Escape, explicit reset, layers/point-size updates and
|
||||
follow transitions. A successful compile alone is not product acceptance.
|
||||
|
||||
The accepted full-fidelity cloud and 30-minute accumulation are outside this
|
||||
navigation patch and must not be reduced to make testing cheaper.
|
||||
@@ -1,5 +1,9 @@
|
||||
# Полная карта интеграции и кастомизации Rerun
|
||||
|
||||
Дополнение от 21 сентября: [профиль планирования, сохранение камеры, окна
|
||||
инструментов и открытая регрессия выделения сетки](2026-09-21-rerun-planning-customizations.md).
|
||||
Оно дополняет, а не заменяет исторический аудит ниже.
|
||||
|
||||
Снимок кода `b2a1b23131642ac496829e467c20c8c069915be9`, 2026-09-05. Ревизия подготовлена для карточки MISSION CORE #74 «Additional Core · Переносимая кастомизация Rerun». Изменения в runtime в рамках аудита не выполнялись.
|
||||
|
||||
## Зафиксированное состояние · 5 сентября 2026
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Mission planner: recorded zone and route draft
|
||||
|
||||
Date: 2026-09-11. Ops: MISSIONCOR-81. Canonical service: `http://127.0.0.1:8000`.
|
||||
|
||||
## Delivered boundary
|
||||
|
||||
The existing Missions/Planner workspace now selects a source from the shared observation-session catalog, reads its complete scanner trajectory, selects a contiguous interval in travelled metres and either direction, and persists a versioned draft without a vehicle. The original disabled five-step mock and its placeholder readiness badge are removed. The three blocks are Apparatus, Zone and Route. Parameters stay inside their blocks for this increment; the wider three-panel inspector composition remains a later refinement.
|
||||
|
||||
The cloud view reuses the existing bounded Rerun overview, top/3D and visual height clip. The route view uses an equal-axis XY schematic and a position slider over the selected original pose indices. This is inspection of a recorded scanner path, not measured localization or a certified chassis path. Selected interval and direction are editable; arbitrary waypoints, direct Rerun drawing and timed playback are not implemented.
|
||||
|
||||
## Source and persistence contracts
|
||||
|
||||
- `missions/sources.py`: immutable generation from validated source identity, approved-prefix staging, input digests before/after preparation and source-bound cache. One preparation at a time, maximum 100,000 poses / 30 MiB derived JSON, parser time/message bounds.
|
||||
- The plugin's `planning_source.py` preserves all pose indices, message ordinals, metric positions, elapsed arrival times when available, and cumulative distance. No second pose transform is applied. One decoded nonempty point-cloud frame confirms spatial evidence. Other cloud messages are counted without decoding every point again; this is not a full point-cloud integrity/accuracy audit. Full overview diagnostics remain separate.
|
||||
- `missions/drafts.py`: SQLite transactions and compare-and-swap revisions, frozen source identity, selected source indices and resolved points. Changing/missing source never removes a saved draft.
|
||||
- `web/mission_planner_api.py`: bounded request models; no vehicle ID or execution parameter is accepted. Data checks bind an exact saved revision, reverify source digests, measure length and largest position step, and retain an immutable report. A concurrent draft change rejects publication of a stale report.
|
||||
- `core/missions`, `components/missions`, `workspaces/missions`: separated domain contracts, reusable renderers and workspace composition. Existing Design Guideline Select, RangeControl, TextField, Window and actions are reused.
|
||||
- Raw captures, cached poses, SQLite drafts/checks and real scene media remain runtime data outside Git. Nothing is installed on the board.
|
||||
|
||||
## Validation
|
||||
|
||||
- 846 frontend tests passed before final bounded UX refinements. The subsequent metre-input change passed the five focused planner tests plus four architecture tests. Final production build includes TypeScript checking.
|
||||
- 16 focused backend tests passed (planner + existing overview); 10 planner tests passed again after removing unnecessary cloud decoding.
|
||||
- First real source preparation after the optimization: HTTP 200 in 2.35 seconds for 4,959 poses and a 487.61 m recorded path. This is a local functional measurement, not a board or localization benchmark.
|
||||
- Browser on the actual 8000: selected the existing road session, obtained the initial 30.01 m / 578-pose interval, saved a named draft without a vehicle, ran its data check (matching source digests; largest step about 0.10 m), reloaded the client and reopened it. Reverse direction starts at the selected final source pose. Exact interval entry uses metres, not hidden frame indices.
|
||||
- Another source named `35` loaded independently with 283 poses / 0.089 m, proving the selector is not bound to the road session. Unsupported derived LAB entries remain visible with a reason.
|
||||
- UI QA used the actual narrow in-app browser surface and expanded window, including Escape. Desktop-width rendering is not separately qualified here.
|
||||
- All checks/builds/browser work ran sequentially. No duplicate backend or port 8765 was introduced. Canonical 8000 remains durable operator state.
|
||||
|
||||
## Next experiment
|
||||
|
||||
No new scan is required to inspect the saved draft. Before claiming live testing, implement a bounded geometric-registration runner, prove known synthetic transforms and reject a wrong reference segment. Then acquire an independent B recording in a new K1 project over the same 20–30 m section, retaining both Mission Core session and native capture. Freeze reference A. Compare B windows against A without future B frames, report failures/ambiguity, timing and memory. Only after offline evidence passes should the same pipeline feed a live testing viewport.
|
||||
|
||||
The current “Проверить маршрут” action does not compare clouds, estimate the scanner's location, determine passability, detect obstacles or send commands. The starting-position tolerance and target-board capacity remain experimental questions.
|
||||
@@ -0,0 +1,91 @@
|
||||
# LAB planning profile — 2026-09-11
|
||||
|
||||
Owner decision: move the research planner to LAB, remove the decorative vehicle
|
||||
step, and make Testing lead through the existing K1 connection/naming/start
|
||||
workflow into a planning-specific spatial scene. The ordinary recorded-pair
|
||||
comparison remains a separate action. This is an operator diagnostic instrument,
|
||||
not a mission executor or a new root/navigation system.
|
||||
|
||||
## Operator workflow
|
||||
|
||||
1. LAB → Planner; open a saved reference draft. A remains immutable. Select the
|
||||
reference interval in trajectory metres and its travel direction; save.
|
||||
2. Testing prepares the reference once and opens the canonical Window containing
|
||||
the existing DeviceWorkspace/K1 connection flow. The server owns the frozen
|
||||
draft/revision; its planning profile survives navigation and browser reload.
|
||||
3. Create a **new K1 project** and a separate capture B at the physical entry of
|
||||
the chosen reference interval. Press the existing acquisition/connect action.
|
||||
Its existing host callback opens Spatial Scene with the planning profile.
|
||||
4. Walk 20–30 m once, approximately the same scanner height and direction. Initial
|
||||
position is the operator's entry hypothesis; first ≥3 m displacement supplies
|
||||
yaw. It is not global place recognition or a guarantee of arbitrary-start
|
||||
recovery. A remains separate from B; no LAS or Gaussian processing is needed.
|
||||
5. Gray shows reference geometry. New query geometry uses a blue-to-magenta
|
||||
height palette. Only accepted per-point nearest-reference distances ≤0.5 m
|
||||
become green. GICP candidate rejection gates remain fixed v1. Live green is
|
||||
removed after 8 s sample age, stopped input, changed identity, or termination.
|
||||
6. Device and recording controls retain the existing K1 stop/finalization path.
|
||||
Finishing the *research* only releases its derived-data lease; it cannot stop
|
||||
capture or a vehicle. The last completed calculation is retained for review.
|
||||
|
||||
## Architecture and resource bounds
|
||||
|
||||
- `sessions/live_planning.py` is the normalized read-only input contract. K1's
|
||||
`planning_live.py` adapts its **existing committed-evidence ingress**; no second
|
||||
MQTT receiver, device command, network scan, or raw-capture owner is introduced.
|
||||
Both verified current and legacy protocol decoders are reused. Published K1
|
||||
map-space points are not transformed by pose a second time.
|
||||
- Planning occupies the existing exclusive derived-data consumer lease. An AI
|
||||
worker already owning it yields an explicit busy response; neither profile
|
||||
silently steals the other's lease. No inference service is started or stopped.
|
||||
- `missions/live_tests.py` captures baseline generation while idle, requires a
|
||||
new session ID/generation, rejects reuse of A/prior capture, and fences every
|
||||
sample/result. A changed producer session terminates the research. Independent
|
||||
project/SLAM reset remains an operator requirement (`slam_reset_verified=false`).
|
||||
- Reference preparation uses existing immutable source validation and submap
|
||||
extraction (≤40 m, ≤100,000 points, ≤90 s). Native GICP uses the same isolated
|
||||
short-lived worker and one shared compute lock as offline comparison.
|
||||
- Live preview samples at most 2 cloud frames/s, requires a preceding pose no
|
||||
older than 0.5 s, crops within 20 m and relative height −3…+6 m, voxelizes at
|
||||
0.25 m, retains 40 frames × ≤4,000 points, then bounds the fit to 40,000 points.
|
||||
Original recording is unaffected by preview thinning or queue overflow.
|
||||
- Fit requests are at least 5 s apart, one active numerical child, 30 s deadline.
|
||||
Capture ingestion continues while fitting; no future query frames enter a job.
|
||||
Research is capped at 40 m or 300 s after session admission, waiting at 30 min.
|
||||
Local synthetic checks are bounded functionality tests, **not load tests** or
|
||||
board resource acceptance.
|
||||
- Rerun uses one isolated viewer/channel. Static reference is sent once;
|
||||
subsequent entity updates replace the query without resetting the camera.
|
||||
Expand/restore and unmount dispose the isolated WASM realm.
|
||||
- Private `data_dir/missions/live-tests/<UUID>` contains frozen draft, reference,
|
||||
per-step NPZ numerical inputs, result JSON, source sequences/host receipt clocks,
|
||||
query path and artifact SHA-256. Ingress before/after counters are retained.
|
||||
`active.json` supports refresh; interrupted runs are marked on server restart,
|
||||
never silently rebound. Raw B remains in the ordinary observation catalog.
|
||||
- Pre-existing immutable offline RRDs are not rewritten. Their legend explicitly
|
||||
identifies the old all-query green format; new reports carry versioned
|
||||
per-correspondence coloring.
|
||||
|
||||
## Validation and limits
|
||||
|
||||
23 focused Python tests (live profile, registration, planner) pass: causal pose
|
||||
window, bounded thinning, per-point green, no green on rejection, existing ingress
|
||||
adapter, exclusive profile lease, fresh identity, frozen draft revision, stale
|
||||
producer rejection, cancellation/release, persisted report, native Rerun stream,
|
||||
known rigid transform recovery and negative registration cases. Frontend planner
|
||||
and architecture tests pass; typecheck/build pass. Browser on canonical 8000:
|
||||
LAB placement, absence of Vehicle step, saved 30 m draft, Testing modal, preparation
|
||||
of real A without capture, gray reference in native Rerun, profile retained across
|
||||
navigation. Final production build `app-C4fpKRYf.js` is served on canonical
|
||||
`127.0.0.1:8000`; 15 frontend checks pass. The browser trial was cancelled with
|
||||
`query_session_id=null`, then the managed local server was restarted and checked.
|
||||
No K1 connection, capture or stream command was issued during this acceptance.
|
||||
Mission Core Ops cards 81 and 79 contain the updated workflow and open hardware
|
||||
acceptance items; both updates were independently read back.
|
||||
|
||||
Independent B and live hardware acceptance are **not yet performed**. No claim of
|
||||
navigation accuracy, a 20 m start radius, robust global relocalization, obstacle
|
||||
avoidance, or adequate Mac mini compute follows from these checks. The next useful
|
||||
result is B from a new project, followed by analysis of accepted/rejected windows,
|
||||
receipt gaps and wrong-location controls. The entry hypothesis can mislead in
|
||||
repeated geometry; green is geometric consistency, never autonomous authority.
|
||||
@@ -0,0 +1,43 @@
|
||||
# LAB planning project browser — 2026-09-11
|
||||
|
||||
## Owner-approved job and composition
|
||||
|
||||
The LAB planner compares point-cloud passages before any potential mission-planning use. Its two entry paths are opening an existing experiment and creating a new project through **+**. The owner explicitly requested a large scene with an overlaid, movable/resizable inspector. No primary navigation or shared visual entity was added.
|
||||
|
||||
The former fixed editing column and separate comparison modal split one operator job across draft and result selectors. They are replaced with one header catalog **Совмещённые маршруты**, create, refresh and settings actions. A saved run opens its own immutable comparison immediately. New-project name, source, bounds, direction and query acquisition are inside the inspector. Settings open by default for creation/preparation; saved results open in review mode with settings available on demand. Closing settings clears the header action's pressed state without changing the selected scene.
|
||||
|
||||
Canonical Design Guideline exports: ApplicationPanel headerTools, Select, IconButton, WorkspaceWindow, Inspector/InspectorSelectField, TextField, RangeControl, SegmentedControl and LoadingRegion. WorkspaceWindow is the bounded scene tool admitted by WINDOWS_AND_LAYERS.md: it owns pointer/keyboard drag, resize, maximize and reclamping. Application CSS only arranges the domain content. The previous comparison Window component is removed.
|
||||
|
||||
## Project identity and evidence
|
||||
|
||||
`missions/projects.py` projects existing stores; it creates no second project database. Each recorded registration or physical live experiment retains a separate `kind:run_id` identity, timestamp, frozen draft revision, sources, outcome and available evidence. Unstarted legacy drafts remain identifiable as preparation. Preparation-only cancelled live probes without a query are not presented as passage experiments.
|
||||
|
||||
Catalog and detail GETs never select an active live test, start acquisition, change a draft or rerun registration. Recorded scenes are hash-checked against the persisted report on every open. Large per-point correspondence arrays are omitted from the UI detail. Live historical views use the exact committed step's hash-bound input, source sequence, query path and fitted transform, with a separate derived-view cache. The terminal unregistered preview is never substituted for a saved fit. Missing result geometry remains unavailable.
|
||||
|
||||
The new project form requires a name and saved reference. **Новый проход** uses the existing planning-profile connection workflow. **Из записи** selects an already captured repeat and interval in the same inspector. Starting saves the setup, freezes it into a new run and selects that run. Viewing an existing finished project is read-only. Selection persists as optional session UI state; report authority stays on the server. Changing selection does not reorder the catalog.
|
||||
|
||||
## Renderer correctness and retained limitations
|
||||
|
||||
The saved renderer applies `T_reference_query` once to both query cloud and query trajectory; the reference is unchanged. A synthetic nonzero translation test verifies this convention. Existing source clouds, transforms, thresholds and result reports were not modified or recomputed during this UI task. Double surfaces visible after registration remain evidence of geometric residuals or source-map effects; the UI does not hide them or claim they were eliminated.
|
||||
|
||||
The real retained B result is `820571ba-6076-482b-be34-29ceb5328c80`, JA-SADOVAYA-001 → JA-SADOVAYA-002, 2026-09-11 17:24:04 Moscow. Its report SHA-256 remains `97236f89d73f63745a77558bf585dcac5ead5ffaa8191c27afb73b52c6271d59`. The older failed live run remains a separate **Без результата совмещения** entry. Its stopped-time message is shown as historical context, not a fresh instruction to rescan. Prior all-green query rendering is explicitly labelled as the early format rather than per-point acceptance.
|
||||
|
||||
## Code ownership
|
||||
|
||||
- Core/backend: `missions/projects.py`, `web/mission_registration_api.py` and router composition. Read-only catalog/detail and verified scene delivery.
|
||||
- UI core: `planningProjects.ts`, `usePlanningProjects.ts`, `useMissionPlanner.ts`, `useRegistrationTest.ts`, `PlanningTestContext.tsx`.
|
||||
- Domain components: `PlanningProjectSettings.tsx`, `PlanningProjectResult.tsx`, existing `RegistrationScene.tsx`. Removed `MissionRegistrationWindow.tsx` and its redundant UI polling/history path.
|
||||
- Workspace: `MissionPlannerWorkspace.tsx`, feature CSS, typed header portal host through App/WorkspaceRenderer. API and project behavior remain outside App.
|
||||
|
||||
## Verification
|
||||
|
||||
- 30 focused Python tests passed: frozen-project identity, separate run entries, preparation and failed-live states, scene digest rejection, read-only archive, committed-live-step rendering/cache, one-time query transform plus registration/planner/live regressions.
|
||||
- 850 frontend tests passed. After final catalog-order/polling cleanup, 12 focused architecture/planner tests passed again. Full TypeScript check and production build passed; final build includes the small historical-message wording adjustment.
|
||||
- Actual canonical 8000 browser: automatic saved-result opening, exact project selection, old recorded result, failed live entry, + with blank name and mandatory reference, query selection from saved A/B, launch disabled until valid fields, unsaved-form confirmation, settings close/reopen, keyboard movement and resizing, maximize/restore/Escape, ordinary/expanded panel, selection across page reload.
|
||||
- Geometry check: inspector moved 20 px and resized from 390×600 to 400×610; stage remained 628×839.906 px. One Rerun iframe stayed mounted during settings interaction. Pointer drag/resize uses the existing canonical WorkspaceWindow; the new integration's measured geometry check used its keyboard controls.
|
||||
- The temporary UI form was discarded. No new real registration, capture or device command was submitted. Three recorded reports and two live reports remained in the private stores; five visible projects were projected. No independent localization/false-positive/live-camera acceptance is implied.
|
||||
- Canonical LaunchAgent was restarted to load the additive backend routes. A single backend remains on 127.0.0.1:8000; no listener on 8765. Docker backend/VM was not started. Tests/builds and browser QA ran sequentially; numerical workers were not launched for this UI change.
|
||||
|
||||
## Next stage
|
||||
|
||||
Use the retained A/B evidence to test successive bounded windows and wrong-region controls. The candidate remains geometric agreement, not proven robot-pose accuracy. Hardware capture, obstacle policy and vehicle control remain separate acceptance work.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Planning scene and independent pass B — repair audit
|
||||
|
||||
User scope: retain the ordinary spatial scene toolbar, camera, recording controls
|
||||
and Planning profile; eliminate the exit action that loses the research; unify
|
||||
the planner toolbar and Inspector geometry. No new capture or hardware command.
|
||||
|
||||
## Retained evidence
|
||||
|
||||
JA-SADOVAYA-002 was captured on 2026-09-11, 13:43:55–13:47:03 UTC. The archive
|
||||
catalog marks its point-cloud and trajectory sources ready: 959 cloud messages,
|
||||
960 poses, 37,812,938 raw bytes. Raw SHA-256:
|
||||
`fe1366a177c20204f26e709649521611f0fc3aae138cb857bcabe84a5809268a`.
|
||||
The device's last distance report is 62.532 m; sum of received pose steps is
|
||||
62.974 m, including gaps, and is not independent ground truth.
|
||||
|
||||
The research failed before its first registration result: 2.776 m of accumulated
|
||||
movement and 14,101 preview points were retained in its status. Received pose 161
|
||||
moves 10.407 m after a 9.973 s receipt gap. The first implementation rejected any
|
||||
step above 3 m without considering elapsed time. Therefore no green was produced;
|
||||
this is an ingestion failure, not an experiment disproving localization.
|
||||
|
||||
The selected frozen draft was the prior internal reverse-direction comparison
|
||||
of the first 30 m of A, not the forward 30 m draft. This source selection is
|
||||
preserved, not silently corrected. Start/direction must be checked before using
|
||||
B to claim a registration result.
|
||||
|
||||
B has no archived camera frames. Its camera epoch sealed with zero media segments
|
||||
and `stale-activation-commit`. This is separate from the missing floating camera
|
||||
UI. Camera authority fences are retained; no unsafe retry or physical command is
|
||||
introduced by this patch. An unavailable recording cannot be reconstructed as
|
||||
video. The next actual camera run still requires hardware acceptance.
|
||||
|
||||
## Implementation
|
||||
|
||||
SpatialWorkspace is extracted unchanged in ownership from the workspace hub to
|
||||
`workspaces/spatial`. A typed visual-profile slot supplies the research renderer,
|
||||
status, metrics and toolbar; the existing K1 controls, source picker and floating
|
||||
camera transport remain owned by the shared scene. The profile no longer puts
|
||||
stop controls behind an extra modal or exposes a button clearing the research.
|
||||
Terminal errors outrank generic waiting/staleness. Saved research selection
|
||||
restores its frozen reference and verified preview across navigation/restart,
|
||||
without running a new capture or fit. Future previews are persisted with hashes.
|
||||
|
||||
Receipt gaps above 2 s clear the bounded fit window and invalidate old accepted
|
||||
samples. A step is rejected above max(3 m, 3 m/s × min(receipt gap, 30 s)); this
|
||||
is a diagnostic plausibility bound, not odometry acceptance. Jobs carry the
|
||||
segment identity and cannot commit across a gap. Slow results remain recorded
|
||||
as historical evidence; the existing 8 s green freshness rule is unchanged.
|
||||
|
||||
The planner uses canonical Inspector/InspectorSelectField with full-width
|
||||
stacked actions. One right-aligned host toolbar owns cloud/route, top/3D and
|
||||
expand actions; no nested host title. For the pinned single-view Rerun runtime,
|
||||
its measured 54 CSS px canvas chrome (28 px top allocation + 26 px view strip)
|
||||
is allocated outside the clipped content viewport. The Inspector scrolls independently
|
||||
inside the available panel height. Scene content height and native pointer handling are preserved.
|
||||
|
||||
## Validation
|
||||
|
||||
Focused Python registration/planner/live tests pass (24 cases), including the
|
||||
10 s receipt-gap regression, rejection of a fast coordinate jump, bounded sample
|
||||
reset, persisted preview restoration and no restored live-green authority.
|
||||
Frontend architecture and planner checks pass. Full frontend validation and
|
||||
canonical browser smoke are recorded at completion below.
|
||||
|
||||
The original failing report and raw session are retained. No localization success,
|
||||
new camera capture, autonomy, control or navigation acceptance is claimed.
|
||||
|
||||
## Completed acceptance on canonical 8000
|
||||
|
||||
Production typecheck/build passed. The complete frontend run executed 849 cases
|
||||
(840 passed; nine obsolete architecture/source-location expectations failed).
|
||||
After updating those expectations for LAB placement/shared scene extraction and
|
||||
removing generic-shell vendor labels, focused reruns passed; the final remaining
|
||||
four affected files passed all 50 cases. No failing assertions remain from that run.
|
||||
|
||||
Browser inspection on the built localhost service verified: equal action widths
|
||||
(315.2 CSS px in the observed Inspector), split canonical selects, independent
|
||||
Inspector scrolling, one aligned toolbar, no visible Rerun title/help strip,
|
||||
real A cloud, expand/restore/Escape, preserved forward draft, and reopening the
|
||||
actual failed research with its frozen reference and explicit failure. The old
|
||||
failed run has no persisted query preview; its 14,101 status points are not
|
||||
reconstructed or presented as a successful result. Raw B remains intact.
|
||||
|
||||
The shared source picker shows K1 cloud and both camera channels; cameras are
|
||||
unconfirmed/disabled while capture is stopped. Camera/recording controls retain
|
||||
the ordinary scene owners, but a new physical camera run was not performed.
|
||||
The next action is analysis of existing A/B in the correct forward draft, not
|
||||
an immediate repeat scan. This UI repair is not hardware-camera acceptance.
|
||||
@@ -0,0 +1,76 @@
|
||||
# Recorded passage registration — 2026-09-11
|
||||
|
||||
The mission planner now compares a selected saved reference route with a bounded interval of another saved recording. The operator opens **Тестирование**, selects the query recording and interval, and starts **Сопоставить проходы**. The report, inputs and 3D evidence remain available after the window closes or the service restarts. This is a recorded-data experiment; no live localization or vehicle commands are produced.
|
||||
|
||||
## Initial hypothesis and scope
|
||||
|
||||
The selected query entry is assumed to be near the selected reference route entry, with matching travel direction. Initial translation maps those entry poses together; initial yaw aligns the first displacement of at least 3 m. Roll/pitch initially agree with the sessions' vertical axes. GICP refines all six degrees of freedom. The hypothesis is explicitly shown in the UI. This is not global place recognition and does not establish a 20 m acquisition radius.
|
||||
|
||||
Both intervals must be 3–40 m. The reference comes from the immutable saved draft revision. Different recording IDs do not, by themselves, prove independent K1 projects. For B the operator must start a new K1 project and a separate Mission Core recording. Overlapping intervals of the same recording are rejected. Disjoint A outbound/return intervals are permitted as an explicitly labelled internal diagnostic, retaining shared SLAM limitations.
|
||||
|
||||
## Implementation
|
||||
|
||||
- `missions/registration.py`: pinned small_gicp 1.0.1 CPU GICP; source-to-reference rigid transform; finite/bounded input validation; origin-independent centering; nearest-surface evaluation and candidate rejection policy.
|
||||
- `missions/registration_worker.py`: one short-lived numerical process, one computation thread, 30 s timeout. The process exits after each result. No permanent worker or second backend.
|
||||
- `missions/registration_runs.py`: single admitted background job, submitted draft snapshot, generation and SHA-256 source binding, immutable per-run JSON/NPZ/RRD artifacts in the existing private data directory. Interrupted queued/running reports are marked as errors on restart. No implicit retry that would duplicate a run.
|
||||
- Plugin-owned K1 extraction reads only cloud messages inside the selected pose-message interval. It selects at most 120 frames, records original message indices and receipts, crops within 20 m of the preceding recorded scanner position and between −3/+6 m relative height, then keeps the first point per 0.25 m voxel. It never applies the scanner pose a second time to K1 map-space points. No future query frames beyond the selected end enter the cloud.
|
||||
- Extraction is bounded by 90 s, 2 million raw points, 1 million cropped observations and 100,000 final points. Missing real receipt timing remains null. Immutable source digests are checked before and after prefix staging/extraction.
|
||||
- `web/mission_registration_api.py`: separate run/history/report/scene API. `components/missions/` and `core/missions/` own the UI. Existing route data checks retain their distinct purpose. The background cloud viewer is unmounted while the test window is open; one result Rerun realm is active, with expand/restore and teardown.
|
||||
|
||||
Install only in the repository environment: `uv sync --extra localization --inexact`. Core remains usable without the optional numeric module; a calculation failure is retained as an error report. Upstream method: [small_gicp](https://github.com/koide3/small_gicp), version [1.0.1](https://pypi.org/project/small-gicp/1.0.1/). The local/global registration distinction is also described in [Open3D's registration tutorial](https://www.open3d.org/docs/release/tutorial/pipelines/global_registration.html).
|
||||
|
||||
## Fixed candidate policy v1
|
||||
|
||||
0.25 m preprocessing; 1.5 m correspondence search; 40 iterations. Required: convergence, ≥55% of query points within 0.5 m of the reference, inlier point-to-point RMSE ≤0.25 m, correction at the patch center ≤3 m and rotation ≤30°. Shape covariance and diagonal-normalized information eigenvalue ratios must exceed 0.002 and 0.0001. These are experimental rejection gates fixed before the real probe, not validated safety thresholds. A candidate is not an accepted robot pose. `localization_confirmed=false`, `vehicle_control=false` on every report.
|
||||
|
||||
## Measurements
|
||||
|
||||
One bounded functional probe on the local arm64 Mac; no load/stress test and no Mini resource qualification. Raw source A digests remained unchanged. Display-only overview sampling was not used as registration geometry.
|
||||
|
||||
| Probe | Result | Query within 0.5 m | Inlier RMSE | Registration |
|
||||
|---|---|---:|---:|---:|
|
||||
| Known synthetic rigid transform, 0.7/−0.4/0.2 m and 5.73° yaw | Candidate; transform recovered within 1 cm / 0.1° assertions | 100% | 0.065 m | about 0.03 s |
|
||||
| A outbound 30 m vs final return 30 m; initial hypothesis perturbed by 0.7/−0.4/0.2 m and 6° | Candidate | 90.70% | 0.188 m | 0.23 s |
|
||||
| A different road section, 130–155 m, deliberately seeded at reference entry | Rejected: non-convergence and residual | 59.38% | 0.274 m | 0.57 s |
|
||||
| Same clouds with initial offset 1 km on each axis | Rejected: no overlap/information | 0% | unavailable | 3.27 s |
|
||||
| Coincident synthetic plane | Rejected: insufficient 3D geometry | — | — | unit test |
|
||||
|
||||
The A return fit differs from the original common frame by about 0.716 m / 0.906°. This is a fitting correction, not localization ground-truth error. Independent survey measurements are absent. Only one wrong-region example was checked; false-positive frequency and repeated-scene robustness remain unmeasured.
|
||||
|
||||
The real server/UI comparison used a reversed 30 m reference and a 20.06 m inbound query with the route-entry/travel-heading hypothesis. Result: 89.32%, 0.194 m inlier RMSE; correction 2.94 m / 3.5°. It completed in 35.0 s initially, of which 1.60 s was registration; most time was bounded Python data extraction. A repeat with the isolated numerical process completed in 31.2 s (1.46 s registration). The first run appeared stalled during interactive inspection but did finish; a native-library deadlock was not established. Timeout isolation was retained as containment, not presented as proof of that diagnosis.
|
||||
|
||||
## Validation and retained limitations
|
||||
|
||||
23 focused backend tests passed (registration, planner, overview), including known-transform recovery, no-overlap/plane rejection, input limits, no future-frame inclusion, no second pose transform, immutable report/revision behavior and one-job admission. Nine planner/architecture frontend tests and typecheck/build passed. Optional localization tests require the localization extra. Real 8000 browser flow, result display, history reopening after server restart, expanded 3D and Escape restore were checked on the available narrow in-app viewport. Full desktop layout and a clean deployment host are not qualified by this check.
|
||||
|
||||
Not implemented: independent B proof, sequential causal tracking, consensus over several windows, arbitrary-start/global relocalization, ground-truth accuracy, hardware timing, LAS comparison, obstacle policy or chassis control. The test consumes an already saved interval; it does not run while the operator walks.
|
||||
|
||||
## Next acquisition
|
||||
|
||||
A is already available. Record only B: new K1 project + separate Mission Core session, start near A's first physical start with the same travel direction, hold the scanner at roughly the same height, walk the first 20–30 m once, finish and save. Keep original A unchanged. Choose the forward 30 m mission draft, then B in Testing; the first comparison uses B's first 20 m. If acquisition starts elsewhere, record the location/direction and select the corresponding reference entry instead of silently treating it as the first start. LAS and Gaussian generation are unnecessary for this probe.
|
||||
|
||||
## Independent B recording — saved comparison at 14:24 UTC
|
||||
|
||||
The acquisition instruction above is now complete: JA-SADOVAYA-002 is saved. No new field recording is needed for the next analysis. One bounded comparison was executed through the canonical server on port 8000, without source changes, a restart, hardware commands or a new capture.
|
||||
|
||||
- Run `820571ba-6076-482b-be34-29ceb5328c80`, 2026-09-11T14:24:04.314Z–14:24:33.127Z; started monotonic ns `514814164352083`.
|
||||
- Forward draft `6159d3fa-dda4-4223-acdf-ceeceedefaad`, revision 1, **JA-SADOVAYA · проверка 30 м**. A: `20260911T085226Z_viewer_live`, poses 0–577, 30.012 m.
|
||||
- B: `20260911T134352Z_viewer_live`, poses 0–251, 20.078 m, 39.530 s. Its whole recorded trajectory is approximately 62.974 m. Evidence relation is `different_recordings`; K1 SLAM reset is not independently verified by this label.
|
||||
- GICP candidate, converged in 23 iterations: **91.212%** of 23,135 query evaluation points within **0.5 m** of the 30,280-point reference; inlier surface RMSE **0.1763 m**. These are geometric consistency measures, not scanner/rover pose accuracy.
|
||||
- Correction from the entry/direction hypothesis: **2.940 m / 6.920°**. Translation is close to the fixed 3 m rejection gate; this run does not establish a reliable startup radius. Gates were unchanged. No arbitrary-location search was performed.
|
||||
- Numerical registration **1.048 s**, complete preparation/evaluation/persistence **28.810 s** on the local Darwin arm64 Python 3.12.13 environment. This is one recorded-data probe, not a live-rate or onboard-computer qualification.
|
||||
- `localization_confirmed=false`, `vehicle_control=false`. No independent ground truth or sequential-window/false-positive qualification for B yet.
|
||||
|
||||
Report and artifacts are retained under private `data_dir/missions/registration-runs/820571ba-6076-482b-be34-29ceb5328c80/`. SHA-256 verification passed for all four declared artifacts:
|
||||
|
||||
| Artifact | SHA-256 |
|
||||
|---|---|
|
||||
| report.json | `97236f89d73f63745a77558bf585dcac5ead5ffaa8191c27afb73b52c6271d59` |
|
||||
| clouds.npz | `174e60a37685c83553a97500bbe89e0a7da5ec39543629c03fff1691e0c09717` |
|
||||
| scene.rrd | `2824f1d3f0064d5d62dbe6823febd6c6013c162ad08ecfc57281abfe7c9069a4` |
|
||||
| registration-input.npz | `5eedfda3e792bf291bab16c723646b2dfb4e8a1ca3b117a3c7e26dd15bfd4a52` |
|
||||
| registration-result.json | `1c0faf1ed677391a126e8fbb5ee6d7bacdf48343581ad0c503f15f9a5d2fd52e` |
|
||||
|
||||
The saved result was opened in the actual browser comparison history at **17:24:04 Moscow time** and its expanded Rerun view inspected: gray reference, colored B and green distance-qualified correspondences rendered. The short-lived numerical worker exited; port 8000 still returned HTTP 200. Docker backend/VM was not running during this probe. No new permanent process was introduced.
|
||||
|
||||
Next planned experiment: replay successive bounded B windows against fixed A, retain causal evidence and assess discontinuities; add wrong-region controls before claiming stable localization. New scanning can wait for those results. The previous failed live report remains unchanged and is not reclassified by this recorded result. Missing archived B camera frames are not restored by point-cloud registration.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Черновик карточки Mission Core в Ops
|
||||
|
||||
Статус: после явного разрешения владельца от 2026-09-11 краткий отчёт создан и проверен в [MISSIONCOR-80](https://ops.nodedc.ru/nodedc/browse/MISSIONCOR-80), состояние Done. Предыдущие попытки были отклонены автоматической проверкой; подробные локальные идентификаторы и хеши в эту карточку не переносились.
|
||||
|
||||
## Название
|
||||
|
||||
Обзор сохранённой сессии: виды и верхний срез
|
||||
|
||||
## Результат
|
||||
|
||||
Окно информации содержит пространственный обзор, таблицу сведений и график интервалов кадров. Панели изменяют размер. Кнопки «Сверху» и «3D» переключают ракурс. Вертикальный ползунок слева скрывает точки выше выбранной высоты. Верхнее положение восстанавливает весь обзор. Срез сохраняет ручной ракурс и не изменяет исходную запись.
|
||||
|
||||
## Проверка
|
||||
|
||||
В браузере проверены переключение видов, ползунок мышью и клавиатурой, восстановление облака, вращение, изменение размеров панелей и возврат из информации. Проверки кода и итоговая сборка прошли. Подробный инженерный отчёт сохранён локально.
|
||||
|
||||
## Приёмка
|
||||
|
||||
- [x] Виды сверху и 3D работают.
|
||||
- [x] Верхний срез обратим и сохраняет ракурс.
|
||||
- [x] Обычный и развёрнутый размеры проверены.
|
||||
- [x] Проверки и сборка прошли.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user