feat: finalize corrected-route planning and Rerun recording review
This commit is contained in:
@@ -10,3 +10,4 @@
|
|||||||
*.las binary
|
*.las binary
|
||||||
*.lcc 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.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",
|
"name": "@nodedc/mission-core-control-station",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"postinstall": "node scripts/install-rerun-navigation.mjs",
|
||||||
|
"prebuild": "node scripts/install-rerun-navigation.mjs",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:unit": "node --test test/*.test.mjs",
|
"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,
|
Inspector,
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToastStack,
|
||||||
UserProfileMenu,
|
UserProfileMenu,
|
||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
@@ -49,6 +50,7 @@ import type {
|
|||||||
} from "./core/observation/sessionArchive";
|
} from "./core/observation/sessionArchive";
|
||||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||||
|
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
|
||||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||||
import {
|
import {
|
||||||
@@ -181,6 +183,7 @@ export default function App() {
|
|||||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||||
|
const closeDisplayRef = useRef<() => void>(() => {});
|
||||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||||
@@ -228,6 +231,13 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||||
replayActiveRef.current = replayPresented;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||||
@@ -310,6 +320,7 @@ export default function App() {
|
|||||||
const remote = runtime.state?.viewerSettings;
|
const remote = runtime.state?.viewerSettings;
|
||||||
if (
|
if (
|
||||||
!remote ||
|
!remote ||
|
||||||
|
replayActiveRef.current ||
|
||||||
workspaceLayoutProfile.profile ||
|
workspaceLayoutProfile.profile ||
|
||||||
sceneSettingsCommitterRef.current?.isBusy()
|
sceneSettingsCommitterRef.current?.isBusy()
|
||||||
) return;
|
) return;
|
||||||
@@ -332,7 +343,7 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const profile = workspaceLayoutProfile.profile;
|
const profile = workspaceLayoutProfile.profile;
|
||||||
if (!profile) return;
|
if (!profile || selectedRecordedSessionIdRef.current) return;
|
||||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||||
viewerSettingsCommitTimerRef.current = null;
|
viewerSettingsCommitTimerRef.current = null;
|
||||||
@@ -403,7 +414,10 @@ export default function App() {
|
|||||||
|
|
||||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||||
if (windowId === "sources") setSourceWindowOpen(false);
|
if (windowId === "sources") setSourceWindowOpen(false);
|
||||||
if (windowId === "display") setDisplayWindowOpen(false);
|
if (windowId === "display") {
|
||||||
|
setDisplayWindowOpen(false);
|
||||||
|
closeDisplayRef.current();
|
||||||
|
}
|
||||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -464,14 +478,22 @@ export default function App() {
|
|||||||
const next = { ...displayDraftRef.current, ...patch };
|
const next = { ...displayDraftRef.current, ...patch };
|
||||||
displayDraftRef.current = next;
|
displayDraftRef.current = next;
|
||||||
setDisplayDraft(next);
|
setDisplayDraft(next);
|
||||||
|
sessionDisplayProfile.edited();
|
||||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
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 = window.setTimeout(() => {
|
||||||
viewerSettingsCommitTimerRef.current = null;
|
viewerSettingsCommitTimerRef.current = null;
|
||||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||||
}, viewerSettingsQuietPeriodMs);
|
}, viewerSettingsQuietPeriodMs);
|
||||||
}, []);
|
}, [sessionDisplayProfile.edited]);
|
||||||
|
|
||||||
const flushDisplaySettings = useCallback(() => {
|
const flushDisplaySettings = useCallback(() => {
|
||||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||||
@@ -481,8 +503,15 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||||
|
sessionDisplayProfile.edited();
|
||||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||||
}, [commitDisplaySettings]);
|
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
|
||||||
|
|
||||||
|
closeDisplayRef.current = () => {
|
||||||
|
const settings = {...displayDraftRef.current};
|
||||||
|
commitDisplaySettings(settings);
|
||||||
|
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
|
||||||
|
};
|
||||||
|
|
||||||
const openDisplay = () => {
|
const openDisplay = () => {
|
||||||
if (!sceneWorkspaceActive) return;
|
if (!sceneWorkspaceActive) return;
|
||||||
@@ -1036,8 +1065,11 @@ export default function App() {
|
|||||||
onClose={() => closeSceneWindow("display")}
|
onClose={() => closeSceneWindow("display")}
|
||||||
>
|
>
|
||||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||||
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||||
</Window>
|
</Window>
|
||||||
|
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
|
||||||
|
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
|
||||||
|
onDismiss={sessionDisplayProfile.dismissError} />
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit,
|
limit,
|
||||||
scope: "source",
|
scope: "standalone",
|
||||||
replayEnabled: blockedReason === null,
|
replayEnabled: blockedReason === null,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
onReplayAccepted,
|
onReplayAccepted,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./rerun/recordedRerunCameraJournal";
|
} from "./rerun/recordedRerunCameraJournal";
|
||||||
|
|
||||||
import type { SceneSettings } from "../sceneSettings";
|
import type { SceneSettings } from "../sceneSettings";
|
||||||
|
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
|
||||||
import {
|
import {
|
||||||
advanceLiveReceiverOpenWatchdog,
|
advanceLiveReceiverOpenWatchdog,
|
||||||
advanceLiveReceiverWatchdog,
|
advanceLiveReceiverWatchdog,
|
||||||
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
|
|||||||
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
||||||
sceneSettings?: Pick<
|
sceneSettings?: Pick<
|
||||||
SceneSettings,
|
SceneSettings,
|
||||||
|
| "pointDecimationPercent"
|
||||||
| "accumulationSeconds"
|
| "accumulationSeconds"
|
||||||
| "showGrid"
|
| "showGrid"
|
||||||
| "showPoints"
|
| "showPoints"
|
||||||
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
|
|||||||
cameraContract?: string | null;
|
cameraContract?: string | null;
|
||||||
appliedFollowTrajectory?: boolean | null;
|
appliedFollowTrajectory?: boolean | null;
|
||||||
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
||||||
configureCameraJournal?: (
|
getCameraEye?: () => RecordedRerunCameraEye | null;
|
||||||
eye: RecordedRerunCameraEye,
|
|
||||||
spatialViewportStart: number,
|
|
||||||
) => void;
|
|
||||||
getCameraEye?: () => RecordedRerunCameraEye;
|
|
||||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
|
||||||
getCurrentTimeNs?: () => number | null;
|
getCurrentTimeNs?: () => number | null;
|
||||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
|
||||||
channel: {
|
channel: {
|
||||||
readonly ready: boolean;
|
readonly ready: boolean;
|
||||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||||
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
currentTimeNs,
|
currentTimeNs,
|
||||||
reactivateUpdates = false,
|
reactivateUpdates = false,
|
||||||
onCameraMaxOrbitalRadius,
|
onCameraMaxOrbitalRadius,
|
||||||
|
displayPointBank,
|
||||||
perceptionLayers = {
|
perceptionLayers = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
detections2d: false,
|
detections2d: false,
|
||||||
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
currentTimeNs?: number | null;
|
currentTimeNs?: number | null;
|
||||||
reactivateUpdates?: boolean;
|
reactivateUpdates?: boolean;
|
||||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||||
|
displayPointBank?: string | null;
|
||||||
perceptionLayers?: RecordedPerceptionLayers;
|
perceptionLayers?: RecordedPerceptionLayers;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
},
|
},
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const resolvedUnifiedPerception =
|
const resolvedUnifiedPerception =
|
||||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
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 base = new URL(origin);
|
||||||
const endpoint = new URL(endpointUrl, base.origin);
|
const endpoint = new URL(endpointUrl, base.origin);
|
||||||
if (
|
if (
|
||||||
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
...cameraEye.eyeUp,
|
...cameraEye.eyeUp,
|
||||||
].some((value) => !Number.isFinite(value))) ||
|
].some((value) => !Number.isFinite(value))) ||
|
||||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
|
||||||
)) ||
|
)) ||
|
||||||
[
|
[
|
||||||
perceptionLayers.enabled,
|
perceptionLayers.enabled,
|
||||||
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
application_id: identity.applicationId,
|
application_id: identity.applicationId,
|
||||||
recording_id: identity.recordingId,
|
recording_id: identity.recordingId,
|
||||||
blueprint_session_id: blueprintSessionId,
|
blueprint_session_id: blueprintSessionId,
|
||||||
|
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
|
||||||
accumulation_seconds: settings.accumulationSeconds,
|
accumulation_seconds: settings.accumulationSeconds,
|
||||||
show_grid: settings.showGrid,
|
show_grid: settings.showGrid,
|
||||||
show_points: settings.showPoints,
|
show_points: settings.showPoints,
|
||||||
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
eye_up: cameraEye?.eyeUp ?? null,
|
eye_up: cameraEye?.eyeUp ?? null,
|
||||||
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
||||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||||
current_time_ns: currentTimeNs,
|
current_time_ns: requestTimeNs,
|
||||||
}),
|
}),
|
||||||
show_detections_2d: perceptionLayers.detections2d,
|
show_detections_2d: perceptionLayers.detections2d,
|
||||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||||
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
|
|||||||
followTrajectory: boolean;
|
followTrajectory: boolean;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
// Following is a tracking property of the current native eye. It must never
|
// Following uses the native eye snapshot, never the startup preset.
|
||||||
// initialize the browser journal again: doing so replaces the operator's
|
// Plan/3D and explicit reset are the only preset transitions.
|
||||||
// current pose with the startup preset immediately before the blueprint is
|
|
||||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
|
||||||
return [activeView, viewResetGeneration, planView].join(":");
|
return [activeView, viewResetGeneration, planView].join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -841,6 +842,7 @@ function RerunViewportInstance({
|
|||||||
const presentationGateRef = useRef(presentationGate);
|
const presentationGateRef = useRef(presentationGate);
|
||||||
presentationGateRef.current = presentationGate;
|
presentationGateRef.current = presentationGate;
|
||||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||||
|
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
|
||||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||||
const recordedBlueprintUrl = sourceUrl
|
const recordedBlueprintUrl = sourceUrl
|
||||||
? resolveRecordedBlueprintUrl(
|
? resolveRecordedBlueprintUrl(
|
||||||
@@ -857,6 +859,17 @@ function RerunViewportInstance({
|
|||||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
: 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(
|
const presentationStatus = rerunPresentationStatus(
|
||||||
status,
|
status,
|
||||||
presentationGate,
|
presentationGate,
|
||||||
@@ -1638,29 +1651,14 @@ function RerunViewportInstance({
|
|||||||
cameraContract: null,
|
cameraContract: null,
|
||||||
appliedFollowTrajectory: null,
|
appliedFollowTrajectory: null,
|
||||||
pendingFollowCameraEye: null,
|
pendingFollowCameraEye: null,
|
||||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
|
||||||
if ("configure_camera_journal" in viewer) {
|
|
||||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCameraEye: () => "get_camera_eye" in viewer
|
getCameraEye: () => "get_camera_eye" in viewer
|
||||||
? viewer.get_camera_eye()
|
? viewer.get_camera_eye()
|
||||||
: RECORDED_RERUN_ORBITAL_EYE,
|
: null,
|
||||||
setCameraViewportStart: (spatialViewportStart) => {
|
|
||||||
if ("set_camera_viewport_start" in viewer) {
|
|
||||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCurrentTimeNs: () => {
|
getCurrentTimeNs: () => {
|
||||||
const currentIdentity = recordedIdentityRef.current;
|
const currentIdentity = recordedIdentityRef.current;
|
||||||
if (!currentIdentity) return null;
|
if (!currentIdentity) return null;
|
||||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
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,
|
channel,
|
||||||
};
|
};
|
||||||
blueprintChannelRef.current = blueprintChannel;
|
blueprintChannelRef.current = blueprintChannel;
|
||||||
@@ -2010,6 +2008,7 @@ function RerunViewportInstance({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordedPointColorsUrl || !sceneSettings) return;
|
if (!recordedPointColorsUrl || !sceneSettings) return;
|
||||||
|
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
|
||||||
const active = blueprintChannelRef.current;
|
const active = blueprintChannelRef.current;
|
||||||
const identity = recordedIdentityRef.current;
|
const identity = recordedIdentityRef.current;
|
||||||
if (
|
if (
|
||||||
@@ -2089,6 +2088,7 @@ function RerunViewportInstance({
|
|||||||
sceneSettings?.colorMode,
|
sceneSettings?.colorMode,
|
||||||
sceneSettings?.customColor,
|
sceneSettings?.customColor,
|
||||||
sceneSettings?.palette,
|
sceneSettings?.palette,
|
||||||
|
sceneSettings?.pointDecimationPercent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2113,12 +2113,6 @@ function RerunViewportInstance({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const cameraContractChanged = active.cameraContract !== cameraContract;
|
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 abort = new AbortController();
|
||||||
const previousFollow = active.appliedFollowTrajectory ?? false;
|
const previousFollow = active.appliedFollowTrajectory ?? false;
|
||||||
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
||||||
@@ -2143,7 +2137,9 @@ function RerunViewportInstance({
|
|||||||
if (eyeRelativeToTracking && currentTimeNs == null) {
|
if (eyeRelativeToTracking && currentTimeNs == null) {
|
||||||
throw new Error("Recorded tracking cursor is unavailable");
|
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,
|
origin: window.location.origin,
|
||||||
blueprintSessionId: blueprintSessionIdRef.current,
|
blueprintSessionId: blueprintSessionIdRef.current,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
@@ -2157,13 +2153,11 @@ function RerunViewportInstance({
|
|||||||
planView: recordedPlanView,
|
planView: recordedPlanView,
|
||||||
cameraEye,
|
cameraEye,
|
||||||
eyeRelativeToTracking,
|
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,
|
reactivateUpdates,
|
||||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
|
||||||
if (blueprintChannelRef.current === active) {
|
|
||||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const canApply = () => (
|
const canApply = () => (
|
||||||
@@ -2175,24 +2169,29 @@ function RerunViewportInstance({
|
|||||||
const applyPayload = (payload: Uint8Array) => {
|
const applyPayload = (payload: Uint8Array) => {
|
||||||
if (!canApply()) return false;
|
if (!canApply()) return false;
|
||||||
active.channel.send_rrd(payload);
|
active.channel.send_rrd(payload);
|
||||||
active.setCameraViewportStart?.(
|
// Exclude inactive point generations before admitting their data into
|
||||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
// cached recordings whose embedded blueprint predates display controls.
|
||||||
);
|
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const firstEye = disablingFollow
|
const firstEye = disablingFollow
|
||||||
? transitionEye ?? undefined
|
? transitionEye ?? undefined
|
||||||
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
||||||
? pendingFollowEye ?? active.getCameraEye?.()
|
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
|
||||||
: undefined;
|
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
|
||||||
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
||||||
disablingFollow || recordedFollowTrajectory
|
disablingFollow || recordedFollowTrajectory
|
||||||
);
|
);
|
||||||
const firstPayload = await requestBlueprint(
|
const firstPayload = await requestBlueprint(
|
||||||
firstEye,
|
firstEye,
|
||||||
firstEyeIsTrackingRelative,
|
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;
|
if (!applyPayload(firstPayload)) return;
|
||||||
active.cameraContract = cameraContract;
|
active.cameraContract = cameraContract;
|
||||||
@@ -2213,9 +2212,10 @@ function RerunViewportInstance({
|
|||||||
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
||||||
if (!applyPayload(stabilizedPayload)) return;
|
if (!applyPayload(stabilizedPayload)) return;
|
||||||
active.pendingFollowCameraEye = null;
|
active.pendingFollowCameraEye = null;
|
||||||
})().catch(() => {
|
})().catch((error: unknown) => {
|
||||||
// The recording remains usable with its embedded default blueprint.
|
// The recording remains usable with its embedded default blueprint.
|
||||||
// A later settings change retries through the same small channel.
|
// 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();
|
return () => abort.abort();
|
||||||
}, [
|
}, [
|
||||||
@@ -2243,6 +2243,9 @@ function RerunViewportInstance({
|
|||||||
sceneSettings?.showPoints,
|
sceneSettings?.showPoints,
|
||||||
sceneSettings?.showTrajectory,
|
sceneSettings?.showTrajectory,
|
||||||
sceneSettings?.showGrid,
|
sceneSettings?.showGrid,
|
||||||
|
pointDisplay.ready,
|
||||||
|
pointDisplay.bank,
|
||||||
|
pointDisplay.hidePoints,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import type {ReactNode} from "react";
|
|||||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||||
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
|
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
|
||||||
export function MissionZonePreview({ sessionId, toolbar }: { sessionId: string; toolbar?:ReactNode }) {
|
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
|
||||||
const { data, error, retry } = useSessionOverview(sessionId);
|
const { data, error, retry } = useSessionOverview(sessionId);
|
||||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||||
const failure = error || (data?.state === "error" ? data.message : null);
|
const failure = error || (data?.state === "error" ? data.message : null);
|
||||||
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
|
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>;
|
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} toolbar={toolbar} hideTitle />;
|
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +1,30 @@
|
|||||||
import { Button, Icon, Inspector, InspectorSelectField, LoadingRegion, RangeControl, SegmentedControl, TextField } from '@nodedc/ui-react';
|
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
|
||||||
import { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
|
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
|
||||||
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||||
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
|
|
||||||
|
|
||||||
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
|
export function PlanningProjectSettings({p,onStart,starting}:{
|
||||||
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
|
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
|
||||||
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const disabled=p.busy||starting;
|
const disabled=p.busy||starting;
|
||||||
const length=routeLength(p.poses);
|
const length=routeLength(p.poses);
|
||||||
return <Inspector variant="panel" defaultOpen={['project','zone','route','query']} sections={[
|
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">
|
{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)}/>
|
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
|
||||||
</div>},
|
</div>},
|
||||||
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
|
{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}/>
|
<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.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
|
||||||
{p.source&&<small>{p.source.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
|
{p.source&&<>
|
||||||
</div>},
|
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
|
||||||
{id:'route',label:'Участок эталона',icon:<Icon name="plan"/>,content:<div className="inspector-control-stack">
|
|
||||||
{p.source&&!p.sourceChanged?<>
|
|
||||||
<RangeControl label="Начало участка" value={p.source.poses[p.start]?.distance_m??0} min={0} max={p.source.poses[p.source.poses.length-2].distance_m} step="any" disabled={disabled}
|
|
||||||
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>{const n=indexAtDistance(p.source!,distance,0,p.source!.poses.length-2);p.setStart(n);if(n>=p.end)p.setEnd(n+1);}}/>
|
|
||||||
<RangeControl label="Конец участка" value={p.source.poses[p.end]?.distance_m??0} min={p.source.poses[p.start+1]?.distance_m??0} max={p.source.path_m} step="any" disabled={disabled}
|
|
||||||
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>p.setEnd(indexAtDistance(p.source!,distance,p.start+1,p.source!.poses.length-1,true))}/>
|
|
||||||
<Button disabled={disabled} onClick={()=>p.setEnd(endAtDistance(p.source!,p.start,30))}>30 м от начала участка</Button>
|
|
||||||
<Button disabled={disabled} onClick={()=>{p.setStart(0);p.setEnd(p.source!.poses.length-1);}}>Вся траектория</Button>
|
|
||||||
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
|
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
|
||||||
<small>{length.toFixed(2)} м · {p.poses.length.toLocaleString('ru-RU')} положений</small>
|
|
||||||
</>:<p>Выберите сохранённую запись эталона.</p>}
|
|
||||||
</div>},
|
|
||||||
{id:'query',label:'Повторный проход',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
|
|
||||||
<SegmentedControl label="Источник повторного прохода" value={mode} onChange={setMode} items={[{value:'scanner',label:'Новый проход',disabled},{value:'recording',label:'Из записи',disabled}]}/>
|
|
||||||
{mode==='scanner'?<p>После запуска откроется подключение сканера. Новый проход записывается отдельным проектом.</p>:<>
|
|
||||||
<InspectorSelectField label="Повторная запись" value={t.sessionId} disabled={disabled} searchable onChange={t.setSessionId}
|
|
||||||
options={[{value:'',label:'Выберите повторный проход'},...p.sessions.filter(canSelectSession).map(s=>({value:s.id,label:s.label,description:s.id===p.sessionId?'Та же запись · внутренняя проверка':'Сохранённые облако и траектория'}))]}/>
|
|
||||||
<LoadingRegion loading={t.loading} label="Подготовка повторного прохода">
|
|
||||||
{t.source&&<div className="inspector-control-stack">
|
|
||||||
<RangeControl label="Начало повторного участка" min={0} max={t.source.poses[t.source.poses.length-2].distance_m} step="any" value={t.source.poses[t.start].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`}
|
|
||||||
onChange={n=>{const start=indexAtDistance(t.source!,n,0,t.source!.poses.length-2);t.setStart(start);t.setEnd(endAtDistance(t.source!,start,20));}}/>
|
|
||||||
<RangeControl label="Конец повторного участка" min={t.source.poses[t.start+1].distance_m} max={t.source.path_m} step="any" value={t.source.poses[t.end].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`} onChange={n=>t.setEnd(indexAtDistance(t.source!,n,t.start+1))}/>
|
|
||||||
</div>}
|
|
||||||
</LoadingRegion>
|
|
||||||
</>}
|
</>}
|
||||||
<small>{mode==='scanner'
|
|
||||||
?'Длина выбранного участка задаёт предел прохода. Ограничения по времени нет; запись сканера не останавливается автоматически. Неподвижная калибровка просматривает весь выбранный маршрут. После калибровки оставайтесь на месте до статуса «Сопровождение»; при отсутствии или неоднозначности совпадения сцена прямо сообщит причину и не начнёт сопровождение.'
|
|
||||||
:'Участки от 3 до 40 м. Начальная привязка — выбранное место старта и одинаковое направление.'}</small>
|
|
||||||
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!canStartPlanningRoute(length,mode)||(mode==='recording'&&(!t.source||t.loading||!canStartPlanningRoute(t.length,'recording')))} onClick={onStart}><Icon name="play"/>{mode==='scanner'?'Начать новый проход':'Запустить совмещение'}</Button>
|
|
||||||
{(p.error||t.error)&&<p role="alert">{p.error||t.error}</p>}
|
|
||||||
</div>},
|
</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>
|
||||||
|
</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,10 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
|
|||||||
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
|
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
|
||||||
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||||
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
|
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
|
||||||
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode } from "../../core/observation/sessionOverviewSpatial";
|
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
|
||||||
|
|
||||||
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
|
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
|
||||||
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean }) {
|
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
|
||||||
const host = useRef<HTMLDivElement>(null);
|
const host = useRef<HTMLDivElement>(null);
|
||||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||||
const [retry, setRetry] = useState(0);
|
const [retry, setRetry] = useState(0);
|
||||||
@@ -14,6 +14,10 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
|||||||
const [mode, setMode] = useState<OverviewViewMode>("3d");
|
const [mode, setMode] = useState<OverviewViewMode>("3d");
|
||||||
const [viewError, setViewError] = useState<string | null>(null);
|
const [viewError, setViewError] = useState<string | null>(null);
|
||||||
const [visiblePoints, setVisiblePoints] = useState<number | 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 appliedMode = useRef<OverviewViewMode | null>(null);
|
||||||
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
|
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -23,7 +27,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
|||||||
appliedMode.current = null;
|
appliedMode.current = null;
|
||||||
setState("loading");
|
setState("loading");
|
||||||
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
|
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
|
||||||
void fetchOverviewSpatial(sourceUrl, abort.signal).then(setMetadata).catch(() => { if (!disposed) setViewError("Параметры среза недоступны."); });
|
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 runtime = createIsolatedRerunHost(host.current);
|
||||||
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||||
@@ -44,29 +52,33 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (state !== "ready" || !metadata || !controller.current) return;
|
if (state !== "ready" || !metadata || !controller.current) return;
|
||||||
const abort = new AbortController();
|
const abort = new AbortController();
|
||||||
|
setUpdating(true);
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
|
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).then(result => {
|
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;
|
if (abort.signal.aborted || !controller.current) return;
|
||||||
controller.current.channel.send_rrd(result.bytes);
|
controller.current.channel.send_rrd(result.bytes);
|
||||||
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
|
|
||||||
appliedMode.current = mode;
|
appliedMode.current = mode;
|
||||||
setVisiblePoints(result.visiblePoints); setViewError(null);
|
setVisiblePoints(result.visiblePoints); setViewError(null);
|
||||||
}).catch(() => { if (!abort.signal.aborted) setViewError("Не удалось обновить вид облака."); });
|
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
|
||||||
|
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
|
||||||
}, 180);
|
}, 180);
|
||||||
return () => { abort.abort(); clearTimeout(timer); };
|
return () => { abort.abort(); clearTimeout(timer); };
|
||||||
}, [state, metadata, sourceUrl, ceiling, mode, retry]);
|
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
|
||||||
|
|
||||||
const low = metadata?.height_min_m;
|
const low = comparison?.height_min_m ?? metadata?.height_min_m;
|
||||||
const high = metadata?.height_max_m;
|
const high = comparison?.height_max_m ?? metadata?.height_max_m;
|
||||||
return <>
|
return <>
|
||||||
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
|
<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}
|
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
|
||||||
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
|
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
|
||||||
{toolbar}
|
{toolbar}
|
||||||
</div>
|
</div>
|
||||||
<LoadingRegion loading={state === "loading"} label="Загрузка облака" className="session-overview__scene">
|
<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" ? "visible" : "hidden" }} />
|
<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">
|
{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"
|
<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('.', ',')}
|
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
|
||||||
@@ -74,7 +86,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
|||||||
</div>}
|
</div>}
|
||||||
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||||
</LoadingRegion>
|
</LoadingRegion>
|
||||||
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
|
<span className="session-overview__note" role="status" aria-busy={updating}>
|
||||||
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек</span>}
|
{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 = {
|
export type RecordedRerunCameraEye = {
|
||||||
readonly position: readonly [number, number, number];
|
readonly position: readonly [number, number, number];
|
||||||
readonly lookTarget: readonly [number, number, number];
|
readonly lookTarget: readonly [number, number, number];
|
||||||
readonly eyeUp: 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 = {
|
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||||
position: [16, -16, 18],
|
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
|
||||||
lookTarget: [0, 0, 0],
|
|
||||||
eyeUp: [0, 0, 1],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||||
position: [0, 0, 30],
|
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
|
||||||
lookTarget: [0, 0, 0],
|
|
||||||
eyeUp: [0, 1, 0],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
|
||||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
if (value == null) return null; // No 3D frame rendered yet.
|
||||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
|
||||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
const eye = value as Record<string, unknown>;
|
||||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
const vector = (key: string): [number, number, number] => {
|
||||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
const item = eye[key];
|
||||||
a[1] * b[2] - a[2] * b[1],
|
if (!Array.isArray(item) || item.length !== 3 ||
|
||||||
a[2] * b[0] - a[0] * b[2],
|
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
|
||||||
a[0] * b[1] - a[1] * b[0],
|
throw new Error("Invalid native Rerun camera vector");
|
||||||
];
|
}
|
||||||
const length = (value: Vector3) => Math.hypot(...value);
|
return [item[0], item[1], item[2]];
|
||||||
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);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
|
|||||||
"set_current_time" | "set_playing"
|
"set_current_time" | "set_playing"
|
||||||
> & {
|
> & {
|
||||||
open_channel: (name?: string) => Channel;
|
open_channel: (name?: string) => Channel;
|
||||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
get_camera_eye: () => RecordedRerunCameraEye | null;
|
||||||
get_camera_eye: () => RecordedRerunCameraEye;
|
|
||||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
|
||||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
/** 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_active_timeline: (...args) => command("set_active_timeline", args),
|
||||||
set_current_time: (...args) => command("set_current_time", args),
|
set_current_time: (...args) => command("set_current_time", args),
|
||||||
set_playing: (...args) => command("set_playing", 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"),
|
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 };
|
return { facade, dispose, notify };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||||
|
|
||||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||||
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
|||||||
let notify: RerunNotify | null = null;
|
let notify: RerunNotify | null = null;
|
||||||
const subscriptions = new Map<number, () => void>();
|
const subscriptions = new Map<number, () => void>();
|
||||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
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 send = (message: object) => notify?.(JSON.stringify(message));
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
notify = null;
|
notify = null;
|
||||||
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
|||||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||||
}
|
}
|
||||||
channels.clear();
|
channels.clear();
|
||||||
cameraJournal?.dispose();
|
|
||||||
cameraJournal = null;
|
|
||||||
const current = native;
|
const current = native;
|
||||||
native = null;
|
native = null;
|
||||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||||
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
|||||||
const starting = required();
|
const starting = required();
|
||||||
try {
|
try {
|
||||||
await starting.start(args[0], mount, args[1]);
|
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 });
|
if (native === starting) send({ type: "started", id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (native === starting) send({ type: "start-failed", id, message: String(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);
|
channels.delete(id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
case "get-camera-eye": result = readNativeRerunCameraEye(
|
||||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
|
||||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
); break;
|
||||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
|
||||||
case "open": required().open(args[0]); break;
|
case "open": required().open(args[0]); break;
|
||||||
case "close": required().close(args[0]); break;
|
case "close": required().close(args[0]); break;
|
||||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); 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};
|
||||||
|
}
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
||||||
|
|
||||||
export function useMissionPlanner() {
|
export function useMissionPlanner() {
|
||||||
const initializeRoute = useRef(true);
|
|
||||||
const [sessions, setSessions] = useState<SessionOption[]>([]);
|
const [sessions, setSessions] = useState<SessionOption[]>([]);
|
||||||
const [cursor, setCursor] = useState<string | null>(null);
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||||
@@ -10,8 +9,6 @@ export function useMissionPlanner() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [sessionId, setSessionId] = useState("");
|
const [sessionId, setSessionId] = useState("");
|
||||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||||
const [start, setStart] = useState(0);
|
|
||||||
const [end, setEnd] = useState(1);
|
|
||||||
const [direction, setDirection] = useState<Direction>("forward");
|
const [direction, setDirection] = useState<Direction>("forward");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [sourceError, setSourceError] = useState<string | null>(null);
|
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||||
@@ -19,12 +16,13 @@ export function useMissionPlanner() {
|
|||||||
const [catalogBusy, setCatalogBusy] = useState(true);
|
const [catalogBusy, setCatalogBusy] = useState(true);
|
||||||
const [loadVersion, setLoadVersion] = useState(0);
|
const [loadVersion, setLoadVersion] = useState(0);
|
||||||
const [sourceVersion, setSourceVersion] = useState(0);
|
const [sourceVersion, setSourceVersion] = useState(0);
|
||||||
|
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
|
||||||
const [check, setCheck] = useState<RouteCheck | null>(null);
|
const [check, setCheck] = useState<RouteCheck | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController(); setCatalogBusy(true); setError(null);
|
const abort = new AbortController(); setCatalogBusy(true); setError(null);
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?limit=100&pagination=cursor-v1", { signal: abort.signal }),
|
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 }),
|
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); } })
|
]).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); })
|
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
|
||||||
@@ -34,26 +32,29 @@ export function useMissionPlanner() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController(); setSource(null); setSourceError(null);
|
const abort = new AbortController(); setSource(null); setSourceError(null);
|
||||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
|
||||||
.then(data => { if (!abort.signal.aborted) { const next = validatePlanningSource(data, sessionId); setSource(next); if (initializeRoute.current) { setStart(0); setEnd(endAtDistance(next, 0, 30)); initializeRoute.current = false; } } })
|
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
|
||||||
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
|
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
|
||||||
return () => abort.abort();
|
return () => abort.abort();
|
||||||
}, [sessionId, sourceVersion]);
|
}, [sessionId, sourceVersion, pinnedGeneration]);
|
||||||
|
|
||||||
const currentSource = source?.session_id === sessionId ? source : null;
|
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 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 poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
|
||||||
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|
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;
|
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
|
||||||
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
|
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
|
||||||
const chooseSource = (id: string) => { initializeRoute.current = true; setSessionId(id); setStart(0); setEnd(1); setCheck(null); };
|
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 newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
|
||||||
const openDraft = async (id: string) => {
|
const openDraft = async (id: string) => {
|
||||||
setBusy(true); setError(null);
|
setBusy(true); setError(null);
|
||||||
try {
|
try {
|
||||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
|
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
|
||||||
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
|
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
|
||||||
setStart(next.route.start_index); setEnd(next.route.end_index); setDirection(next.route.direction); setCheck(null);
|
setDirection(next.route.direction); setCheck(null);
|
||||||
setSourceVersion(n => n + 1);
|
setSourceVersion(n => n + 1);
|
||||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||||
};
|
};
|
||||||
@@ -63,7 +64,7 @@ export function useMissionPlanner() {
|
|||||||
try {
|
try {
|
||||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
|
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,
|
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
|
||||||
generation: currentSource.generation, start_index: start, end_index: end, direction,
|
generation: currentSource.generation, whole_recording: true, direction,
|
||||||
}) });
|
}) });
|
||||||
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
|
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
|
||||||
return next;
|
return next;
|
||||||
@@ -79,11 +80,11 @@ export function useMissionPlanner() {
|
|||||||
if (!cursor) return;
|
if (!cursor) return;
|
||||||
setCatalogBusy(true);
|
setCatalogBusy(true);
|
||||||
try {
|
try {
|
||||||
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
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);
|
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); }
|
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
|
||||||
};
|
};
|
||||||
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource, start, setStart, end, setEnd,
|
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
|
||||||
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
|
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), []),
|
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,
|
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource } from "./planner";
|
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
|
||||||
|
|
||||||
export interface RegistrationReport {
|
export interface RegistrationReport {
|
||||||
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
|
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
|
||||||
@@ -15,6 +15,25 @@ export function useRegistrationTest(draft: Draft | null) {
|
|||||||
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
|
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
|
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
|
||||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
||||||
@@ -35,5 +54,5 @@ export function useRegistrationTest(draft: Draft | null) {
|
|||||||
return data;
|
return data;
|
||||||
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
|
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
|
||||||
};
|
};
|
||||||
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run };
|
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"
|
| "interrupted"
|
||||||
| "failed";
|
| "failed";
|
||||||
|
|
||||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
|
||||||
|
|
||||||
export interface ObservationLabInstance {
|
export interface ObservationLabInstance {
|
||||||
labId: string;
|
labId: string;
|
||||||
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
|
|||||||
seekable: true;
|
seekable: true;
|
||||||
byteLength: number;
|
byteLength: number;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
mapGeneration?: string;
|
||||||
playback: {
|
playback: {
|
||||||
speed: number;
|
speed: number;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
|
|||||||
"seekable",
|
"seekable",
|
||||||
"byte_length",
|
"byte_length",
|
||||||
"sha256",
|
"sha256",
|
||||||
|
"map_generation",
|
||||||
"playback",
|
"playback",
|
||||||
"media_sources",
|
"media_sources",
|
||||||
]);
|
]);
|
||||||
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
|
|||||||
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
||||||
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
|
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(
|
const viewerSourceUrl = requireString(
|
||||||
launch.viewer_source_url,
|
launch.viewer_source_url,
|
||||||
"launch.viewer_source_url",
|
"launch.viewer_source_url",
|
||||||
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
|
|||||||
integer: true,
|
integer: true,
|
||||||
}),
|
}),
|
||||||
sha256: launch.sha256,
|
sha256: launch.sha256,
|
||||||
|
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
|
||||||
playback: { speed, loop: launch.playback.loop },
|
playback: { speed, loop: launch.playback.loop },
|
||||||
mediaSources,
|
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);
|
||||||
|
}
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
export type OverviewViewMode = "3d" | "top";
|
export type OverviewViewMode = "3d" | "top";
|
||||||
export interface OverviewSpatialMetadata { height_min_m: number | null; height_max_m: number | null; sample_points: number; }
|
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) {
|
function endpoint(source: string) {
|
||||||
const url = new URL(source, window.location.origin);
|
const url = new URL(source, window.location.origin);
|
||||||
@@ -12,12 +21,15 @@ export async function fetchOverviewSpatial(source: string, signal: AbortSignal):
|
|||||||
if (!response.ok) throw new Error("Параметры среза недоступны.");
|
if (!response.ok) throw new Error("Параметры среза недоступны.");
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal) {
|
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 url = endpoint(source);
|
||||||
const generation = url.searchParams.get("generation");
|
const generation = url.searchParams.get("generation");
|
||||||
url.search = "";
|
url.search = "";
|
||||||
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
|
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect }) });
|
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
|
||||||
|
comparison_generation: comparisonGeneration, representation }) });
|
||||||
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
|
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
|
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
|
||||||
|
|||||||
@@ -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)};
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
|
|||||||
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
|
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
|
||||||
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
|
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
|
||||||
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||||
import { useRegistrationTest } from '../../core/missions/useRegistrationTest';
|
|
||||||
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
|
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
|
||||||
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
|
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
|
||||||
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
|
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
|
||||||
@@ -17,9 +16,7 @@ import '../../styles/mission-planner.css';
|
|||||||
|
|
||||||
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
|
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
|
||||||
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
||||||
const t=useRegistrationTest(p.saved);
|
|
||||||
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
||||||
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
|
|
||||||
const [view,setView]=useState<'cloud'|'route'>('cloud');
|
const [view,setView]=useState<'cloud'|'route'>('cloud');
|
||||||
const [starting,setStarting]=useState(false);
|
const [starting,setStarting]=useState(false);
|
||||||
const [pending,setPending]=useState<(()=>void)|null>(null);
|
const [pending,setPending]=useState<(()=>void)|null>(null);
|
||||||
@@ -39,7 +36,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
|||||||
};
|
};
|
||||||
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
|
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
|
||||||
const newProject=()=>replace(()=>{
|
const newProject=()=>replace(()=>{
|
||||||
projects.select('');p.newDraft();t.setSessionId('');setMode('scanner');setView('cloud');
|
projects.select('');p.newDraft();setView('cloud');
|
||||||
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
|
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
|
||||||
});
|
});
|
||||||
const start=async()=>{
|
const start=async()=>{
|
||||||
@@ -47,13 +44,8 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
|||||||
try {
|
try {
|
||||||
const draft=p.saved&&!p.dirty?p.saved:await p.save();
|
const draft=p.saved&&!p.dirty?p.saved:await p.save();
|
||||||
if(!draft)return;
|
if(!draft)return;
|
||||||
if(mode==='scanner') {
|
const next=await live.begin(draft);
|
||||||
const next=await live.begin(draft);
|
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
|
||||||
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
|
|
||||||
} else {
|
|
||||||
const result=await t.run(draft);
|
|
||||||
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
|
|
||||||
}
|
|
||||||
projects.refresh();
|
projects.refresh();
|
||||||
} finally {setStarting(false);}
|
} finally {setStarting(false);}
|
||||||
};
|
};
|
||||||
@@ -81,13 +73,13 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
|||||||
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
|
{!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.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"/>
|
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
|
||||||
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
|
: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>
|
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
|
||||||
:project?<>
|
:project?<>
|
||||||
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
|
<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}/>
|
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
|
||||||
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
|
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
|
||||||
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись остаётся в разделе «Данные».</small></div>}
|
:<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>}
|
{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"/>
|
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
|
||||||
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
|
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
|
||||||
@@ -96,7 +88,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
|||||||
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
|
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
|
||||||
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
|
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
|
||||||
className="planning-project__inspector">
|
className="planning-project__inspector">
|
||||||
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
|
{editing?<PlanningProjectSettings p={p} onStart={()=>void start()} starting={starting}/>
|
||||||
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
|
: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>}
|
{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>}
|
{live.error&&editing&&<p role="alert">{live.error}</p>}
|
||||||
|
|||||||
@@ -46,11 +46,11 @@ export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
|
|||||||
separatorLabel="Высота графика интервалов" className="session-overview__split"
|
separatorLabel="Высота графика интервалов" className="session-overview__split"
|
||||||
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} 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">
|
primary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||||
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
|
{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>}
|
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
|
||||||
</GlassSurface>}
|
</GlassSurface>}
|
||||||
secondary={<GlassSurface className="session-overview__panel" radius="panel">
|
secondary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||||
<h2>Сведения о записи</h2><div className="session-overview__facts">
|
<h2>Сведения об исходной записи</h2><div className="session-overview__facts">
|
||||||
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
|
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
|
||||||
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
|
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ export function SpatialWorkspace({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
</>}
|
</>}
|
||||||
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||||
<ObservationTimeline
|
<ObservationTimeline
|
||||||
active={presentedViewerStatus === "ready"}
|
active={presentedViewerStatus === "ready"}
|
||||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||||
@@ -572,6 +572,7 @@ export function SpatialWorkspace({
|
|||||||
onPlayingChange={playbackController?.setPlaying}
|
onPlayingChange={playbackController?.setPlaying}
|
||||||
onJumpToEnd={playbackController?.jumpToEnd}
|
onJumpToEnd={playbackController?.jumpToEnd}
|
||||||
accumulationSeconds={accumulationSeconds}
|
accumulationSeconds={accumulationSeconds}
|
||||||
|
accumulationMaxSeconds={sceneSettings.accumulationMaxSeconds}
|
||||||
onAccumulationChange={onAccumulationChange}
|
onAccumulationChange={onAccumulationChange}
|
||||||
onAccumulationCommit={onAccumulationCommit}
|
onAccumulationCommit={onAccumulationCommit}
|
||||||
className="scene-timeline"
|
className="scene-timeline"
|
||||||
|
|||||||
@@ -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"';
|
const preparationEtag = '"prepare-20260717T131400Z"';
|
||||||
|
|
||||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
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/);
|
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(
|
const spatialStyles = await readFile(
|
||||||
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
||||||
"utf8",
|
"utf8",
|
||||||
@@ -422,9 +431,10 @@ test("recording preparation statuses share the viewer's left alignment", async (
|
|||||||
spatialStyles.indexOf(".scene-operation-status {"),
|
spatialStyles.indexOf(".scene-operation-status {"),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.match(statusStack, /left:\s*0\.85rem/);
|
assert.match(statusStack, /right:\s*0\.85rem/);
|
||||||
assert.match(statusStack, /justify-items:\s*start/);
|
assert.match(statusStack, /top:\s*0\.85rem/);
|
||||||
assert.doesNotMatch(statusStack, /right:/);
|
assert.match(statusStack, /justify-items:\s*end/);
|
||||||
|
assert.doesNotMatch(statusStack, /left:|bottom:/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
||||||
|
|||||||
@@ -150,14 +150,15 @@ test("follow toggles retain the current recorded camera journal", () => {
|
|||||||
assert.notEqual(afterReset, before);
|
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 bounds = { width: 1280, height: 720 };
|
||||||
const left = initialObservationWindowRect(0, 2, bounds);
|
const left = initialObservationWindowRect(0, 2, bounds);
|
||||||
const right = initialObservationWindowRect(1, 2, bounds);
|
const right = initialObservationWindowRect(1, 2, bounds);
|
||||||
|
|
||||||
assert.equal(left.y, right.y);
|
assert.equal(left.y, right.y);
|
||||||
assert.ok(left.x + left.width < right.x);
|
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);
|
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", () => {
|
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
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(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
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", () => {
|
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 () => {
|
test("recorded point colors use one strict same-origin component overlay", async () => {
|
||||||
const endpoint = resolveRecordedPointColorsUrl(
|
const endpoint = resolveRecordedPointColorsUrl(
|
||||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||||
|
|||||||
@@ -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\)/);
|
||||||
|
});
|
||||||
@@ -1,101 +1,42 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
import { after, before, test } from "node:test";
|
import { after, before, test } from "node:test";
|
||||||
import { createServer } from "vite";
|
import { createServer } from "vite";
|
||||||
|
|
||||||
let server, createRecordedRerunCameraJournal, initialEye;
|
let server, readNativeRerunCameraEye;
|
||||||
before(async () => {
|
before(async () => {
|
||||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||||
server: { middlewareMode: true } });
|
server: { middlewareMode: true } });
|
||||||
const module = await server.ssrLoadModule(
|
({ readNativeRerunCameraEye } = await server.ssrLoadModule(
|
||||||
"/src/components/rerun/recordedRerunCameraJournal.ts");
|
"/src/components/rerun/recordedRerunCameraJournal.ts"));
|
||||||
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
|
|
||||||
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
|
|
||||||
});
|
});
|
||||||
after(async () => { await server?.close(); });
|
after(async () => { await server?.close(); });
|
||||||
|
|
||||||
class FakeEvent {
|
test("camera snapshot copies exact native pose, without approximating input", () => {
|
||||||
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
|
const native = { position: [287, -74, 8], lookTarget: [286, -72, 0], eyeUp: [0, 0, 1] };
|
||||||
}
|
const eye = readNativeRerunCameraEye(native);
|
||||||
|
assert.deepEqual(eye, native);
|
||||||
class FakeTarget {
|
native.position[0] = 999;
|
||||||
listeners = new Map();
|
assert.equal(eye.position[0], 287);
|
||||||
emitted = [];
|
assert.equal(readNativeRerunCameraEye(null), null);
|
||||||
addEventListener(type, listener) {
|
});
|
||||||
const listeners = this.listeners.get(type) ?? [];
|
|
||||||
listeners.push(listener); this.listeners.set(type, listeners);
|
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] }]) {
|
||||||
removeEventListener(type, listener) {
|
assert.throws(() => readNativeRerunCameraEye(value), /Invalid native Rerun camera/);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
const radius = (eye) => Math.hypot(
|
test("iframe owner reads the native camera and installs no shadow input listeners", () => {
|
||||||
eye.position[0] - eye.lookTarget[0],
|
const owner = readFileSync(new URL("../src/components/rerun/recordedRerunOwner.ts", import.meta.url), "utf8");
|
||||||
eye.position[1] - eye.lookTarget[1],
|
const camera = readFileSync(new URL("../src/components/rerun/recordedRerunCameraJournal.ts", import.meta.url), "utf8");
|
||||||
eye.position[2] - eye.lookTarget[2],
|
assert.match(owner, /get_camera_eye\?\.\(\)/);
|
||||||
);
|
assert.doesNotMatch(owner + camera, /createRecordedRerunCameraJournal|addEventListener|Math\.exp/);
|
||||||
|
});
|
||||||
test("recorded orbital eye tracks only 3D viewport navigation", () => {
|
|
||||||
const scope = new FakeTarget();
|
test("display blueprint activation carries the current native eye, not the embedded preset", () => {
|
||||||
const timers = [];
|
const viewport = readFileSync(new URL("../src/components/RerunViewport.tsx", import.meta.url), "utf8");
|
||||||
Object.assign(scope, {
|
assert.match(viewport, /!enablingFollow \? active\.getCameraEye\?\.\(\) \?\? undefined : undefined/);
|
||||||
WheelEvent: FakeEvent,
|
assert.match(viewport, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,/);
|
||||||
requestAnimationFrame(callback) { callback(); return 1; },
|
assert.match(viewport, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -59,6 +59,17 @@ function bridge(native, mount = {}) {
|
|||||||
return parent;
|
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) => {
|
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
||||||
const f = fixture(t, { stopFails: true });
|
const f = fixture(t, { stopFails: true });
|
||||||
const scope = createIsolatedRerunHost(f.host);
|
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,/);
|
||||||
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
|
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
|
||||||
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
|
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.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import test from "node:test";
|
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 packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||||
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
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 application = readJson(resolve(root, "package.json"));
|
||||||
const installed = readJson(resolve(packageRoot, "package.json"));
|
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||||
|
|
||||||
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||||
assert.equal(installed.version, "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", () => {
|
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,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\)/);
|
||||||
|
});
|
||||||
@@ -13,7 +13,8 @@ before(async () => {
|
|||||||
after(async () => { await server?.close(); });
|
after(async () => { await server?.close(); });
|
||||||
|
|
||||||
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
|
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
|
||||||
viewportRef: { current: null }, primaryFocused: focused, toolbar: null, renderer: null,
|
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'),
|
sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'),
|
||||||
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
|
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
|
||||||
metrics: createElement('div', null, 'METRICS'),
|
metrics: createElement('div', null, 'METRICS'),
|
||||||
@@ -37,6 +38,18 @@ test('focus exit remains viewport-owned outside the hidden information stack', (
|
|||||||
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
|
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 () => {
|
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 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 responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
|
||||||
@@ -48,3 +61,13 @@ test('scene layout uses flow, retains compact metrics and removes only the calib
|
|||||||
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
|
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
|
||||||
assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/);
|
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,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:
|
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
|
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.
|
- **Test contour → Laboratory contours** contains derived experimental runs.
|
||||||
Examples: LAB E24, E25, E26, E28 and E29. A LAB run references an original
|
Examples: LAB E24, E25, E26, E28 and E29. A LAB run references an original
|
||||||
record and never becomes another original capture.
|
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
|
The backend may keep both entities in one durable SQLite catalog, but every
|
||||||
consumer must request an explicit catalog scope:
|
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=laboratory` for LAB projections;
|
||||||
- `scope=all` only for internal joins that must resolve both a derivative and
|
- `scope=all` only for internal joins that must resolve both a derivative and
|
||||||
its source.
|
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
|
Deleting, renaming or moving source payloads to make the UI look clean is
|
||||||
forbidden. Product separation is expressed by typed projections.
|
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
|
## Current reference-source policy
|
||||||
|
|
||||||
RAVNOVES00 is the sole active physical reference source for the current
|
RAVNOVES00 is the sole active physical reference source for the current
|
||||||
|
|||||||
@@ -3,6 +3,10 @@
|
|||||||
Date: 2026-08-30
|
Date: 2026-08-30
|
||||||
Status: accepted; RAVNOVES004TREE is the first migrated full-route LAB
|
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
|
Implementation audit, 2026-09-05: see the
|
||||||
[complete customization inventory](../audits/2026-09-05-rerun-customization-inventory.md)
|
[complete customization inventory](../audits/2026-09-05-rerun-customization-inventory.md)
|
||||||
and [upstream provenance evidence](../audits/2026-09-05-rerun-upstream-evidence.json).
|
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.
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
# Corrected session as the operator and laboratory default
|
||||||
|
|
||||||
|
## Decision and product surface
|
||||||
|
|
||||||
|
Owner direction supersedes the earlier *view-only* admission in
|
||||||
|
`2026-09-21-map-comparison-view.md`: an explicitly reviewed correction becomes
|
||||||
|
the ordinary representation of the same physical session in Data, and the
|
||||||
|
default reference when that recording is selected for a new planner study.
|
||||||
|
Original evidence stays immutable and available in Information → comparison.
|
||||||
|
|
||||||
|
This is domain content in an admitted composition (class A), not a new catalog
|
||||||
|
row, root, workspace, solver or viewer. Rejected alternatives: duplicating the
|
||||||
|
capture under another name, and connecting only the bounded preview while the
|
||||||
|
planner still consumes original geometry. Canonical SegmentedControl,
|
||||||
|
RangeControl and LoadingRegion remain the only UI primitives used here.
|
||||||
|
|
||||||
|
States: no activated correction → original; activated correction → corrected;
|
||||||
|
missing/corrupt selected correction → explicit error, never silent original
|
||||||
|
fallback. Comparison is an optional inspector control, not a different default
|
||||||
|
policy. Initial overview geometry remains hidden until the selected
|
||||||
|
representation is applied. Version and clipping changes retain the viewer and
|
||||||
|
camera; only explicit camera controls reset the view.
|
||||||
|
|
||||||
|
## Architecture and source ownership
|
||||||
|
|
||||||
|
`SessionMapVersions` stores immutable complete bundles in application data and
|
||||||
|
atomically selects one generation for each physical session. Activation is an
|
||||||
|
explicit offline operation after review, not automatic acceptance of any
|
||||||
|
solver output. `activate_session_map.py` verifies the original transport,
|
||||||
|
index, frozen clock and origin before admission. The pointer grants only
|
||||||
|
recorded playback and laboratory reference use. It cannot authorize movement.
|
||||||
|
|
||||||
|
Data uses `MapReplaySessionStore`, a read-only playback facade over the real
|
||||||
|
catalog. `ReplayCommand.map_version` pins the geometry generation before work
|
||||||
|
is queued. Native capture confinement remains unchanged; three separately
|
||||||
|
verified projection inputs (manifest, complete points, trajectory) extend the
|
||||||
|
preparation/cache identity. A raw ready-job cannot satisfy a corrected launch.
|
||||||
|
The existing single background worker and seekable RRD path are reused.
|
||||||
|
|
||||||
|
The K1 exporter reads the exact corrected cloud frame and corrected pose,
|
||||||
|
including orientation, at the corresponding original receipt time. The raw
|
||||||
|
capture clock, duration, media and event sequence are unchanged. Map timestamps
|
||||||
|
are relative to the first raw message, not the first pose or capture start.
|
||||||
|
All cloud/pose ownership is checked before publication. The normal operator
|
||||||
|
RRD's existing temporal display sampling is retained; no 180k-point preview is
|
||||||
|
substituted for playback or registration data. Point-color overlays use the
|
||||||
|
same corrected positions and preserve observation count/order. Uncorrected
|
||||||
|
perception overlays are rejected instead of mixing two spatial frames.
|
||||||
|
|
||||||
|
New planner selections resolve through `DefaultPlanningSources.get`, retaining
|
||||||
|
the physical session's name. Existing studies resolve their pinned generation
|
||||||
|
through `bound`/`verify`: old studies retain original geometry, and corrected
|
||||||
|
studies use their immutable map even if the default later changes. The full
|
||||||
|
map and trajectory feed the existing reference extraction/preparation. No
|
||||||
|
registration acceptance thresholds or search rules were weakened.
|
||||||
|
|
||||||
|
Planner preview requests also carry the reference generation. An existing
|
||||||
|
original study must not display a newly corrected default as its old reference.
|
||||||
|
Information retains both representations and original acquisition metrics.
|
||||||
|
|
||||||
|
## Scope and verification
|
||||||
|
|
||||||
|
Original capture bytes and the previously reviewed correction/solver seal are
|
||||||
|
unchanged. The correction remains a regularized estimate validated against
|
||||||
|
this capture, not independent proof of positioning accuracy. Admission is
|
||||||
|
for the next supervised scanner experiment, not rover movement.
|
||||||
|
|
||||||
|
### Acceptance completed
|
||||||
|
|
||||||
|
- Frontend architecture checks, typecheck, all 885 frontend tests, and the
|
||||||
|
production build passed sequentially. Focused Python suites passed: the
|
||||||
|
162-test integration selection, the 70-test overview/API/live-planning
|
||||||
|
selection, and the final 31-test recording/exporter-capability selection
|
||||||
|
(these selections overlap; their counts are not additive).
|
||||||
|
- Synthetic coverage verifies durable activation, historical generation pins,
|
||||||
|
fail-closed missing/corrupt defaults, native path confinement, source digest
|
||||||
|
ownership, corrected frame/pose projection, and unchanged replay timing.
|
||||||
|
- The reviewed full correction was explicitly activated in application data;
|
||||||
|
no physical session was duplicated, and no original capture was modified.
|
||||||
|
Canonical source lookup now returns the same selected map generation as
|
||||||
|
recorded playback and the Information comparison.
|
||||||
|
- Actual operator RRD inspection matched all 1,036 displayed cloud frames to
|
||||||
|
their complete-map counterparts and all 5,182 poses/orientations to the
|
||||||
|
corrected trajectory. Original timing (597.439503125 seconds) and media
|
||||||
|
timing remain unchanged. The ordinary operator display sampling is retained.
|
||||||
|
- Isolated full-route planner preparation used the selected corrected source,
|
||||||
|
produced 15 reference tiles and 694,420 voxel points, and verified all 5,182
|
||||||
|
trajectory poses across approximately 579.98 metres. It did not persist a
|
||||||
|
real study, issue device commands, or grant vehicle-control authority.
|
||||||
|
- Browser acceptance on canonical port 8000: ordinary recording-name selection
|
||||||
|
opens playback; Information initially selects Corrected; Original/Corrected
|
||||||
|
switching works; window restore/expand works and Escape closes Information.
|
||||||
|
A new planner selection of the same ordinary name displays Corrected and
|
||||||
|
579.98 metres. Selecting Whole trajectory includes all 5,182 positions.
|
||||||
|
No browser console errors were reported during this acceptance.
|
||||||
|
- One canonical backend remains listening on 8000; no backend listens on 8765.
|
||||||
|
|
||||||
|
### Next supervised experiment
|
||||||
|
|
||||||
|
Use a **new** planner study, the ordinary physical recording name, and **Whole
|
||||||
|
trajectory** so the reference includes both sides of the closure. Existing
|
||||||
|
studies deliberately retain their pinned generation. Try several stationary
|
||||||
|
initializations near the seam, including offset positions, then short walks
|
||||||
|
through it after tracking is confirmed. Inspect those runs before extending to
|
||||||
|
kilometre-scale routes. This admission is not an independent measurement of
|
||||||
|
localization accuracy or autonomous-driving readiness.
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# Complete route search before provisional localization
|
||||||
|
|
||||||
|
## Decision and scope
|
||||||
|
|
||||||
|
Owner-approved follow-up to the [entry/seam audit](2026-09-21-ring-entry-and-seam-review.md).
|
||||||
|
Fix cold-start search scheduling, retain the precise start-area solver and all
|
||||||
|
geometric/fresh-data gates. No device commands, threshold relaxation, map
|
||||||
|
correction changes, endpoint snapping or retroactive edit of the failed study.
|
||||||
|
|
||||||
|
`route-relocalization/v7` compares the complete selected route before choosing a
|
||||||
|
provisional place. A finite hypothesis queue replaces the shared 35-second
|
||||||
|
deadline. `stationary-fresh-bootstrap/v4` separates search geometry from current
|
||||||
|
localization evidence; an old prefix can never establish tracking directly.
|
||||||
|
|
||||||
|
## Algorithm and boundaries
|
||||||
|
|
||||||
|
1. Collect the existing 10-second stationary prefix.
|
||||||
|
2. Run all 108 established dense-start seeds at the start/last confirmed hint.
|
||||||
|
Keep a qualified dense candidate, but do not accept it before route comparison.
|
||||||
|
A missing local start target does not prohibit searching the remaining map.
|
||||||
|
3. Index the complete reference and rank all geometrically usable route anchors,
|
||||||
|
spaced by the existing 5-m policy. Ranking changes order, never eligibility.
|
||||||
|
4. At each anchor, test the existing three yaw proposals with two translations:
|
||||||
|
sensor entry to route anchor, and the former cloud-median placement. One
|
||||||
|
prepared local target tree is shared by all six fits. No atlas density cap
|
||||||
|
or new thinning is introduced.
|
||||||
|
5. Compare the resulting SE(3) clusters together with the dense-start candidate.
|
||||||
|
A distinct similarly good place remains ambiguous; first passing fit is not
|
||||||
|
accepted. A failed fresh trial can advance to the next unambiguous candidate,
|
||||||
|
with a new receipt fence and no reused validation data.
|
||||||
|
6. During search, continue checking source identity/order, receipt segment,
|
||||||
|
pose/cloud freshness and stationary translation (existing 0.10-m envelope).
|
||||||
|
Movement or broken input invalidates the hypothesis. The existing 40-second
|
||||||
|
prefix-age fence remains for the legacy local protocol, not for v7's explicitly
|
||||||
|
monitored stationary whole-route search.
|
||||||
|
7. After the result arrives, discard all pre-result receipts for confirmation.
|
||||||
|
Three consistent fresh geometric checks remain mandatory before tracking.
|
||||||
|
Ordinary tracking, local reference extraction, quality thresholds and the
|
||||||
|
8-second fresh-result gate are unchanged.
|
||||||
|
|
||||||
|
The numerical child emits progress after individual work units. The supervisor
|
||||||
|
allows arbitrary total search duration while work advances; 60 seconds with no
|
||||||
|
progress is a **worker stall**, not evidence that the location is unknown.
|
||||||
|
STOP, source end, moved-search cancellation and teardown terminate/reap that
|
||||||
|
exact child. A queued operator retry waits for its release and starts with a new
|
||||||
|
prefix. Recording remains owned by the device plugin.
|
||||||
|
|
||||||
|
The candidate queue stays in one worker across scheduling batches; this is not
|
||||||
|
a durable checkpoint/resume facility after a process crash. A failed worker
|
||||||
|
returns incomplete search and offers reinitialization without stopping recording.
|
||||||
|
Operator copy uses the existing status surface under the product UI canon.
|
||||||
|
|
||||||
|
## Real saved-input regressions
|
||||||
|
|
||||||
|
Same immutable run `473870e0-5210-452c-b9e4-9d7937e93646`, query
|
||||||
|
`20260921T103511Z_viewer_live`, corrected full reference
|
||||||
|
`JA-STROITEL-SUN-RING · correction v2`, 579.98 m / 694,420 points.
|
||||||
|
Only original initialization arrays enter the production worker. No end pose,
|
||||||
|
later successful transform, manually selected anchor or offline deadline override.
|
||||||
|
One CPU worker at a time; numerical thread counts fixed to one.
|
||||||
|
|
||||||
|
| Original attempt | New result | Surface overlap | Inlier RMSE | Worker wall |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| First, failed cold start near route end | Candidate at anchor 113 / 565 m | 98.8467% | 0.14330 m | 138.58 s |
|
||||||
|
| Second, familiar start | Candidate at start | 98.7191% | 0.13466 m | 156.52 s |
|
||||||
|
|
||||||
|
Both searches completed 810/810 fits: 108 dense + 117 anchors × 3 yaws × 2 seeds.
|
||||||
|
No remaining route anchors; no competing qualified location close enough to
|
||||||
|
trigger ambiguity. The second case retained the dense-start solution rather
|
||||||
|
than degrading its target geometry.
|
||||||
|
|
||||||
|
All 159 files of the original saved study were hashed before/after each replay
|
||||||
|
and remained unchanged. Raw evidence and generated arrays/results remain in the
|
||||||
|
ignored private `2026-09-21-full-route-fix` audit directory. The earlier study
|
||||||
|
still truthfully records its failed first initialization.
|
||||||
|
|
||||||
|
## Validation and review
|
||||||
|
|
||||||
|
- 121 focused backend tests passed: route coverage, last-ranked candidate,
|
||||||
|
dense/global competition, median-seed regression, anchor-origin placement,
|
||||||
|
multi-minute search, stationary continuity, distinct fresh windows, stale and
|
||||||
|
ambiguous rejection, live recovery, cancellation, same-recording retry,
|
||||||
|
child stall/forced cleanup and corrected-map source admission.
|
||||||
|
- Focused Ruff and `git diff --check` passed.
|
||||||
|
- Replacing median placement outright failed the existing lateral-offset GICP
|
||||||
|
regression; therefore both initializations are retained. No quality threshold
|
||||||
|
was weakened to make that test pass.
|
||||||
|
- Registration/correction producer files and reference generations are unchanged.
|
||||||
|
- Application architecture checks, TypeScript, all 887 frontend tests and the
|
||||||
|
production build passed sequentially. Only the existing large-chunk build
|
||||||
|
warning remains. Normal/expanded planner, latest ring evidence and Escape menu
|
||||||
|
dismissal were checked in the existing in-app browser; no live state was faked.
|
||||||
|
- With acquisition idle and the old study completed, the canonical LaunchAgent
|
||||||
|
was restarted. Health/liveness passed on the sole port-8000 backend (PID 64086);
|
||||||
|
no backend on 8765 and no temporary search/test workers remain. New captures
|
||||||
|
use v7; the old report continues to expose its original v6 evidence.
|
||||||
|
|
||||||
|
## Limitations and next field acceptance
|
||||||
|
|
||||||
|
Completeness is relative to the declared finite anchor/yaw/seed policy, not a
|
||||||
|
mathematical proof that every pose in continuous space was searched. Descriptor
|
||||||
|
ranking remains imperfect; queue completeness prevents a low rank from excluding
|
||||||
|
the real place, but is not a fast kilometer-scale place-recognition index.
|
||||||
|
|
||||||
|
Full comparison increases even familiar-start latency to roughly 2–3 minutes on
|
||||||
|
this 580-m map. This is an explicit regression in speed, accepted here for complete
|
||||||
|
comparison, not a claim of final optimization. Target preprocessing is reused;
|
||||||
|
safe coarse-to-fine acceleration still needs independent evidence.
|
||||||
|
|
||||||
|
Offline replay proves the corrected numerical cold-start outcome, **not** live
|
||||||
|
stationary continuity or independent absolute pose accuracy. Synthetic tests
|
||||||
|
prove the temporal contract; the operator must confirm it on a new capture.
|
||||||
|
Surface overlap/RMSE are not position accuracy or autonomous-driving acceptance.
|
||||||
|
|
||||||
|
Field check: start at the formerly rejected place (last point of the old pass),
|
||||||
|
leave the scanner stationary through search and fresh confirmation, then walk
|
||||||
|
20–30 m across the seam and back. Record the time to tracking and any interruption.
|
||||||
|
No longer route is needed to answer this regression question.
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
# Large-ring offline qualification — 21 September 2026
|
||||||
|
|
||||||
|
## Scope and retained authority
|
||||||
|
|
||||||
|
The owner supplied `JA-STROITEL-SUN-RING-002`, session
|
||||||
|
`20260921T134309Z_viewer_live`, and requested source, drift, correction and
|
||||||
|
offline-localization testing without another physical capture. The finish was
|
||||||
|
approximately 3–5 m beyond the physical start. Endpoint equality is therefore
|
||||||
|
specifically prohibited as a fitting constraint.
|
||||||
|
|
||||||
|
All geometry, detailed results, scripts and SHA-256 inventories are private under
|
||||||
|
`.runtime/audits/2026-09-21-large-ring-002/`. No hardware command, source overwrite,
|
||||||
|
map activation, planner mutation, production-code edit, service restart or motor
|
||||||
|
authority is part of this audit. Earlier unrelated uncommitted work is retained.
|
||||||
|
|
||||||
|
## Source acceptance
|
||||||
|
|
||||||
|
- Saved capture: 1,446.684 s; catalog and RRD preparation are ready.
|
||||||
|
- Spatial trajectory: 1,595.409 m over 1,362.429 s of pose receipts.
|
||||||
|
- 13,608 cloud frames, 13,624 poses, 49,217,854 decoded points.
|
||||||
|
- All 31,450 metadata records cover the complete 573,728,851-byte transport.
|
||||||
|
- Every payload SHA-256, topic, length and frame offset was checked against
|
||||||
|
metadata: 31,450/31,450 agree, zero mismatches.
|
||||||
|
- Transport SHA-256 agrees with its capture manifest. Cloud/pose sequences have
|
||||||
|
no missing numbers or resets; decoded geometry and receipt order are valid.
|
||||||
|
- No capture reconnect, rejected transport message or recovery gap. Maximum
|
||||||
|
cloud receipt gap is 1.344 s; maximum pose receipt gap is 1.189 s.
|
||||||
|
- No pose discontinuity under the existing live criterion; largest successive
|
||||||
|
position step is 0.1515 m. Single-receipt speed is not physical velocity:
|
||||||
|
transport bursts compress receipt intervals. Five-second travel averages
|
||||||
|
reach 1.501 m/s. Hardware synchronization is not established by these checks.
|
||||||
|
- Nearest pose-to-cloud receipt gap: p95 42.7 ms, maximum 180.8 ms. These are
|
||||||
|
host-clock observations, not per-point LiDAR firing-time reconstruction.
|
||||||
|
|
||||||
|
The raw endpoint displacement is 4.000 m horizontally and +1.5875 m vertically,
|
||||||
|
4.3034 m in 3D. This includes real operator displacement and is NOT a surveyed
|
||||||
|
drift measurement. The corrected result must retain the real overshoot.
|
||||||
|
|
||||||
|
The control-plane acquisition ended with
|
||||||
|
`acquisition.stop.accepted_physical_outcome_unknown`, cleanup pending false.
|
||||||
|
This is a separate physical-stop confirmation issue. The receiver reports
|
||||||
|
`external_stop`, no transport error, and the saved recording is ready; do not
|
||||||
|
misreport that control state as corruption of the completed cloud.
|
||||||
|
|
||||||
|
## Existing recipe failure and diagnostic variant
|
||||||
|
|
||||||
|
The unchanged `recorded-ring-experiment/v1` recipe stopped before producing a
|
||||||
|
correction. Its first-20-s/last-5-s fit converged: overlap 95.317%, inlier RMSE
|
||||||
|
0.1641 m, correction at the patch center 4.2176 m / 2.6199 degrees. Rejection was
|
||||||
|
solely the ordinary local-tracking correction guard of 3 m.
|
||||||
|
|
||||||
|
Merely changing that guard is insufficient. Four disjoint five-second tail
|
||||||
|
probes using the existing coarse acquisition policy failed the unchanged
|
||||||
|
bidirectional-consistency gate. The first fit's cycle was 0.0280 m / 0.6007
|
||||||
|
degrees, against the existing 0.1 m / 0.2-degree limits.
|
||||||
|
|
||||||
|
A declared training-only grid of first windows [10, 20, 40] s and last windows
|
||||||
|
[10, 20, 30] s was evaluated without using withheld frames. Two of nine passed
|
||||||
|
the existing quality and cycle gates: 20/30 and 40/30. The larger 40/30 support
|
||||||
|
was selected before held-out evaluation. It yields 72.777% overlap, 0.1875 m
|
||||||
|
inlier RMSE and a 0.0194 m / 0.0973-degree bidirectional cycle. Its lower overlap
|
||||||
|
reflects a different, larger point population; it is not comparable to the
|
||||||
|
short-window 95% as an identical-denominator score.
|
||||||
|
|
||||||
|
This private adapter uses the already defined route-acquisition registration
|
||||||
|
policy for the first coarse seam fit only. Ordinary local registration,
|
||||||
|
shape/information gates, overlap/RMSE thresholds, reverse consistency, solver and
|
||||||
|
validation policy remain unchanged. Production modules were not patched.
|
||||||
|
|
||||||
|
All 80 attempted neighboring-window links failed overlap and RMSE gates; ten
|
||||||
|
also failed convergence. None was admitted. The reconstruction is therefore
|
||||||
|
one measured closure plus a smoothness prior, NOT recovery of a densely
|
||||||
|
measured drift history. The input is K1 mapped increments, not native LiDAR
|
||||||
|
sweeps from which a new independent SLAM solution was reconstructed.
|
||||||
|
|
||||||
|
## Frozen candidate and held-out evaluation
|
||||||
|
|
||||||
|
The existing `smooth-map-correction-experiment/v2` spline solver and full-frame
|
||||||
|
materializer produced a separate experimental candidate. Every ten seconds,
|
||||||
|
the two-second phase 4–6 interval was withheld: 2,855 frames, with 10,753 retained
|
||||||
|
for fitting. Shared upstream K1 mapping means this split is not independent
|
||||||
|
survey truth, despite disjoint frame membership.
|
||||||
|
|
||||||
|
| Measurement | Original | Diagnostic corrected candidate |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Local held-out windows accepted | 134/136 | 136/136 |
|
||||||
|
| Median local overlap, 0.5 m radius | 98.077% | 98.081% |
|
||||||
|
| Median local inlier RMSE | 0.16495 m | 0.16563 m |
|
||||||
|
| Cross-visit held-out overlap | 22.723% | 95.093% |
|
||||||
|
| Cross-visit median all-point distance | 1.2018 m | 0.1186 m |
|
||||||
|
| Cross-visit p95 all-point distance | 2.5941 m | 0.4914 m |
|
||||||
|
| Endpoint XY separation | 4.000 m | 2.801 m |
|
||||||
|
| Endpoint Z difference | +1.5875 m | −0.0112 m |
|
||||||
|
| Trajectory length | 1,595.409 m | 1,599.807 m |
|
||||||
|
|
||||||
|
Cross-visit evaluation fixes the same 7,499 source points in 18 held-out tail
|
||||||
|
frames, compares them only to retained first-20-s geometry, and performs no
|
||||||
|
additional fit. Those query frames did not enter closure selection or fitting.
|
||||||
|
The source and corrected failed-window checks use the same frozen priors;
|
||||||
|
fresh one-second halves in original failure groups 74 and 120 all qualify.
|
||||||
|
|
||||||
|
The remaining 2.8 m endpoint separation is compatible with, but does not survey,
|
||||||
|
the operator's 3–5 m overshoot. Near-zero endpoint Z is not centimetre absolute
|
||||||
|
accuracy. A fit between coincident-looking endpoints was never imposed.
|
||||||
|
|
||||||
|
Maximum trajectory displacement is 8.164 m, maximum displacement gradient
|
||||||
|
0.04123 m per travelled metre (p95 0.03768). Changing regularizer strength by
|
||||||
|
0.5×/2× changes trajectory positions by at most 0.009 mm; this is numerical
|
||||||
|
prior stability, NOT proof of the actual spatial distribution of drift.
|
||||||
|
Using the other qualifying 20/30-second closure measurement instead changes
|
||||||
|
the corrected trajectory by up to 0.2483 m (p95 0.2262 m). This measurement-window
|
||||||
|
sensitivity is materially larger than regularizer sensitivity and is retained
|
||||||
|
as model uncertainty, not hidden by the visually closed seam.
|
||||||
|
|
||||||
|
All 49,217,854 materialized points and all 13,624 corrected poses were reproduced
|
||||||
|
from the frozen field. Within-frame motion is rigid. Maximum coordinate
|
||||||
|
serialization error is 0.0306 mm; sampled intra-frame pair-distance error is
|
||||||
|
0.0601 mm. No count, intensity, frame order or raw source was discarded.
|
||||||
|
|
||||||
|
## Independent archived passes and complete-route search
|
||||||
|
|
||||||
|
The production versioned-map extractor and tiled reference builder were reused
|
||||||
|
through a private adapter, not through an admitted catalog generation. The
|
||||||
|
whole route yields 1,924,498 original and 1,940,166 corrected registration
|
||||||
|
points. Preparation took 25.48 / 26.67 s. No 30 m crop or whole-map point-count
|
||||||
|
cap was introduced. Presentation's 80 m envelope is not the local numerical
|
||||||
|
matching profile.
|
||||||
|
|
||||||
|
The first independent query is the original cold prefix from study
|
||||||
|
`473870e0-5210-452c-b9e4-9d7937e93646`, captured in a different session. Only its
|
||||||
|
original query points and sensor origin are loaded, not its old reference or
|
||||||
|
accepted transform. Full production v7 search tests all 2,034 fits: 108 dense
|
||||||
|
start hypotheses and 1,926 route hypotheses.
|
||||||
|
|
||||||
|
| Same independent cold input | Original atlas | Corrected atlas |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Complete search | 2,034/2,034 | 2,034/2,034 |
|
||||||
|
| Result | Candidate | Candidate |
|
||||||
|
| Time | 320.41 s | 317.07 s |
|
||||||
|
| Overlap | 98.899% | 98.978% |
|
||||||
|
| Inlier RMSE | 0.1493 m | 0.1449 m |
|
||||||
|
|
||||||
|
This does not demonstrate a material speedup or inability to localize against
|
||||||
|
the original. Correction's demonstrated benefit is cross-visit map agreement.
|
||||||
|
|
||||||
|
The corrected atlas then accepts all 29 stored fresh snapshots over the
|
||||||
|
101.633 m independent walk using a causal previous-accepted-transform chain.
|
||||||
|
Overlap is 98.546–99.823%, inlier RMSE 0.1181–0.1501 m; measured local calculation
|
||||||
|
time is 0.073–0.105 s. Real recorded request/receipt clocks are retained; measured
|
||||||
|
rerun calculation duration is substituted. This is an archived-snapshot replay,
|
||||||
|
NOT a live ingress, camera, scheduler, motion-stationarity or recovery test.
|
||||||
|
|
||||||
|
The original atlas also accepts all 29 snapshots of that short pass. Thus this
|
||||||
|
test proves compatibility of the corrected version, not a general superiority
|
||||||
|
claim for every metric.
|
||||||
|
|
||||||
|
A second independent cold prefix from study
|
||||||
|
`0324337e-b590-4561-b19a-8fbc7835b888` also qualifies after all 2,034 fits:
|
||||||
|
352.94 s, 99.108% overlap, 0.1438 m inlier RMSE. Replaying its entire old smaller
|
||||||
|
ring against the new larger one is **not all-positive**: 30/101 snapshots qualify,
|
||||||
|
with first rejection at 132.265 m. Reference-path proximity under the last
|
||||||
|
accepted transform grows from 12.2 m at the last positive window to 18.2 m at
|
||||||
|
first rejection, then approximately 72 m. This supports a different-route /
|
||||||
|
out-of-localization-coverage explanation, not a claim that the two rings cover
|
||||||
|
the same corridor. It is not ground truth during the lost interval.
|
||||||
|
|
||||||
|
Near the old pass's return to the shared area, overlap again reaches 99.25–99.64%
|
||||||
|
and inlier RMSE about 0.139 m, but correcting the obsolete local prior requires
|
||||||
|
3.15–3.20 m and is correctly rejected by ordinary tracking policy. This replay
|
||||||
|
deliberately has no complete recovery/bootstrap state machine. It neither proves
|
||||||
|
nor disproves physical reinitialization after returning; a fresh stationary
|
||||||
|
relocalization is a separate operation, not continued green tracking on the old
|
||||||
|
hint. The original and failed cases are retained in full.
|
||||||
|
|
||||||
|
The middle-route probe selects held-out time group 68 without looking for a
|
||||||
|
favorable fit: source progress 794.812 m, 11,625 query points. All 2,855 held-out
|
||||||
|
frames are excluded from the atlas; query coordinates are translated to a new
|
||||||
|
origin and rotated 90 degrees. The generating correction is used only to
|
||||||
|
measure post-fit model consistency, never as the search seed.
|
||||||
|
|
||||||
|
Complete search qualifies after 2,034/2,034 fits in 452.16 s: overlap 98.545%,
|
||||||
|
inlier RMSE 0.1625 m, selected retrieval anchor at 800 m. Fitted sensor position
|
||||||
|
is 0.0461 m from the generating model, NOT surveyed truth. This is a same-source
|
||||||
|
numeric place-retrieval test with shared upstream K1 mapping. The source query
|
||||||
|
spans 3.804 m of motion; it is explicitly not a valid stationary bootstrap
|
||||||
|
prefix and must not be presented as live cold-start acceptance at the midpoint.
|
||||||
|
|
||||||
|
In total, four complete cold numerical searches performed 8,136 fits. Runtime
|
||||||
|
on this Mac ranges from 5.28 to 7.54 minutes. These are measured functional
|
||||||
|
experiments, not a stress test or a guarantee for larger routes or the onboard
|
||||||
|
computer. The full finite queue and ambiguity checks remain intact.
|
||||||
|
|
||||||
|
## Negative controls and software regressions
|
||||||
|
|
||||||
|
On real query geometry, local initial-X perturbations 0/1 m converge; 3 m is
|
||||||
|
rejected by the unchanged correction guard; 5/10/20 m fail numeric-quality
|
||||||
|
criteria and 1,000 m has no target coverage. These are software initial-guess
|
||||||
|
perturbations, NOT physical scanner-displacement acceptance. Full-route search
|
||||||
|
is a different operation and is not restricted by those local outcomes.
|
||||||
|
|
||||||
|
The fixed halfway wrong-region comparison is rejected on surface RMSE.
|
||||||
|
Applying real positive geometry with future, nine-second-old or prior-segment
|
||||||
|
receipt metadata is rejected in all three cases. Zero false confirmations in
|
||||||
|
this small set is not a false-acceptance-rate estimate.
|
||||||
|
|
||||||
|
All 178 focused software regressions pass: correction, registration, full-route
|
||||||
|
coverage/ambiguity/worker ownership, bootstrap freshness, recovery, replay,
|
||||||
|
live lifetime/multiple traversals, reference preparation and map-version/default
|
||||||
|
boundaries. A pre-existing Starlette/httpx deprecation warning remains. These
|
||||||
|
fixtures do not constitute physical multi-lap or onboard-computer acceptance.
|
||||||
|
|
||||||
|
## Decision boundary
|
||||||
|
|
||||||
|
The data support an experimental correction of this larger loop without
|
||||||
|
forcing the real endpoints together. The existing automatic recipe is NOT
|
||||||
|
qualified unchanged: seam acquisition and window-support selection require a
|
||||||
|
separate production change. Do not lower tracking-quality gates to conceal
|
||||||
|
that distinction.
|
||||||
|
|
||||||
|
Not proven: independent repeat accuracy throughout the newly added territory,
|
||||||
|
absolute map/pose error, correct internal drift distribution, arbitrary-loop
|
||||||
|
discovery, seasonal transfer, simultaneous live latency, target-board budget,
|
||||||
|
physical multiple laps, navigation safety or motor authority. No further live
|
||||||
|
scanner trial is needed to reproduce the present offline evidence.
|
||||||
|
|
||||||
|
The next production increment should separate offline closure acquisition from
|
||||||
|
local tracking correction, qualify window-support selection and preserve the
|
||||||
|
measured quality/cycle/holdout gates. Do not ship the experiment by silently
|
||||||
|
changing the global live 3 m guard or forcing final coordinates onto the start.
|
||||||
|
Only after that separate change and versioned admission should the candidate
|
||||||
|
be offered as the recording's corrected default. The current default is not
|
||||||
|
changed by this audit.
|
||||||
|
|
||||||
|
## Runtime and final evidence seal
|
||||||
|
|
||||||
|
Heavy checks ran sequentially with a single numerical worker on the operator
|
||||||
|
Mac; no stress workload or second application server was introduced. The final
|
||||||
|
seal verifies 311 input files unchanged and inventories 62 private artifacts.
|
||||||
|
The only non-ignored repository addition from this audit is this report.
|
||||||
|
Production modules, original experiment scripts and raw capture remain intact.
|
||||||
|
|
||||||
|
The canonical service remains the same PID 73593 on port 8000. Final health is
|
||||||
|
operational with zero consecutive reconciler failures; no temporary audit/search
|
||||||
|
worker or port-8765 listener remains. No restart or hardware action was taken.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# Corrected map comparison — view-only admission
|
||||||
|
|
||||||
|
## Product surface brief
|
||||||
|
|
||||||
|
Operator job: inspect the recorded ring before and after reviewed correction,
|
||||||
|
with identical observations, colors, clipping and camera. Primary entity remains
|
||||||
|
the physical recording; correction is a separate derived representation.
|
||||||
|
|
||||||
|
Placement: Data → Sessions and recordings → selected source → Information.
|
||||||
|
Extend the admitted overview with a canonical `SegmentedControl` labelled
|
||||||
|
“Версия облака”: “Исходное / Исправленное”. This is domain content inside an
|
||||||
|
existing composition (class A), not a new workspace, LAB or product root.
|
||||||
|
Rejected: adding another catalog session (would impersonate a physical capture),
|
||||||
|
or a separate experiment viewer (duplicates camera/lifecycle controls).
|
||||||
|
The planner's source preview does not enable comparison and remains original.
|
||||||
|
|
||||||
|
States: no admitted pair → ordinary original overview; available pair → explicit
|
||||||
|
version choice; pending update → retain previous view with an updating label;
|
||||||
|
failure → retain previous view with visible error, never silently label original
|
||||||
|
geometry as corrected. Source changes invalidate access. The information panel
|
||||||
|
explicitly refers to original capture metrics in both modes.
|
||||||
|
|
||||||
|
## Identity, geometry and authority
|
||||||
|
|
||||||
|
`overview_comparison.py` publishes a content-addressed, bounded paired geometry
|
||||||
|
bundle outside the raw session catalog. Its manifest binds original session ID,
|
||||||
|
overview generation, raw artifact digests and reviewed map generation. An atomic
|
||||||
|
pointer selects an explicitly published pair; requests pin that pair, rather than
|
||||||
|
following subsequent publications. No HTTP filesystem path input or mutation API.
|
||||||
|
|
||||||
|
The offline producer verifies the admitted map, its packaging receipt, frozen
|
||||||
|
decoded-cache seal, original transport/clocks, frame/pose ownership and current
|
||||||
|
overview. It then samples identical point and pose indices in both geometries.
|
||||||
|
The preview policy is at most 180,000 points and 20,000 poses, uniformly across
|
||||||
|
the complete capture including endpoints. These are viewer resource budgets,
|
||||||
|
not route-distance, acquisition, correction or localization limits. Full
|
||||||
|
resolution corrected and original files remain unchanged. The preview is not a
|
||||||
|
ground-truth accuracy measurement or a new live reference.
|
||||||
|
|
||||||
|
## Rerun lifetime
|
||||||
|
|
||||||
|
One original RRD supplies stable application/recording identity. Updates replace
|
||||||
|
only `/world/cloud`, `/world/route` and `/world/endpoints` through the existing
|
||||||
|
channel. No new recording, viewer mount, blueprint or camera journal update on
|
||||||
|
version or clip change. Explicit Top/3D commands may change the camera; both
|
||||||
|
representations use common bounds. Height controls start unclipped, with maximum
|
||||||
|
at least 80 m. Paired observation colors are computed once conceptually from
|
||||||
|
original heights, so a color shift cannot masquerade as geometry correction.
|
||||||
|
|
||||||
|
No Rerun fork customization, solver modification, source-session mutation,
|
||||||
|
reference promotion, vehicle authority, or frontend root change is introduced.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Python: 78 focused tests passed across overview/comparison, candidate admission,
|
||||||
|
packaging, reference preparation, planner, correction and registration. The
|
||||||
|
17 overview/comparison tests were rerun after formatting and passed. Coverage
|
||||||
|
includes exact source/generation binding, hash corruption, symlinks, path
|
||||||
|
traversal, missing version (409 rather than raw fallback), shared RRD identity,
|
||||||
|
route/endpoints replacement, unchanged colors, reversible clipping, and no
|
||||||
|
camera blueprint on representation changes.
|
||||||
|
- Frontend: architecture gate, full typecheck, all 884 unit tests and production
|
||||||
|
build passed. After the final footer-only adjustment, architecture + both
|
||||||
|
overview test files (9 tests), typecheck and production build passed again.
|
||||||
|
- Ruff and `git diff --check` passed. Existing Vite large-chunk and Starlette
|
||||||
|
httpx deprecation warnings remain; no dependency change was made for them.
|
||||||
|
- The real recorded ring was admitted as a view-only pair: 180,000 identical
|
||||||
|
point indices, all 5,182 poses, original 579.797271 m and corrected 579.982703 m.
|
||||||
|
Full-resolution original and corrected clouds remain intact. Private manifest
|
||||||
|
and generation identities live under runtime evidence, not the session catalog.
|
||||||
|
- Canonical API accepted the pair after restart. IAB visually displayed original
|
||||||
|
and corrected versions, Top/3D, and height clipping at 5 m then full 80 m.
|
||||||
|
A manually rotated view remained stable on toggling. Normal/expanded workspace
|
||||||
|
modes preserved version and clipping; Escape closed the information view.
|
||||||
|
- Final-build viewport geometry was identical before/after comparison switching:
|
||||||
|
308.828125 × 311.78125 CSS pixels at the same coordinates on the narrow IAB.
|
||||||
|
The footer uses common copy to avoid changing viewer height with the version.
|
||||||
|
Desktop 1440 × 1000 QA override was reset. The corrected real case remains
|
||||||
|
open in the canonical IAB; no alternative server or extra viewer was started.
|
||||||
|
- Resource gate: memory free percentage 40–49%; builds, tests, materialization
|
||||||
|
and browser work ran sequentially. Docker Desktop was not started.
|
||||||
|
- Ops reporting remains pending: direct `tasker_get_agent_instructions` and
|
||||||
|
`tasker_list_projects` both timed out after 60 seconds. No card ID was guessed,
|
||||||
|
no duplicate card or substitute API write was used. This report preserves the
|
||||||
|
implementation/acceptance record for the existing card when Ops is reachable.
|
||||||
|
|
||||||
|
## Decision and remaining boundary
|
||||||
|
|
||||||
|
The candidate can now be inspected in the product. That does not admit it for
|
||||||
|
live localization or autonomous movement. No new scan is necessary just to
|
||||||
|
compare the two representations. A corrected-reference replay and independent
|
||||||
|
repeat capture remain separate acceptance stages, not proved by this viewer.
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Explicit map-reference version admission
|
||||||
|
|
||||||
|
## Outcome and boundary
|
||||||
|
|
||||||
|
The reviewed smooth ring correction now has a separately sealed, content-addressed
|
||||||
|
map bundle and an **offline** adapter to the existing planning-source interface.
|
||||||
|
The whole corrected route was assembled by the unchanged `build_reference_map`
|
||||||
|
consumer. This is data-path admission, not independent localization acceptance.
|
||||||
|
|
||||||
|
The adapter is deliberately not installed in the web composition. No source
|
||||||
|
session, active reference, stored draft, live run, matcher policy or viewer was
|
||||||
|
changed. The ordinary `get(session_id)` still returns the original source; a
|
||||||
|
candidate requires its exact derivative generation. Nothing chooses “latest”.
|
||||||
|
|
||||||
|
The next integration must add an explicit version choice and matching corrected
|
||||||
|
preview in the existing planner. Selecting a corrected trajectory while showing
|
||||||
|
the raw session's overview would be a contract error. Live field instructions
|
||||||
|
are deferred until that coherent selection/preview path is verified.
|
||||||
|
|
||||||
|
## Implementation and provenance
|
||||||
|
|
||||||
|
- `reconstruction/map_version.py` owns `missioncore.map-reference-version/v1`,
|
||||||
|
full artifact hashing, exclusive staging, idempotent publication and projections.
|
||||||
|
It has no SciPy, device-plugin, session-store, viewer or hardware dependency.
|
||||||
|
- `missions/versioned_sources.py` composes the original provider with one explicitly
|
||||||
|
pinned candidate. A source generation/digest mismatch or artifact corruption
|
||||||
|
fails closed; it never falls back to different geometry under the same identity.
|
||||||
|
- `experiments/package_recorded_map_version.py` is the bounded K1 experiment
|
||||||
|
adapter. It reads the canonical source API and checks native transport, receipt
|
||||||
|
index, frozen capture clock and origin. It never imports or writes the web app.
|
||||||
|
- A reviewed evidence-seal SHA binds the original experiment code, correction,
|
||||||
|
geometry and measured reports. Packaging adds its own producer/contract hashes
|
||||||
|
and frame-reproduction receipt without rewriting those reports.
|
||||||
|
|
||||||
|
The bundle preserves full-resolution corrected XYZ, unchanged cached intensities,
|
||||||
|
pose orientations, original frame offsets/counts/sequences and receipt chronology.
|
||||||
|
Its manifest binds the original physical session, source generation and every
|
||||||
|
source-artifact digest. Separate map frame identity prevents confusing corrected
|
||||||
|
coordinates with the raw scanner frame.
|
||||||
|
|
||||||
|
The trajectory contract explicitly distinguishes `source_distance_m` (the smooth
|
||||||
|
field's original traversal parameter) from recomputed corrected `distance_m`.
|
||||||
|
Drafts and route tiling consume the latter. No first/last pose equality is added.
|
||||||
|
|
||||||
|
Multi-tile preparation uses one verified private bundle snapshot and checks the
|
||||||
|
original and derivative again before the complete map may be returned. Failure,
|
||||||
|
cancellation and exceptions clean only that temporary snapshot. Existing source
|
||||||
|
evidence and earlier versions remain untouched.
|
||||||
|
|
||||||
|
## Measured execution
|
||||||
|
|
||||||
|
On the existing ring, all **5,178 full-resolution cloud frames / 17,669,672 points**
|
||||||
|
were reproduced from the frozen correction field and compared bit-for-bit after
|
||||||
|
float32 serialization. All **5,182 corrected positions and orientations** were
|
||||||
|
checked, as were source pose indices, distances and receipt times.
|
||||||
|
|
||||||
|
The canonical source generation remained unchanged. The original trajectory is
|
||||||
|
579.797271 m; the corrected trajectory is 579.982703 m. The existing reference-map
|
||||||
|
builder assembled 15 route tiles and 694,420 voxel-retained registration points.
|
||||||
|
The packaging plus full-route assembly check took 10.36 seconds on the local CPU.
|
||||||
|
This is one measured execution, not a throughput benchmark or a scalability claim.
|
||||||
|
|
||||||
|
The source/model fidelity and held-out registration findings remain those in
|
||||||
|
`2026-09-21-smooth-map-correction.md`; packaging did not refit or remeasure them.
|
||||||
|
The full candidate map includes all source frames. Its assembly therefore must
|
||||||
|
not be reported as an additional held-out localization score.
|
||||||
|
|
||||||
|
Extraction has a declared, separate numerical profile (120 uniformly selected
|
||||||
|
frames per interval, 0.25 m voxel, 20 m radius and relative height −3…6 m), matching
|
||||||
|
the current recorded reference extraction settings. Presentation uses an 80 m
|
||||||
|
radius and no vertical crop. These are derived preparation profiles, not raw
|
||||||
|
capture limits, and the full stored bundle is not thinned. Convergence,
|
||||||
|
loss/recovery, quality thresholds and acquisition remain unchanged.
|
||||||
|
|
||||||
|
## Validation and limitations
|
||||||
|
|
||||||
|
Tests cover idempotency, separate source/version generations and draft geometry,
|
||||||
|
corrected distance calculation, raw default selection, source/frame ownership,
|
||||||
|
no second cloud transform, presentation/numerical separation, every artifact's
|
||||||
|
hash, wrong parent, unsafe paths/symlinks, nonfinite data, bad frame indices,
|
||||||
|
invalid quaternions, cancellation, exception cleanup and changes during assembly.
|
||||||
|
|
||||||
|
The focused suite passed **61 tests**, including the existing source preparation,
|
||||||
|
draft and registration regressions and the correction solver tests. Ruff and
|
||||||
|
diff whitespace checks passed. Frontend code/build and web composition were not
|
||||||
|
changed or restarted for this offline increment.
|
||||||
|
|
||||||
|
The packaging adapter initially rejected the real source because its provisional
|
||||||
|
mapping swapped capture-clock and receipt-index artifact names. Inspection of
|
||||||
|
the plugin's actual source contract corrected that mapping; a dedicated regression
|
||||||
|
now checks the frozen capture clock separately from the mutable current timeline.
|
||||||
|
No recorded bytes or source metadata were changed to make admission pass.
|
||||||
|
|
||||||
|
The original correction still has only one accepted closure and a smoothness
|
||||||
|
prior. Same-source held-out tests do not establish independent positioning truth,
|
||||||
|
and numerical GICP sensitivity remains an open issue. No new field acceptance,
|
||||||
|
automatic arbitrary-loop discovery, online SLAM replacement or navigation/safety
|
||||||
|
authority is claimed.
|
||||||
|
|
||||||
|
## Ops and local runtime
|
||||||
|
|
||||||
|
Ops access instructions, granted projects and Mission Core project context respond.
|
||||||
|
Card discovery did not recover: a full list and searches for `planning` and `кольц`
|
||||||
|
each timed out after 60 seconds. The earlier `совмещение` search returned no rows.
|
||||||
|
No issue was guessed, duplicated or overwritten, and no raw-API fallback was used.
|
||||||
|
The structured engineering update remains pending card access.
|
||||||
|
|
||||||
|
Local memory was 39% free before the sequential checks; no Docker workload or
|
||||||
|
parallel heavy validation ran. The canonical operator service on 8000 was retained.
|
||||||
|
The package and detailed private receipt remain under ignored `.runtime/analysis`.
|
||||||
|
# Subsequent view-only admission
|
||||||
|
|
||||||
|
The same reviewed candidate is now available for explicit Original/Corrected
|
||||||
|
comparison in the existing session overview. See
|
||||||
|
`2026-09-21-map-comparison-view.md` for the separate paired-preview contract,
|
||||||
|
source identity checks, UI placement and acceptance. This does not change the
|
||||||
|
reference-promotion or independent-validation boundaries documented below.
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
# Native Rerun navigation · 2026-09-21
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
The owner accepted the recorded cloud quality and asked to finish navigation.
|
||||||
|
No scanner command, recording rewrite, cloud decimation, color change or matching
|
||||||
|
algorithm change belongs to this increment. OPS receives the final result only
|
||||||
|
after completion, per the owner's explicit instruction.
|
||||||
|
|
||||||
|
## Cause and implementation
|
||||||
|
|
||||||
|
Upstream orbital pan translates in the camera's screen plane. Consequently the
|
||||||
|
target can move above the ground grid; scrolling then approaches that floating
|
||||||
|
target rather than the intended ground location. Upstream also limits orbital
|
||||||
|
zoom-out to five scene diagonals. The application previously inferred a camera
|
||||||
|
from DOM deltas; that approximation was not the rendered native camera.
|
||||||
|
|
||||||
|
ADR 0052 admits a bounded patch to the pinned upstream 0.36.3 WebViewer:
|
||||||
|
|
||||||
|
- Project orbital pan onto the native grid plane and translate the complete rig.
|
||||||
|
- Anchor untracked orbital views before rendering; stop interpolation when the
|
||||||
|
operator navigates. Ordinary orbit keeps the target fixed.
|
||||||
|
- Zoom to that target independently of scene extent, retaining only 0.02 m
|
||||||
|
near-plane protection and a finite arithmetic guard.
|
||||||
|
- Read the native rendered eye through the existing disposable iframe boundary.
|
||||||
|
Remove the approximate DOM camera journal and its viewport/radius plumbing.
|
||||||
|
- Preserve first-person navigation and explicit follow/reset/plan actions.
|
||||||
|
Display-only updates carry the actual native eye and do not seek/reload data.
|
||||||
|
Browser QA caught that omitting eye fields on blueprint activation restored
|
||||||
|
the incoming store's startup camera despite the stable view identity. The
|
||||||
|
settings boundary now preserves the operator eye explicitly.
|
||||||
|
|
||||||
|
The existing canonical hint describes LMB orbit, RMB ground-pivot pan and wheel
|
||||||
|
zoom. No new product controls, renderer, debug UI or alternative event loop were
|
||||||
|
introduced. The product UI skill kept this change inside the shared viewer.
|
||||||
|
|
||||||
|
## Build and evidence
|
||||||
|
|
||||||
|
Pinned upstream commit: `6ded109d33c549e98185f7c95fa8009d44e4adef`.
|
||||||
|
Portable patch and generated artifacts are under
|
||||||
|
`apps/control-station/vendor/rerun-web-viewer-0.36.3`.
|
||||||
|
|
||||||
|
Worker006 used a temporary 4-CPU / 16-GB build-only container, no GPU, ports or
|
||||||
|
scanner data. Native Rust camera tests passed **6/6**, including fixed-pivot
|
||||||
|
rotation, ground pan, another grid orientation, top-down pan, zoom from over
|
||||||
|
20 km to below 3 cm and invalid-input / first-person regression.
|
||||||
|
|
||||||
|
The first browser build failed because macOS AppleDouble archive metadata was
|
||||||
|
interpreted as a WGSL shader. The versioned packaging/build step now excludes
|
||||||
|
and strips that metadata inside the disposable build workspace. No shader or
|
||||||
|
upstream dependency was changed to bypass the error.
|
||||||
|
|
||||||
|
The three source files on Worker006 were SHA-256 identical to the locally
|
||||||
|
audited patch result. `navigation-build.json` records source, toolchain and
|
||||||
|
paired JS/WASM identities. The installer validates the entire set before writing
|
||||||
|
and rejects package drift rather than silently applying a patch to a new SDK.
|
||||||
|
|
||||||
|
## Acceptance record
|
||||||
|
|
||||||
|
- Native tests: **6/6** passed. The source patch applies cleanly to the pinned
|
||||||
|
pristine upstream archive.
|
||||||
|
- Final application architecture: **4/4**; TypeScript: passed; complete frontend
|
||||||
|
suite: **895/895**, no skipped tests; production build: passed (14.36 s).
|
||||||
|
- Installer ran twice successfully. Generated JS/WASM ABI and all artifact
|
||||||
|
hashes passed. Served build uses WASM SHA-256
|
||||||
|
`2b661de545ac90bb950eda1a28fc5a7c3ce4ca7f5bb24894789acf7589495185`.
|
||||||
|
- Real in-app browser, canonical port 8000, `JA-STROITEL-SUN-RING-002`, paused
|
||||||
|
at 60 s: orbit, close/far wheel zoom, point size change to 0.5 px, cloud layer
|
||||||
|
off/on, follow on/off, expanded scene and return all exercised. After the
|
||||||
|
settings-boundary correction, the manually rotated view survived display and
|
||||||
|
layer changes. No browser console errors were reported.
|
||||||
|
- Host-focused Escape returned from the expanded scene. The automation did not
|
||||||
|
establish Escape delivery with focus inside the native canvas; that gesture
|
||||||
|
is not claimed as verified. No keyboard code was changed in this increment.
|
||||||
|
- Physical two-finger/right-button dragging is not exposed by the available
|
||||||
|
browser automation. Ground-plane pan and fixed-pivot invariants are covered
|
||||||
|
by the native tests; the owner's actual trackpad feel remains manual acceptance.
|
||||||
|
- The final recorded scene is left paused for owner review. The temporary
|
||||||
|
Worker006 build container was removed after artifacts/logs were copied.
|
||||||
|
No alternate backend or temporary renderer was left running.
|
||||||
|
|
||||||
|
The owner subsequently accepted navigation ("устраивает") and authorized OPS
|
||||||
|
documentation. Card #74 now records the accepted native patch, build manifest,
|
||||||
|
upgrade/rollback procedure and verification boundaries. This does not qualify
|
||||||
|
rover operation or alter the navigation test limitations above.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
The guide grid is a geometric plane, not measured terrain. Explicit following
|
||||||
|
may move a target because that is its requested purpose. Mission Core admits one
|
||||||
|
active 3D pane per iframe; multiple simultaneous panes would require a view-id
|
||||||
|
argument for snapshots. Floating-point/near-plane safeguards are retained;
|
||||||
|
"no route-size zoom cap" does not mean infinite numeric coordinates.
|
||||||
|
|
||||||
|
The accepted full-fidelity RRD and 30-minute accumulation are unchanged. Raw and
|
||||||
|
corrected map versions do not need regeneration for this navigation update.
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# Recorded cloud fidelity and navigation — 2026-09-21
|
||||||
|
|
||||||
|
## Scope and evidence
|
||||||
|
|
||||||
|
Operator report: JA-STROITEL-SUN-RING-002 is slow to reopen, its cloud is much
|
||||||
|
sparser than the live scan, point-size changes do not appear effective, the
|
||||||
|
accumulation control stops at 120 s, and the orbital pivot moves off the grid.
|
||||||
|
No scanner commands, raw-cloud changes, alignment-threshold changes, or map
|
||||||
|
correction changes are part of this work.
|
||||||
|
|
||||||
|
The large ring has 13,608 captured point frames and 49,217,854 points over
|
||||||
|
1,446.684 s. The selected corrected map remains generation
|
||||||
|
`99f7b84541875a23aa3a8c58eb246116b66f8af03f1d2656eba4b527af36b1db`.
|
||||||
|
These are captured samples, not a count of unique surface points.
|
||||||
|
|
||||||
|
## Confirmed causes
|
||||||
|
|
||||||
|
1. The v12 RRD exporter retained only frames 1, 6, 11, … . Normal point batches
|
||||||
|
were complete, but four out of five captured scans never reached playback.
|
||||||
|
Another stride of four applied above 100,000 points per individual frame.
|
||||||
|
Neither rule altered the immutable raw capture.
|
||||||
|
2. Ordinary recorded-view settings appended blueprint rows without activating
|
||||||
|
the updated blueprint. Upstream Rerun uses an active clone; updates need
|
||||||
|
activation. The LAB costmap path already requested this, ordinary recordings
|
||||||
|
did not. A visible slider value was therefore not evidence of native state.
|
||||||
|
3. Both accumulation controls clamped to 120 seconds, independently of the
|
||||||
|
duration of the source.
|
||||||
|
4. Preparing the already-published v12 recording took 6.290 s on the first API
|
||||||
|
request (including integrity checks), then 0.006 s on the next request with
|
||||||
|
the same 182,745,691-byte RRD digest. No repeated map correction or RRD export
|
||||||
|
was observed in those requests. This is **not** a browser-open timing: the
|
||||||
|
disposable native viewer still reads and indexes the RRD when opened.
|
||||||
|
5. Ordinary display requests sent a playback cursor, causing a full RRD bounds
|
||||||
|
scan even though the eye did not change. Only tracking-relative camera
|
||||||
|
transitions now send that cursor.
|
||||||
|
6. Global HTTP gzip recompressed native RRD chunks; the stock 64 KiB file
|
||||||
|
response also incurred thousands of thread/event-loop handoffs competing
|
||||||
|
with telemetry/color work. RRD routes bypass gzip, and pinned file responses
|
||||||
|
use bounded 1 MiB reads. JSON/text compression, Range, ETag and cache pins
|
||||||
|
remain intact.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- v13 RRD projection retains every point of every captured frame. Color-only
|
||||||
|
overlays follow the same timestamps and complete point ordering. Only the
|
||||||
|
derived cache version changes; old projections are not accepted as v13.
|
||||||
|
- Both existing accumulation controls use one shared 1,800-second maximum,
|
||||||
|
with minute/second labels. The control changes the visible history; it does
|
||||||
|
not delete or decimate archived points. Existing chosen values are retained.
|
||||||
|
- Every recorded display update requests blueprint activation. Stable view
|
||||||
|
IDs remain; ordinary display changes do not inject a guessed camera pose.
|
||||||
|
- Export/cache failures remain explicit. There is no silent low-quality
|
||||||
|
fallback. More complete data necessarily increases file and memory size.
|
||||||
|
|
||||||
|
## Navigation boundary / pending decision
|
||||||
|
|
||||||
|
Rerun SDK and web viewer remain unmodified 0.36.3. Upstream `eye.rs` pans in
|
||||||
|
the screen plane, not the XY ground plane. Its orbital zoom approaches the
|
||||||
|
current look target; it does not fly through that target toward a remote
|
||||||
|
object. The public WebViewer API cannot read/write the active eye. The local
|
||||||
|
camera journal is a shadow estimate, not the native camera controller; changing
|
||||||
|
that estimate alone would not fix ground-constrained pan and would risk more
|
||||||
|
desynchronization.
|
||||||
|
|
||||||
|
The requested ground-constrained pivot and unchanged pivot during orbit need
|
||||||
|
a deliberately supported native navigation extension (with pinned source,
|
||||||
|
patch, build provenance and upgrade tests), not per-pointer HTTP blueprint
|
||||||
|
replacement or another overlay renderer. Owner approval for that change to
|
||||||
|
the unmodified-upstream architecture was requested. Native navigation is not
|
||||||
|
claimed fixed by this increment.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Architecture tests, full TypeScript check and 889 frontend unit tests passed.
|
||||||
|
- Production build passed (existing large-bundle warning remains).
|
||||||
|
- Focused Python export/color/cache/corrected-map tests passed. New checks prove
|
||||||
|
all 12 synthetic cloud frames are actually logged, all six color frames are
|
||||||
|
emitted, corrected positions are retained for every frame, 0.5 screen-space
|
||||||
|
radius is serialized, and a 30-minute history does not write eye controls.
|
||||||
|
- Real v13 export completed. A streaming audit counted exactly 13,608 frames
|
||||||
|
and 49,217,854 positions, matching capture metadata. Size 743,853,833 bytes;
|
||||||
|
SHA-256 `40dbebba5dddccf94ba5c48118a94909bdb01ee8de883746d0db54a5d4ea86f2`.
|
||||||
|
A repeat prepare request reused this digest in 16 ms, without another export.
|
||||||
|
- Incremental gates: 81 frontend tests, full TypeScript check and production
|
||||||
|
build passed; 12 focused HTTP/session tests passed, including unchanged RRD
|
||||||
|
bytes, 206 ranges, length, ETag, pin release, overlay POST and retained JSON
|
||||||
|
gzip. No extra integrated backend was started.
|
||||||
|
- Bounded real HTTP sample: an 8 MiB range took 7.274 s with 64 KiB reads while
|
||||||
|
the archive/color load was active. After the 1 MiB change, cold metadata
|
||||||
|
restoration took 5.264 s and the body another 6 ms; the warm repeated request
|
||||||
|
took 13.9 ms total. These differ in background load and are not a controlled
|
||||||
|
throughput benchmark or a complete browser-open measurement.
|
||||||
|
- Browser acceptance on the canonical IAB viewer: after the transport fix,
|
||||||
|
the complete 24:06.684 timeline and dense playing cloud were visible at the
|
||||||
|
first inspection roughly 45 s after open (upper bound, not a precise load
|
||||||
|
stopwatch). Before the chunk-size fix, a 5+ minute observation still had no
|
||||||
|
usable timeline. Native color defaults remained visible while the first
|
||||||
|
requested height/Viridis overlay was preparing.
|
||||||
|
- Paused playback at 60 s: 0.5 → 5 → 0.5 changed the actual native point
|
||||||
|
footprint; a manually orbited camera retained its viewpoint through these
|
||||||
|
settings changes. Expanded cloud/Escape restore worked. The existing
|
||||||
|
display modal was used; its product anatomy was not changed in this task.
|
||||||
|
- The 1,800-second slider showed “30 мин”; seeking to the exact archive end
|
||||||
|
kept earlier geometry visible. However, this full-cloud draw took roughly
|
||||||
|
23 s for the UI capture and swap rose from about 9 GB to 14.8 GB. The viewer
|
||||||
|
was closed immediately to release resources. This is functional acceptance
|
||||||
|
of the range, **not** smooth full-ring interaction acceptance on the 18 GB
|
||||||
|
Mac. No hidden decimation or lowered accumulation limit was substituted.
|
||||||
|
|
||||||
|
## Remaining work and limits
|
||||||
|
|
||||||
|
- Ground-constrained pivot, fixed-target orbit, and map-independent zoom are
|
||||||
|
not implemented; they require the native-controller decision above.
|
||||||
|
- First non-default coloring still decodes the complete source into a bounded
|
||||||
|
overlay cache and can take minutes on this capture. The existing index cache
|
||||||
|
is smaller than the complete 49M-point index. Opening no longer waits for
|
||||||
|
this to show the base cloud, but color-preparation latency is not solved.
|
||||||
|
- Full-fidelity storage is not a sufficient large-scene rendering strategy.
|
||||||
|
A follow-up should separate a whole-route overview from full-detail spatial
|
||||||
|
inspection, with explicit level-of-detail/provenance and access to every raw
|
||||||
|
point. Do not reintroduce silent frame skipping as a performance “fix”.
|
||||||
|
|
||||||
|
## Resource and authority policy
|
||||||
|
|
||||||
|
Single canonical service on port 8000; no Docker startup, extra viewer, load
|
||||||
|
test, device command, changed raw evidence or regenerated corrected map.
|
||||||
|
Tests, build, large export and browser QA run sequentially on the 18 GB Mac.
|
||||||
|
The full-cloud view is not a guarantee of interactive performance at every
|
||||||
|
history length. Any future LOD policy must be explicit and must preserve a
|
||||||
|
full-fidelity inspection path.
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# Full-ring continuity and live-pass lifetime
|
||||||
|
|
||||||
|
## Scope and evidence
|
||||||
|
|
||||||
|
Owner requested review of the latest full ring, removal of distance-triggered
|
||||||
|
termination, and removal of the recorded/live pass chooser from operator setup.
|
||||||
|
Full-route acquisition and its quality gates are deliberately unchanged.
|
||||||
|
|
||||||
|
The completed study is `0324337e-b590-4561-b19a-8fbc7835b888`, captured on
|
||||||
|
2026-09-21. This is an audit of immutable production decisions, not a numerical
|
||||||
|
rerun, new capture, or absolute-position survey. The operator reports a start
|
||||||
|
approximately 20 m before the original seam and a full circular walk.
|
||||||
|
|
||||||
|
Private evidence is retained under
|
||||||
|
`.runtime/audits/2026-09-21-ring-002-unbounded/`: audit script, per-check results,
|
||||||
|
UTC/monotonic audit bounds, input SHA-256 inventory and test logs. Every study
|
||||||
|
artifact, the raw MQTT capture, metadata, clocks and capture manifest were hashed
|
||||||
|
before and after inspection; they were unchanged. Raw data remains outside Git.
|
||||||
|
|
||||||
|
## Production result
|
||||||
|
|
||||||
|
- Reference length: **579.9827 m**; cumulative live travel at termination:
|
||||||
|
**580.0751 m**. Recorded reason: **`distance-limit`**.
|
||||||
|
- Route search: **810/810** fits, complete; selected anchor at **555 m** along
|
||||||
|
the reference. Worker wall time **127.93 s**, followed by three fresh
|
||||||
|
confirmation checks. First tracking receipt: **12:35:02.035 UTC**.
|
||||||
|
- Fresh checks: **101 accepted / 101**, comprising two acquiring decisions and
|
||||||
|
**99 tracking decisions** (the third confirmation establishes tracking).
|
||||||
|
No reinitialization or recovery attempt; no recorded pose receipt gap.
|
||||||
|
- Overlap within the 0.5 m evaluation radius: **98.274–99.928%**, median
|
||||||
|
**99.644%**. Inlier surface RMSE: **0.1330–0.1580 m**, median **0.1416 m**.
|
||||||
|
- Successive accepted transform changes: maximum **0.0716 m / 0.2732°**.
|
||||||
|
These are consistency measurements, not surveyed pose errors.
|
||||||
|
- Local checks: median **0.450 s** worker time, maximum **0.900 s**;
|
||||||
|
median check interval **5.111 s**, maximum **6.564 s**. Maximum accepted
|
||||||
|
result age **3.112 s**, below the unchanged freshness gate.
|
||||||
|
- No LiDAR/pose queue overflow or oversize rejection. Camera preview dropped
|
||||||
|
1,271 superseded queue frames; this is a separate latest-frame video path,
|
||||||
|
not evidence of missing registration clouds.
|
||||||
|
- Last check at **12:43:25.926 UTC**, at sampled travel **578.815 m**, was
|
||||||
|
accepted and tracking. The distance branch terminated the study **238 ms**
|
||||||
|
later, at **12:43:26.164 UTC**. Capture finalization completed separately at
|
||||||
|
**12:44:51.043 UTC**. No scanner-stop command belongs to that distance branch.
|
||||||
|
|
||||||
|
The stop is therefore an execution-policy defect, not a failed geometric fit.
|
||||||
|
The reported black screen was not independently reproduced; saved aligned
|
||||||
|
geometry and the historical scene remain available. Browser telemetry is a
|
||||||
|
presentation-admission proxy, not a GPU-paint receipt.
|
||||||
|
|
||||||
|
## Correction and architecture boundary
|
||||||
|
|
||||||
|
`selected-live-route/v2` separates reference coverage from pass lifetime:
|
||||||
|
|
||||||
|
- The selected reference still defines where matching searches and validates.
|
||||||
|
- Distance remains telemetry. It does not consume a budget or end a pass,
|
||||||
|
including on detours, reversals, snakes or additional laps.
|
||||||
|
- New runs expose `maximum_distance_m: null` and `maximum_seconds: null`.
|
||||||
|
The live loop no longer reads a distance cap, including a retained legacy
|
||||||
|
finite field. Historical reports are not migrated or rewritten.
|
||||||
|
- Explicit study cancellation, source end/stop intent, changed source identity,
|
||||||
|
technical failure and service shutdown retain their existing semantics.
|
||||||
|
Quality/freshness loss still revokes tracking and invokes the existing
|
||||||
|
recovery path; removing the cap does not hold a false green status.
|
||||||
|
- Engineering recorded-replay bounds are unchanged. They limit an offline
|
||||||
|
experiment, not operator live travel.
|
||||||
|
|
||||||
|
Operator setup now contains project, full reference and direction, followed by
|
||||||
|
one **«Начать новый проход»** action. The entire **«Повторный проход»** section,
|
||||||
|
its mode switch, recorded-source fields and explanatory paragraphs are removed.
|
||||||
|
The workspace invokes only the live start and no longer mounts the recorded
|
||||||
|
comparison hook or requests its catalog. Existing saved comparisons and backend
|
||||||
|
engineering comparison APIs remain available. The admitted Design Guideline
|
||||||
|
Inspector and Button are reused; no new controls, styling or navigation added.
|
||||||
|
|
||||||
|
## Validation and limits
|
||||||
|
|
||||||
|
Functional fixtures exercise three traversals of the same route geometry with
|
||||||
|
travel equal to three reference lengths, including a finite legacy cap field.
|
||||||
|
They verify that source end, explicit study cancellation and source stop intent
|
||||||
|
still terminate and release leases without commanding capture. A second fixture
|
||||||
|
uses the real stationary bootstrap and causal gate, synthetic numeric fits, and
|
||||||
|
fresh accepted windows beyond three reference lengths. UI tests render the
|
||||||
|
actual settings, verify empty/busy/short-reference gates and the direct start.
|
||||||
|
|
||||||
|
Acceptance: **140 focused backend tests**, **888 frontend tests**, architecture
|
||||||
|
checks, TypeScript and the production build passed. The existing large-chunk
|
||||||
|
build warning remains. In-app browser QA on the canonical `8000` service verified
|
||||||
|
the empty form, full corrected reference, normal/expanded layouts, scrolling to
|
||||||
|
the start action and Escape. No start action was sent during browser QA. The
|
||||||
|
latest real ring result was left open, with its saved geometry visibly loaded.
|
||||||
|
The service was restarted while acquisition was idle and left healthy on `8000`,
|
||||||
|
with no Mission Core listener on `8765`.
|
||||||
|
|
||||||
|
SHA-256 comparison against the preceding handoff confirms unchanged entry
|
||||||
|
acquisition, full-route search/worker, stationary bootstrap, registration and
|
||||||
|
causal-tracking modules. Only lifetime policy/loop and the requested setup UI
|
||||||
|
were changed for this increment, alongside regression tests and this audit.
|
||||||
|
|
||||||
|
The observed physical ring supports continuous localization on this reference.
|
||||||
|
It does not establish absolute pose accuracy, unattended navigation/safety,
|
||||||
|
seasonal transfer, multi-kilometre capacity, or physical multi-lap acceptance.
|
||||||
|
No new device operation or change to route search, correction-map producers,
|
||||||
|
registration thresholds or scanner acquisition was made for this correction.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Ring entry failure and independent seam traversal
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
The latest `JA-STROITEL-RING-001` live study supports local tracking across the
|
||||||
|
corrected reference seam, but exposes a route-wide cold-start scheduling defect.
|
||||||
|
Its first location was not geometrically unsuitable: a complete offline search
|
||||||
|
of the original first prefix finds an unambiguous 98.82% overlap candidate with
|
||||||
|
unchanged production quality gates. The live search never reached that seed.
|
||||||
|
|
||||||
|
No production code, default, threshold, scanner state, saved run or corrected
|
||||||
|
reference was changed during this audit. Diagnostic outputs are private, ignored
|
||||||
|
runtime evidence; the original study retains its actual unsuccessful first
|
||||||
|
attempt. This is not a retroactively successful live initialization.
|
||||||
|
|
||||||
|
## Inputs and scope
|
||||||
|
|
||||||
|
- Latest study `473870e0-5210-452c-b9e4-9d7937e93646`, query capture
|
||||||
|
`20260921T103511Z_viewer_live`.
|
||||||
|
- Reference: `JA-STROITEL-SUN-RING · коррекция v2`, the full 579.9827 m,
|
||||||
|
all 5,182 poses, 694,420 registration-map points. This was not another
|
||||||
|
truncated-reference regression.
|
||||||
|
- First stationary prefix: 8,870 retained query points, approximately 10 seconds,
|
||||||
|
maximum measured prefix motion 0.01066 m. No recorded receipt gap.
|
||||||
|
- Two initialization attempts, one operator reinitialization, final study state
|
||||||
|
`completed`, termination `spatial-stop-requested`.
|
||||||
|
- The operator deliberately returned to the initially rejected physical place.
|
||||||
|
Initial/final poses differ by 0.164 m in this capture's SLAM coordinates. This
|
||||||
|
supports the comparison but is not independent survey ground truth.
|
||||||
|
|
||||||
|
## Execution chronology (Moscow time)
|
||||||
|
|
||||||
|
| Time | Recorded event |
|
||||||
|
| --- | --- |
|
||||||
|
| 13:35:37.912 | First stationary prefix begins |
|
||||||
|
| 13:35:47.921 | First search starts |
|
||||||
|
| 13:36:24.105 | Search rejected as `initialization-incomplete` |
|
||||||
|
| 13:36:46.859 | Operator requests reinitialization at the familiar start area |
|
||||||
|
| 13:36:56.780 | Second search starts |
|
||||||
|
| 13:37:14.769 | Dense-start candidate found: 98.72% overlap |
|
||||||
|
| 13:37:27.973 | Third disjoint fresh check establishes tracking |
|
||||||
|
| 13:38:35–40 | Trajectory crosses the original start/end seam in reverse |
|
||||||
|
| 13:39:36.468 | Last accepted tracking result near the initial failed location |
|
||||||
|
| 13:39:41.876 | Intentional STOP completes the study |
|
||||||
|
|
||||||
|
The final in-flight calculation also produced a geometric candidate, but was
|
||||||
|
correctly not published after STOP. Its unaccepted decision is not a loss event.
|
||||||
|
|
||||||
|
## Root cause of the first failure
|
||||||
|
|
||||||
|
The code first runs 108 dense-start seeds. That stage consumed 15.737 seconds
|
||||||
|
and found no admissible candidate, as the actual location was about 15 m before
|
||||||
|
the seam, not within the familiar start patch. The route-wide stage then received
|
||||||
|
only the remainder of a shared 35-second search budget: approximately 19 seconds.
|
||||||
|
|
||||||
|
The full route contains 117 searchable anchors and three yaw hypotheses per
|
||||||
|
anchor (351 fits). Retrieval covered the full map, but precise verification
|
||||||
|
completed only 40 anchors and one fit of the next anchor: 121/351 route fits.
|
||||||
|
Together with the start stage this is 229/459 planned fits. The saved result
|
||||||
|
explicitly says `complete=false`, `incomplete-route-search`; it does **not** prove
|
||||||
|
`no-route-location`.
|
||||||
|
|
||||||
|
The nearest anchor was ranked 62nd. The anchor whose existing production seed
|
||||||
|
eventually finds the correct fit was ranked 100th. Neither was reached live.
|
||||||
|
The coarse descriptor ranks radial/height distributions around cloud medians,
|
||||||
|
not a measured probability of place identity. Here it prioritizes other route
|
||||||
|
areas over the true place. Different visible geometry in a stationary prefix
|
||||||
|
and a multi-visit local reference makes that representation a plausible source
|
||||||
|
of poor ordering; this audit establishes the bad ordering, not vegetation as
|
||||||
|
its independently classified cause.
|
||||||
|
|
||||||
|
There are two further implementation consequences:
|
||||||
|
|
||||||
|
1. `choose_route_location` discards the candidate queue when a search is
|
||||||
|
incomplete; `StationaryBootstrap.offer_prior` enters `lost`. Initial loss
|
||||||
|
then waits for operator retry rather than continuing the unexamined queue.
|
||||||
|
2. The incomplete/expired operator message asks to stop recording and begin
|
||||||
|
again. That recommendation is inappropriate for this observed compute
|
||||||
|
exhaustion. No scanner reset or bad physical location was established.
|
||||||
|
|
||||||
|
## Bounded offline experiments
|
||||||
|
|
||||||
|
All original run-file hashes were verified before and after each experiment.
|
||||||
|
Single CPU job at a time, one numerical thread; no hardware, ingress or UI
|
||||||
|
publication. Each output is exclusive-created, never written over live evidence.
|
||||||
|
|
||||||
|
### 1. Retrospective geometric check
|
||||||
|
|
||||||
|
Register the original first prefix using a seed from the later successful
|
||||||
|
alignment. This uses future information deliberately and is **not** a cold-start
|
||||||
|
test. It isolates whether the first cloud is compatible with the corrected map.
|
||||||
|
|
||||||
|
- First accepted transform as seed: 98.887% overlap, 0.1396 m inlier RMSE.
|
||||||
|
- Last accepted transform as seed: 98.893% overlap, 0.1399 m inlier RMSE.
|
||||||
|
- Both pass unchanged local registration gates.
|
||||||
|
|
||||||
|
### 2. Complete cold-prefix route search
|
||||||
|
|
||||||
|
Replay the same original first query and complete reference through existing
|
||||||
|
`relocalize_route`. No endpoint, final transform or physical-location hint enters
|
||||||
|
the search. The only experimental override is a 120-second **offline** deadline
|
||||||
|
so the finite queue can finish; all geometric quality gates remain unchanged.
|
||||||
|
|
||||||
|
- All 117 anchors / 351 fits completed in 59.408 seconds.
|
||||||
|
- Correct, non-ambiguous leading candidate: 98.820% overlap, 0.1439 m inlier RMSE.
|
||||||
|
- Candidate anchor at route progress 560 m, ranked 100th; the fitted pose is
|
||||||
|
consistent with approximately 15 m before the seam.
|
||||||
|
- Next distinct fitted candidate: 61.086% overlap at an unrelated route area.
|
||||||
|
It was already present in the incomplete live search. Thus accepting the
|
||||||
|
first threshold-passing fit or loosening acceptance would be an unsafe fix.
|
||||||
|
|
||||||
|
This proves recoverability from the original prefix, not live readiness. A
|
||||||
|
59-second result would violate the existing live 35-second search / 40-second
|
||||||
|
source-age contract and must not simply be relabeled fresh or green.
|
||||||
|
|
||||||
|
### 3. Seed-construction sensitivity
|
||||||
|
|
||||||
|
On a bounded, retrospectively chosen four-anchor subset, compare the existing
|
||||||
|
cloud-median translation seed with a seed mapping query scanner origin to the
|
||||||
|
route anchor, using the same yaw options, targets and quality gates.
|
||||||
|
|
||||||
|
At the nearest anchor (565 m), yaw 90°:
|
||||||
|
|
||||||
|
- Existing median seed: 53.799% overlap, rejected.
|
||||||
|
- Origin-to-anchor seed: 98.847% overlap, accepted geometrically.
|
||||||
|
- Retrospectively measured initial-position error decreases from 4.62 to 1.51 m.
|
||||||
|
|
||||||
|
This is evidence for improving seed construction, not an approved replacement:
|
||||||
|
the subset was chosen after examining the result, and one neighboring anchor
|
||||||
|
still produces a weak approximately 61% candidate. Global ranking, competing
|
||||||
|
places and fresh-data confirmation remain necessary.
|
||||||
|
|
||||||
|
## Tracking and seam quality
|
||||||
|
|
||||||
|
- 28 fresh checks accepted, including initial confirmation; 26 accepted results
|
||||||
|
in tracking state. No transition to lost/recovering after tracking begins.
|
||||||
|
- Observed distance after reinitialization: 101.633 m; last accepted result at
|
||||||
|
100.896 m. The remaining final result was fenced by intentional STOP.
|
||||||
|
- Overlap within 0.5 m: minimum 98.516%, median 99.348%, maximum 99.746%.
|
||||||
|
- Inlier surface RMSE: 0.1226–0.1533 m, median 0.1422 m.
|
||||||
|
- Maximum accepted consecutive-transform change: 0.0316 m / 0.5906°.
|
||||||
|
- Calculation worker wall time: median 0.383 s, maximum 0.464 s. The roughly
|
||||||
|
five-second interval is the configured check cadence, not a five-second fit.
|
||||||
|
- At the seam crossing, overlap remains 99.57–99.69%, with centimetre-scale
|
||||||
|
transform corrections and no recovery event. Route progress wraps from the
|
||||||
|
beginning to the end as expected while physical coordinates remain continuous.
|
||||||
|
|
||||||
|
These metrics measure consistency with the admitted map, not absolute rover
|
||||||
|
position accuracy. Only the traversed seam neighborhood is independently
|
||||||
|
checked here, not the entire 580-m ring, arbitrary seasons, or kilometre routes.
|
||||||
|
|
||||||
|
## Recommended architectural correction (not implemented here)
|
||||||
|
|
||||||
|
1. Preserve the successful local tracking and corrected reference.
|
||||||
|
2. Make full-route acquisition a resumable search with a cached reference index
|
||||||
|
and auditable unexamined candidates. A compute slice expiring means still
|
||||||
|
searching/incomplete, not a declaration that the operator picked a bad place.
|
||||||
|
3. Separate place-hypothesis generation from live position validity. Refresh
|
||||||
|
observations and confirm candidates against disjoint current frames; never
|
||||||
|
extend an old matrix's control authority to cover a long global search.
|
||||||
|
4. Qualify sensor-origin-based descriptor/seed variants against this recording,
|
||||||
|
previous successful start/mid-route runs and wrong-place negative cases.
|
||||||
|
5. Retain ambiguity, identity and quality gates. Do not patch this by lowering
|
||||||
|
overlap or merely increasing the shared timer.
|
||||||
|
|
||||||
|
The saved first prefix, complete map, successful return and disjoint subsequent
|
||||||
|
frames are sufficient for the next offline iteration. No new field recording
|
||||||
|
is required to diagnose or begin correcting this failure.
|
||||||
|
|
||||||
|
## Validation and private reproduction
|
||||||
|
|
||||||
|
62 focused tests passed: route relocalization, stationary bootstrap, stationary
|
||||||
|
recovery, and planning-live lifecycle. Existing tests correctly assert refusal
|
||||||
|
of an incomplete search; they do not establish acceptable completion latency
|
||||||
|
on this real ring. The new captured case should become an evidence-backed
|
||||||
|
acceptance fixture for the acquisition redesign.
|
||||||
|
|
||||||
|
Private evidence: `.runtime/audits/2026-09-21-ring-001-entry/` contains
|
||||||
|
`inspect.json`, `exhaustive.json`, `seeds.json`, the diagnostic `audit.py`, and a
|
||||||
|
provenance seal. Inputs remain in the canonical run directory. No temporary
|
||||||
|
worker remains; canonical Mission Core stays on port 8000.
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Shared known-revisit correction for small and large rings
|
||||||
|
|
||||||
|
## Scope and topology correction
|
||||||
|
|
||||||
|
The operator clarified that the small ring is inside the large ring and shares
|
||||||
|
roughly half its path. Their unshared corridors must not be scored as a failed
|
||||||
|
repeat of the same map. Reference surveys in Data and independent captures
|
||||||
|
owned by planner studies are distinct evidence classes.
|
||||||
|
|
||||||
|
The earlier 30/101 replay used the planner-owned `JA-STROITEL-RING-002` full
|
||||||
|
small-ring pass, not the Data reference itself. Its whole path does not become
|
||||||
|
covered merely because its origin is the planner. The earlier 29/29 short pass
|
||||||
|
also came from the planner. Preserve the negative numbers, but classify their
|
||||||
|
geometric scope correctly. Other short planner passes are separate queries.
|
||||||
|
|
||||||
|
## Implementation and boundaries
|
||||||
|
|
||||||
|
`reconstruction/closure.py` owns a device-independent, injected-registrar policy
|
||||||
|
for acquisition of a known start-area revisit. It has no hardware, UI, catalog
|
||||||
|
or planner import. The K1-specific `reconstruct_recorded_ring.py` adapter now
|
||||||
|
uses this same policy on both source recordings, with no source-name branch.
|
||||||
|
|
||||||
|
The separate offline first-fit registration policy admits a larger initial
|
||||||
|
correction (25 m / 180 degrees) without modifying live tracking's 3 m / 30-degree
|
||||||
|
policy or the existing overlap, RMSE, shape, information and correspondence
|
||||||
|
criteria. These are declared acquisition bounds, not a promise of arbitrary
|
||||||
|
drift recovery. Identity is the initial hypothesis; endpoint positions are
|
||||||
|
never forced together or used to manufacture a seam transform.
|
||||||
|
|
||||||
|
All 12 training-only support pairs are evaluated: first [10,20,40] seconds ×
|
||||||
|
last [5,10,20,30] seconds. Held-out frames cannot enter either side; shared
|
||||||
|
source frames are rejected. Each qualified forward fit must pass seeded reverse
|
||||||
|
refinement and the existing 0.1 m / 0.2-degree round-trip check. Qualified support
|
||||||
|
windows must also agree at both observed patch centers within the separately
|
||||||
|
declared 0.5 m / 1-degree consistency envelope. This is an engineering ambiguity
|
||||||
|
guard, not a calibrated accuracy claim.
|
||||||
|
|
||||||
|
The established 20/5-second measurement is retained if qualified. Otherwise the
|
||||||
|
largest qualifying support is selected. All windows are still examined for
|
||||||
|
contradiction before selection is accepted. The smooth field solver, rigid
|
||||||
|
per-frame transform, original-frame clock binding and full-resolution export
|
||||||
|
are unchanged. Failure keeps the audit and produces no silently accepted
|
||||||
|
identity correction.
|
||||||
|
|
||||||
|
The adapter's v2 contract binds closure search and its declared policy.
|
||||||
|
Sealed v1 decoded caches remain reusable only when all actual decode/holdout
|
||||||
|
settings match. `review_recorded_ring.py` adds explicit frozen-data acceptance:
|
||||||
|
all local windows qualify, enough seam holdout exists, fixed quality gates pass,
|
||||||
|
and cross-visit agreement is not degraded. Packaging requires positive v2
|
||||||
|
acquisition and review, with the complete search included in the evidence seal.
|
||||||
|
Previously published v1 map bundles and pinned studies remain supported.
|
||||||
|
|
||||||
|
This is an offline correction/admission workflow, not a newly installed
|
||||||
|
background auto-corrector for every capture. It does not discover arbitrary
|
||||||
|
interior loops or recover a measured drift history from native LiDAR sweeps.
|
||||||
|
|
||||||
|
## Regression caught and retained
|
||||||
|
|
||||||
|
The first generalization always selected the largest qualifying support. It
|
||||||
|
passed same-source checks (small 52/52, large 136/136), but warm replay of the
|
||||||
|
independent full small-ring planner pass accepted only 100/101 snapshots. The
|
||||||
|
96.115 m window failed numerical convergence despite 99.866% overlap and
|
||||||
|
0.13993 m RMSE; the following window recovered. The same replay against its
|
||||||
|
frozen previous reference accepted 101/101. This was a real numerical regression,
|
||||||
|
not an uncovered-region excuse or permission to ignore convergence.
|
||||||
|
|
||||||
|
The final cascade preserves the qualifying established window and uses expanded
|
||||||
|
support only when necessary. It reproduces the previous small-ring corrected
|
||||||
|
points and trajectory bit-for-bit, and the previous successful large-ring
|
||||||
|
diagnostic geometry bit-for-bit. The unsuccessful intermediate candidate and
|
||||||
|
its full replay are retained privately; no quality threshold was weakened.
|
||||||
|
|
||||||
|
## Verification and runtime
|
||||||
|
|
||||||
|
- 201 focused software tests passed, including 23 new closure/admission cases
|
||||||
|
and the previous registration, correction, route search, causal recovery,
|
||||||
|
reference version and session-default suites. One existing Starlette/httpx
|
||||||
|
deprecation warning remains. Ruff and diff whitespace checks passed.
|
||||||
|
- Final real-source qualification uses the same immutable small 579.797 m and
|
||||||
|
large 1595.409 m recordings and their sealed decode caches. No new scanner
|
||||||
|
recording, hardware command, frontend change or service restart is required.
|
||||||
|
- The shared policy retains the small 20/5-second closure and selects 40/30
|
||||||
|
seconds for the large ring. Source frame holdout remains correlated by the
|
||||||
|
upstream K1 map and is not independent survey truth.
|
||||||
|
- Final same-source local checks pass 52/52 on the small ring and 136/136 on
|
||||||
|
the large ring. Frozen seam holdout overlap is 89.434% and 95.093%, respectively.
|
||||||
|
- Repeating the independent full small-ring planner capture against the final
|
||||||
|
rebuilt small atlas restores 101/101 numerical and causal acceptances, matching
|
||||||
|
the 101/101 frozen-reference baseline. This is a warm map-version regression,
|
||||||
|
initialized by its existing old-map cold fit, not another cold-start claim.
|
||||||
|
- Complete large-map atlas and trajectory arrays equal the previously tested
|
||||||
|
diagnostic atlas. Its independent short-pass 29/29 and cold numerical search
|
||||||
|
evidence remain applicable by exact identity, not by assuming similar maps
|
||||||
|
have identical behavior. No full independent pass through the new territory
|
||||||
|
is inferred from that reuse.
|
||||||
|
- A full numerical search for the planner's `ja-sun-009-100m` was stopped after
|
||||||
|
host swap increased. Its exact temporary worker was terminated and reaped;
|
||||||
|
canonical service 8000 was retained. This incomplete run is neither a
|
||||||
|
localization success nor a geometric rejection. No repeated heavyweight
|
||||||
|
search is required to establish equivalence of identical map arrays.
|
||||||
|
|
||||||
|
Private evidence is under `.runtime/audits/2026-09-21-closure-v2/`, with the
|
||||||
|
initial candidate retained at the root and final cascade results separately
|
||||||
|
under `cascade/`. Earlier observations and source recordings remain immutable.
|
||||||
|
|
||||||
|
## Reviewed version admission
|
||||||
|
|
||||||
|
The existing packager rechecked all 13,608 full-resolution large-map frames and
|
||||||
|
all 13,624 poses against the frozen field, plus source transport/clocks and the
|
||||||
|
complete review seal. It published a separate content-addressed full map. The
|
||||||
|
ordinary large-session selection now resolves to its corrected generation and
|
||||||
|
1599.807 m in the canonical planner API. The small-session default pointer is
|
||||||
|
unchanged; existing planner studies retain their pinned generations. Raw captures
|
||||||
|
remain available, and no vehicle-control authority is granted.
|
||||||
|
|
||||||
|
Canonical API acceptance confirms that the spatial overview defaults to
|
||||||
|
`corrected` and uses the same admitted map generation as ordinary planner
|
||||||
|
selection. The original-generation-pinned view remains `original`. Both
|
||||||
|
Original and Corrected preview RRDs were rendered successfully at the 80 m
|
||||||
|
height ceiling. Browser visual QA and a new full playback export were not run.
|
||||||
|
The final private evidence seal rechecked 212 input files with no changes,
|
||||||
|
no producer changes, and exact small/large geometry equivalence confirmed.
|
||||||
|
|
||||||
|
The operator workflow remains explicit: reconstruct → frozen review → package →
|
||||||
|
publish paired overview → activate the session default. No new UI or background
|
||||||
|
automatic capture correction has been introduced. Publication and admission
|
||||||
|
reuse the existing ordinary-name selection and Original/Corrected inspector.
|
||||||
|
|
||||||
|
## Decision and remaining scope
|
||||||
|
|
||||||
|
The shared cascade is qualified for these two known start-area revisits, with
|
||||||
|
explicit fail-closed acquisition/review boundaries. Universal means one policy
|
||||||
|
and regression corpus, not a claim that every possible ring will close.
|
||||||
|
|
||||||
|
Independent new-territory repeat accuracy, absolute geometry, the actual spatial
|
||||||
|
distribution of drift, arbitrary crossing-loop graphs, seasonal transfer and
|
||||||
|
onboard/live timing remain unproven. One accepted seam plus a smoothness prior
|
||||||
|
cannot establish those facts. Incomplete optional planner searches remain open;
|
||||||
|
do not relabel them as passed or use mixed-coverage loops as an aggregate score.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Offline smooth correction of a recorded ring
|
||||||
|
|
||||||
|
## Decision and boundary
|
||||||
|
|
||||||
|
Implemented and exercised an **experimental map derivative**, not a live SLAM
|
||||||
|
replacement, planner change or automatic reference promotion. The real input and
|
||||||
|
all detailed geometry remain under ignored private runtime storage. No scanner
|
||||||
|
command, capture mutation, viewer publication or vehicle command was made.
|
||||||
|
|
||||||
|
The result supports proceeding with review of a corrected reference. It does not
|
||||||
|
establish absolute positioning accuracy or independent repeat-pass acceptance.
|
||||||
|
|
||||||
|
## Implementation
|
||||||
|
|
||||||
|
- `src/k1link/reconstruction/smooth_correction.py`: vendor-neutral smooth field
|
||||||
|
C(s), where s is cumulative distance in the original recorded trajectory.
|
||||||
|
- `experiments/reconstruct_recorded_ring.py`: K1-specific immutable-source adapter,
|
||||||
|
source hashing, frame split, surface measurements, candidate solver, local
|
||||||
|
registration checks and full-resolution derivative materialization.
|
||||||
|
- `experiments/review_recorded_ring.py`: fixed-population cross-visit holdout and
|
||||||
|
fresh-frame checks of sensitive matching windows; no re-fit of the correction.
|
||||||
|
- `tests/test_smooth_map_correction.py`: synthetic invariants and negative cases.
|
||||||
|
- `pyproject.toml`, `uv.lock`: optional `map-correction` extra with SciPy 1.16.2
|
||||||
|
and the existing small-gicp 1.0.1. No new mandatory runtime import.
|
||||||
|
|
||||||
|
Capture remains plugin-owned. Reconstruction has no plugin, UI, planner or
|
||||||
|
hardware imports. Planning may later consume a separately admitted derivative;
|
||||||
|
there is deliberately no runtime path that changes the current reference.
|
||||||
|
|
||||||
|
## Model, not recovered ground truth
|
||||||
|
|
||||||
|
The field has natural cubic splines for translation and rotation-vector
|
||||||
|
parameters, 20 m knot spacing, and an explicitly recorded first/second derivative
|
||||||
|
penalty. Rotations act about a geometry-bound pivot so changing world coordinates
|
||||||
|
does not change the physical answer. Three-point Gauss quadrature integrates the
|
||||||
|
squared spline derivatives; the first knot fixes the gauge.
|
||||||
|
|
||||||
|
A surface link measures C(query) = C(reference) × T(reference, query), evaluated
|
||||||
|
at the observed patch center and in orientation. No endpoint-position equality,
|
||||||
|
flat-ground constraint or endpoint-derived initial guess is introduced. The
|
||||||
|
weights are engineering regularization scales, **not sensor covariance**.
|
||||||
|
|
||||||
|
Each whole source cloud frame receives one rigid transform, and corresponding
|
||||||
|
pose positions/orientations receive the same field. Thus within-frame distances
|
||||||
|
are preserved, apart from float32 serialization. Different frames may move
|
||||||
|
relative to one another; that is precisely the deformation being tested.
|
||||||
|
|
||||||
|
This field is parameterized by traversal, not a single XYZ warp that would move
|
||||||
|
two different visits identically. Small-rotation chart admission is explicit;
|
||||||
|
large corrections, arbitrary loop discovery and scale to multi-kilometre graphs
|
||||||
|
remain unqualified. Current CPU least-squares uses a dense numerical Jacobian.
|
||||||
|
|
||||||
|
## Input and separation
|
||||||
|
|
||||||
|
The private ring source has 5,178 cloud frames, 5,182 poses and 17,669,672 points,
|
||||||
|
with a 579.797 m recorded trajectory. Full decode and source SHA-256 checks
|
||||||
|
precede and follow the operation. Source sequences are continuous. The export
|
||||||
|
preserves every point; the solver/validation sample every eighth source point
|
||||||
|
and use 0.25 m voxels. Spatial windows are declared computation profiles, not
|
||||||
|
capture-distance or vertical-clipping limits.
|
||||||
|
|
||||||
|
Every ten seconds, the two-second interval at phase 4–6 seconds is withheld:
|
||||||
|
1,061 frames withheld, 4,117 retained. No withheld frame enters the reference map
|
||||||
|
or the surface-link fits. Upstream K1 mapping still makes these observations
|
||||||
|
correlated; this is neither an independent recording nor ground truth.
|
||||||
|
|
||||||
|
Cloud/pose binding uses the existing host-monotonic chronology. Nearest pose
|
||||||
|
receipt distance is 25 ms p95, 143 ms maximum. This is not proven hardware
|
||||||
|
synchronization and does not recreate per-point firing times or native sweeps.
|
||||||
|
|
||||||
|
## Actual constraints admitted
|
||||||
|
|
||||||
|
The start-area revisit qualifies on retained frames with the unchanged production
|
||||||
|
GICP policy: 98.70% overlap within 0.5 m and 0.120 m inlier RMSE. Forward/reverse
|
||||||
|
fits differ by approximately 5 mm at the patch center and 0.191 degrees.
|
||||||
|
|
||||||
|
All **29 attempted additional neighboring-window registrations were rejected**
|
||||||
|
under the unchanged overlap/RMSE gates. They were excluded, not assigned low
|
||||||
|
quality but positive identity. Therefore this result uses **one measured closure
|
||||||
|
and a smoothness prior**, not a densely measured multi-edge reconstruction of
|
||||||
|
the drift history. Rejected measurements are retained in the private audit.
|
||||||
|
|
||||||
|
## Results
|
||||||
|
|
||||||
|
| Check | Original | Corrected v2 |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| Trajectory length | 579.797 m | 579.983 m |
|
||||||
|
| Final minus initial Z | −1.4646 m | +0.00265 m |
|
||||||
|
| Local held-out registration candidates | 51 / 52 | 52 / 52 |
|
||||||
|
| Median local overlap within 0.5 m | 98.259% | 98.285% |
|
||||||
|
| Median local inlier RMSE | 0.16246 m | 0.16192 m |
|
||||||
|
| Cross-visit held-out overlap within 0.5 m | 25.54% | 89.43% |
|
||||||
|
| Cross-visit all-point distance median | 0.891 m | 0.119 m |
|
||||||
|
| Cross-visit all-point distance p95 | 1.825 m | 0.906 m |
|
||||||
|
|
||||||
|
The cross-visit comparison uses the **same 16,109 source points from 41 withheld
|
||||||
|
frames**, compared to only the first retained window, with the correction frozen
|
||||||
|
and no re-fit. The remaining outlier tail is real and is not explained away as
|
||||||
|
vegetation without classification. Millimetre endpoint-Z agreement is **not**
|
||||||
|
millimetre map or localization accuracy. Physical endpoint locations were not
|
||||||
|
measured and may differ; no exact physical endpoint offset was imposed.
|
||||||
|
|
||||||
|
Maximum displacement of the sampled trajectory is 1.548 m. The maximum change
|
||||||
|
of displacement along it is 8.61 mm per travelled metre (p95 7.99 mm/m). This is a
|
||||||
|
correction-field gradient, not a measured strain bound for every 3D surface.
|
||||||
|
The maximum rotation-parameter gradient is 0.000786 degrees/m. Halving/doubling
|
||||||
|
the smoothness weight changes trajectory positions by less than 0.008 mm in this
|
||||||
|
one-closure case; that only shows this prior's numerical stability, not truth.
|
||||||
|
|
||||||
|
## Registration sensitivity retained
|
||||||
|
|
||||||
|
The first candidate had 51/52 accepted windows, with a non-convergence at 437 m;
|
||||||
|
the original had a non-convergence at 327 m. No quality threshold was lowered.
|
||||||
|
Fresh disjoint one-second halves in **both** regions passed for both maps.
|
||||||
|
|
||||||
|
After changing the field's coordinate pivot and exact quadrature (v2), its geometry
|
||||||
|
is nearly unchanged but all 52 two-second windows pass. This is evidence of
|
||||||
|
numerical/window-composition sensitivity of GICP convergence, **not proof that
|
||||||
|
its failure mode has been fixed**. Both revisions and their less-favorable
|
||||||
|
results are preserved. Production matching/loss/recovery code is unchanged.
|
||||||
|
|
||||||
|
The local test starts at identity, then uses the last accepted transform; it
|
||||||
|
does not seed each query with the correction field. The test is local matching,
|
||||||
|
not global place recognition or the entire live state machine. Agreement with
|
||||||
|
the generating field must not be called independent pose accuracy.
|
||||||
|
|
||||||
|
## Reproduction and storage
|
||||||
|
|
||||||
|
Install the optional extra through `uv` in a project environment. Run the
|
||||||
|
experiment with `--raw`, `--sha256`, `--session-id` and a fresh `--output` directory.
|
||||||
|
An optional `--cache` reuses a hash-sealed decoded source. Subsequent review takes
|
||||||
|
the result directory and can include additional explicitly named window groups.
|
||||||
|
Outputs are exclusive-created, not overwritten.
|
||||||
|
|
||||||
|
`corrected-points.f32` is little-endian Nx3 float32 map coordinates. Frame offsets,
|
||||||
|
counts and receipt times are retained in `corrected-trajectory.npz`; `distance_m`
|
||||||
|
and `frame_distance_m` there are **original source traversal parameters**, not
|
||||||
|
recomputed corrected path lengths. Intensities are unchanged and remain in the
|
||||||
|
hash-sealed source cache. `correction.json` records the field including its pivot.
|
||||||
|
The review seal binds code files and measured artifacts by SHA-256.
|
||||||
|
|
||||||
|
## Acceptance and next step
|
||||||
|
|
||||||
|
- 17 focused tests pass, including existing registration regression tests.
|
||||||
|
- Ruff and diff whitespace checks pass.
|
||||||
|
- One sequential CPU job at a time; no duplicate server, Docker workload or
|
||||||
|
temporary worker remains. The canonical service stays on port 8000.
|
||||||
|
- Candidate only; neither stored session nor operational reference replaced.
|
||||||
|
- Next: versioned reference admission and independent repeat-pass verification,
|
||||||
|
retaining recoverable localization state for numerical matching failures.
|
||||||
|
- Ops documentation write is pending: instruction/project MCP reads timed out;
|
||||||
|
no card was changed and no raw API fallback was used.
|
||||||
|
|
||||||
|
Subsequent same-day progress: the separate bundle and offline planning adapter are
|
||||||
|
verified in `2026-09-21-map-reference-version-admission.md`. Source/default live
|
||||||
|
selection remains unchanged. Ops metadata reads recovered, but card searches
|
||||||
|
still time out, so the engineering card update remains pending.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Whole reference and independent capture catalog
|
||||||
|
|
||||||
|
## Owner decision and product composition
|
||||||
|
|
||||||
|
New planning executions use the complete selected reference recording. The
|
||||||
|
reference crop section, its range controls and its 30-m shortcut are removed.
|
||||||
|
Data → Sessions and records lists independent physical recordings; live passes
|
||||||
|
created by the planner stay with their planning studies and are not offered as
|
||||||
|
reference choices. No new type selector, tab, catalog copy or workspace is
|
||||||
|
introduced. This is an owner-approved projection change in existing surfaces,
|
||||||
|
using the existing Select, Inspector, Button and WorkspaceWindow components.
|
||||||
|
|
||||||
|
The rejected alternative was another type dropdown in Data. It would preserve
|
||||||
|
the mixed acquisition catalog the owner explicitly asked to separate. Physical
|
||||||
|
evidence and corrected-map generations remain immutable. A planner pass is
|
||||||
|
still a physical capture, not a synthetic LAB result.
|
||||||
|
|
||||||
|
## Reproduced defects
|
||||||
|
|
||||||
|
- A newly selected reference was initialized to the first 30 metres.
|
||||||
|
- Choosing the same recording again reset its independently stored interval
|
||||||
|
to indices 0…1. React did not reload the unchanged source identity, so the
|
||||||
|
interval remained there. A stationary prefix then showed approximately zero
|
||||||
|
metres and two poses, disabling launch. The scanner lifecycle was not a
|
||||||
|
dependency of that button condition.
|
||||||
|
- The latest failed ring study is terminal (`cancelled`). Its frozen reference
|
||||||
|
was about 97.57 metres, not the complete approximately 579.98-metre ring.
|
||||||
|
Initialization exhausted its recorded candidates without finding a location.
|
||||||
|
This establishes the incomplete reference, not that full-reference matching
|
||||||
|
has already succeeded at the operator's latest location.
|
||||||
|
|
||||||
|
## Implementation boundaries
|
||||||
|
|
||||||
|
The planner derives its reference poses from the complete loaded source; it no
|
||||||
|
longer owns editable start/end state. Re-selecting the same source explicitly
|
||||||
|
reloads its generation without collapsing its route. Product saves request
|
||||||
|
`whole_recording: true`; the server resolves the endpoints from the immutable
|
||||||
|
source. Existing bounded API clients remain a compatibility path for historical
|
||||||
|
recorded experiments. Existing reports retain their frozen geometry. Opening
|
||||||
|
an older editable draft prepares a full-route revision rather than rewriting
|
||||||
|
the prior run.
|
||||||
|
|
||||||
|
The physical session store adds append-only planning-acquisition links keyed by
|
||||||
|
exact session and run identities. New live binding records the link before
|
||||||
|
publishing its run state. Startup reconciliation backfills only explicit live
|
||||||
|
report bindings; names, distances and fit success are never classifiers.
|
||||||
|
The link may precede capture finalization/catalog ingestion, survives archive
|
||||||
|
reconciliation and project catalog tombstones, and is excluded from the physical
|
||||||
|
source evidence digest.
|
||||||
|
|
||||||
|
The `standalone` catalog scope filters these links and LAB projections in SQL
|
||||||
|
before pagination. Data and new reference selectors request it. Existing
|
||||||
|
`source`/`all` scientific scopes and direct source/replay endpoints remain
|
||||||
|
unchanged. Recorded-repeat selection inside the planner still requests physical
|
||||||
|
`source` captures, so planning evidence does not become inaccessible after it
|
||||||
|
is hidden from Data. A previously independent survey is not reclassified just
|
||||||
|
because somebody later compares it offline.
|
||||||
|
|
||||||
|
Live admission, registration thresholds, scanner commands and vehicle authority
|
||||||
|
are unchanged. The older offline recorded-comparison solver still admits short
|
||||||
|
references only; its existing constraint is now explained next to its disabled
|
||||||
|
action instead of silently disabling the full-ring form. This task does not
|
||||||
|
claim to upgrade that solver to route-wide offline relocalization.
|
||||||
|
|
||||||
|
## Acceptance
|
||||||
|
|
||||||
|
- Focused Python regression suite: 135 passed (capture catalog, drafts, live
|
||||||
|
planning, session store/API and corrected session defaults).
|
||||||
|
- Frontend: architecture/targeted checks passed, typecheck passed, complete
|
||||||
|
test suite 887 passed, production build passed. Ruff and `git diff --check`
|
||||||
|
passed.
|
||||||
|
- Canonical LaunchAgent restarted after the build. One backend remains on
|
||||||
|
`127.0.0.1:8000`; no temporary backend was started on 8765.
|
||||||
|
- Startup backfilled 19 exact historical acquisition bindings. The current
|
||||||
|
standalone catalog contains five independent recordings, including
|
||||||
|
`JA-STROITEL-SUN-RING`, and excludes the planning captures. The planner
|
||||||
|
still lists 22 projects, including the latest unsuccessful ring study.
|
||||||
|
- Browser acceptance used the existing application tab after reload. Data and
|
||||||
|
the reference selector both showed the five independent recordings; no
|
||||||
|
second type dropdown was introduced. The planning history retained the
|
||||||
|
latest pass and previous studies.
|
||||||
|
- A new unsaved form selected the corrected ring and showed `Вся запись ·
|
||||||
|
579.98 м · 5 182 положений`. With a name supplied, launch was enabled.
|
||||||
|
Re-selecting that same recording retained the full route and enabled action.
|
||||||
|
Normal and expanded settings, scrolling to the action, and Escape close
|
||||||
|
were checked. Browser error logs were empty.
|
||||||
|
- The temporary form name was cleared without saving or starting a pass. The
|
||||||
|
corrected ring preview remains open. No scanner command, new acquisition,
|
||||||
|
registration run or historical-report rewrite was performed during QA.
|
||||||
|
|
||||||
|
These checks validate form/catalog repair and full-reference admission. They
|
||||||
|
do not claim a successful new field localization at the previously failed
|
||||||
|
location; that remains a short operator-controlled seam test.
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
# Saved recording display profile · 22 September 2026
|
||||||
|
|
||||||
|
## Operator contract
|
||||||
|
|
||||||
|
The existing Display window now offers cloud decimation, an accumulation-slider
|
||||||
|
range in minutes and the current accumulation duration. New session profiles
|
||||||
|
default to a three-minute slider range, zero decimation and the existing 12 s
|
||||||
|
current window. Exact value inputs accept fractional percentages and custom
|
||||||
|
durations; the 30-minute drag range is not a duration-validation ceiling.
|
||||||
|
|
||||||
|
0% retains the original point count, 50% retains half per frame (rounded), and
|
||||||
|
100% hides points without hiding the trajectory or grid. These are presentation
|
||||||
|
controls, not scanner, registration, reconstruction or evidence controls.
|
||||||
|
|
||||||
|
Recorded Display edits preview while the modeless window remains open. Changes
|
||||||
|
coalesce for 750 ms or flush on a control's commit; 0%/100% decimation bypass
|
||||||
|
that delay. Closing atomically saves the complete display settings in
|
||||||
|
`display-profile.json` inside that session's catalog-validated directory. Schema
|
||||||
|
is `missioncore.session-display-profile/v1`. Sealed `method.json`, raw transport,
|
||||||
|
corrected geometry and original RRD are not rewritten. Missing profiles use
|
||||||
|
defaults. Reads/writes are fenced by session identity and edit revision; writes
|
||||||
|
are serialized. Save failure leaves the window closable and reports an error.
|
||||||
|
|
||||||
|
## Implementation / Rerun upgrade inventory
|
||||||
|
|
||||||
|
- `packages/spatial-ui/src/{sceneSettings,ObservationTimeline,SceneDisplayControls}`:
|
||||||
|
shared RangeControl, three-minute default, per-profile timeline maximum.
|
||||||
|
- `apps/control-station/src/core/observation/{sessionDisplayProfile,useSessionDisplayProfile}`
|
||||||
|
and App: JSON mapping, validation, live preview and close-to-save session-bound persistence.
|
||||||
|
- `src/k1link/sessions/display_profile.py`, SessionStore and session_api:
|
||||||
|
confined GET/PUT display-profile endpoint, atomic fsync/replace sidecar.
|
||||||
|
- Plugin contribution `point_display_renderer`, K1 `recorded_point_display.py`:
|
||||||
|
read-only full timeline traversal, deterministic ranked sampling, full-frame
|
||||||
|
palette before selecting rows, corrected map-frame ownership retained.
|
||||||
|
- Follow-up fix `prepared_point_display.py`: intensity/Turbo decimation reads
|
||||||
|
exact positions and packed colors from the already prepared operator RRD via
|
||||||
|
`RrdReaderInternal`/Arrow instead of re-normalizing raw transport. The client
|
||||||
|
supplies the source SHA-256; the server validates and pins that exact ready
|
||||||
|
preparation and releases it on completion, startup failure or cancellation.
|
||||||
|
A stale generation fails with 412. The hook also fences applied banks by
|
||||||
|
source generation. Neither a browser-supplied filesystem path nor a second
|
||||||
|
recording is admitted. Other color modes retain the raw decoding fallback.
|
||||||
|
- `recordedPointDisplay.ts`, `useRecordedPointDisplay.ts`, RerunViewport and
|
||||||
|
viewer/recorded.py: framed RRD batches into the existing receiver, unique generation
|
||||||
|
entity below `/world/display_points`, then blueprint activation. Inactive
|
||||||
|
generations are excluded before streaming, including old cached RRDs. Unique
|
||||||
|
paths avoid accumulating old samples at the same temporal entity. Previous
|
||||||
|
representation remains visible during preparation or failure. 0% uses the
|
||||||
|
base entity; 100% uses visibility, without rebuilding an empty recording.
|
||||||
|
- The same recording, iframe, clock and native camera remain active. Blueprint
|
||||||
|
activation carries actual native eye. Changing only accumulation or size does
|
||||||
|
not regenerate points. Native SDK/WebViewer remain 0.36.3 with the previously
|
||||||
|
accepted navigation patch; this increment adds no Rust/WASM patch.
|
||||||
|
|
||||||
|
The HTTP envelope is NPD1 + repeated little-endian u32 length / standalone RRF2
|
||||||
|
payload + a terminal zero length. Native `send_rrd` expects a complete RRD per
|
||||||
|
call, not arbitrary HTTP chunks. Browser QA caught this distinction: an earlier
|
||||||
|
prototype appeared thinned but logged decoder errors and was rejected. The
|
||||||
|
replacement frames transport explicitly, validates every batch and refuses
|
||||||
|
activation without the success terminator. A multi-batch RrdReader regression
|
||||||
|
decodes each payload independently and counts every input frame.
|
||||||
|
|
||||||
|
Upgrade checks: complete RRF2 batch admission, same recording ID, temporal query and
|
||||||
|
generation include/exclude precedence, point-size overrides on alternate entity,
|
||||||
|
current eye preservation, retained pause/seek and teardown/abort. Repeat profile
|
||||||
|
roundtrip and failure tests independently of browser local storage.
|
||||||
|
|
||||||
|
## Verification and limits
|
||||||
|
|
||||||
|
Frontend complete suite: 900 passed; production TypeScript/Vite build passed.
|
||||||
|
Final backend display/profile/colors/session/blueprint/plugin-boundary suites:
|
||||||
|
65 distinct tests passed, including atomic-write failure, source-generation
|
||||||
|
pin/release, independently decodable batches and exact prepared-RRD attributes.
|
||||||
|
Sampling tests cover 0/5/49/49.5/50/99.9/100%, exact count and nested subsets;
|
||||||
|
stream cancellation releases the preparation slot. Frontend tests cover
|
||||||
|
fragmented RRF2 headers, cancellation, cross-origin rejection, profile identity,
|
||||||
|
range override and recorded-only control exposure.
|
||||||
|
|
||||||
|
Canonical port 8000 browser QA on corrected JA-STROITEL-SUN-RING-002: staged
|
||||||
|
95% decimation, 600 s range and 60 s current window persisted on close. The
|
||||||
|
prototype visibly thinned the cloud, but its decoder errors invalidate that as
|
||||||
|
evidence of complete delivery. Pause remained at 01:45.173 and grid/camera
|
||||||
|
framing stayed unchanged. 100% removed only points; 0% restored full cloud
|
||||||
|
without decoding it again. This tests display, not geometric accuracy or
|
||||||
|
localization.
|
||||||
|
|
||||||
|
The earlier framed-protocol browser trials admitted multiple batches without codec
|
||||||
|
errors, but service restarts interrupted the requests before their success
|
||||||
|
terminator. The previous representation remained visible and the UI reported
|
||||||
|
the failure; it did not activate the incomplete generation. Full real-recording
|
||||||
|
completion was NOT browser-qualified at that point.
|
||||||
|
The watchdog journal has no restart-requested event for these restarts; their
|
||||||
|
initiator has not been established. No watchdog setting was weakened. The small
|
||||||
|
ring's separate replay preparation was also interrupted by a service restart.
|
||||||
|
That earlier test must not be cited as a passed large-recording test.
|
||||||
|
|
||||||
|
After a page reload, the recording restored its exact 0.5 px point size, 0%
|
||||||
|
decimation, 600 s custom range and 60 s accumulation from the JSON profile.
|
||||||
|
The QA session was subsequently returned to 0% decimation, a 180 s range and
|
||||||
|
47 s window. The final retry-dependency fix passed production build and the nine
|
||||||
|
focused frontend profile/camera tests: unrelated size/time edits do not cancel
|
||||||
|
preparation of the same sampling key, while closing Display retries a failure.
|
||||||
|
|
||||||
|
Follow-up browser acceptance on canonical 8000, 22 September: persisted 86.2%
|
||||||
|
was reproduced in JA-STROITEL-SUN-RING-002. The old renderer was still decoding
|
||||||
|
raw transport; retaining the old representation until completion made it look
|
||||||
|
inert. After installing the prepared-RRD path, the complete generated bank was
|
||||||
|
activated. At the same paused cursor 01:38.386 and accumulation 49 s, switching
|
||||||
|
86.2% -> 0% visibly restored the dense cloud; 100% removed points while keeping
|
||||||
|
grid/trajectory, and returning to 86.2% restored the sparse cloud. Camera framing
|
||||||
|
and the paused cursor remained unchanged. Expanded mode and host Escape return
|
||||||
|
passed. Browser error log was empty (only known native web viewport-command
|
||||||
|
warnings). The source had no decoder errors. This closes the earlier real-file
|
||||||
|
activation acceptance item. The test leaves the user's 86.2%, 0.5 px, 180 s
|
||||||
|
range and 49 s current window intact. It does not claim exact GPU counts or
|
||||||
|
physical gesture coverage. The synthetic RRD test proves 138/1000 points per
|
||||||
|
frame at 86.2%, exact XYZ/RGBA and original timestamps without raw decoding.
|
||||||
|
|
||||||
|
OPS registry #74 is updated only after this follow-up browser qualification;
|
||||||
|
the long-lived upgrade registry itself is not moved to Done.
|
||||||
|
|
||||||
|
Performance limitation: the old raw path took several minutes on the
|
||||||
|
24-minute/49-million-point recording. The intensity/Turbo path now traverses the
|
||||||
|
prepared RRD; other palettes/modes still use the slower raw path. A new percentage
|
||||||
|
still needs asynchronous preparation, not instantaneous GPU sampling. The server
|
||||||
|
streams bounded batches (one preparation at a time), and the browser avoids a
|
||||||
|
whole-cloud JS blob. Decimation reduces visible geometry/draw work; it does NOT
|
||||||
|
evict the base recording or guarantee lower total native store RAM. Inactive
|
||||||
|
generations remain subject to native store retention until viewer disposal.
|
||||||
|
Repeated-percentage memory stress and a persistent preview cache are not claimed
|
||||||
|
as qualified. Raw/corrected source integrity and navigation are not traded for
|
||||||
|
speed. No extra backend, Docker runtime or alternate renderer was introduced.
|
||||||
|
|
||||||
|
Host acceptance limitation: the disk had about 300 MiB free during final QA,
|
||||||
|
below the service's 2 GiB reserve. `/api/health` reported recording-cache
|
||||||
|
capacity-pressure while the single canonical service continued serving 8000;
|
||||||
|
8765 had no listener. No recordings were deleted and the reserve was not
|
||||||
|
weakened. New materialization/load stress is not safe until disk headroom is
|
||||||
|
recovered; the prepared-RRD fix does not add a new persistent cloud cache.
|
||||||
|
|
||||||
|
The product UI skill kept the controls in the admitted Display window and reused
|
||||||
|
canonical RangeControl/ToastStack rather than creating another workspace.
|
||||||
|
|
||||||
|
## Follow-up: open-inspector preview regression
|
||||||
|
|
||||||
|
The operator's 07:57 screenshots exposed a second, independent defect after the
|
||||||
|
prepared-RRD fix. `stageDisplayPatch` returned early whenever recorded playback
|
||||||
|
and Display were both open. Both the inspector and the bottom accumulation
|
||||||
|
slider used this callback: their labels showed the draft, while Rerun continued
|
||||||
|
to receive the previous scene settings. The preceding browser acceptance only
|
||||||
|
tested changes after closing Display and did not cover this interaction.
|
||||||
|
|
||||||
|
The inspector-open suppression is removed. Preview is independent of JSON
|
||||||
|
persistence; close still saves the latest shared draft, including edits made
|
||||||
|
from the bottom slider. Cheap 0%/100% changes apply immediately. Intermediate
|
||||||
|
percentage edits remain coalesced and asynchronous, with the previous complete
|
||||||
|
bank retained until the replacement succeeds. Returning to a cached bank or
|
||||||
|
0% now clears an obsolete preparation status. No raw/corrected data, camera
|
||||||
|
navigation or native viewer build is changed by this follow-up.
|
||||||
|
|
||||||
|
Preparation status is placed upper-right on the source-button vertical axis.
|
||||||
|
Camera windows default to bottom-left, still clear of the timeline. Explicit
|
||||||
|
saved/user-moved window rectangles retain priority over the default placement.
|
||||||
|
The product UI skill preserves the existing window and range primitives.
|
||||||
|
|
||||||
|
Follow-up validation: architecture 4/4, full frontend 902/902, TypeScript and
|
||||||
|
production Vite build passed; `git diff --check` passed. The regression test
|
||||||
|
executes the actual App callbacks with controlled timers: open inspector,
|
||||||
|
100% -> 0%, 180 s -> 10 s, fractional 49.5% preview, then close-to-save. Existing
|
||||||
|
lower-right/left-status layout assertions were updated to the requested layout.
|
||||||
|
|
||||||
|
Visual acceptance of this follow-up remains PENDING. The prepared large ring
|
||||||
|
loaded after the rebuild, but browser control was interrupted before the
|
||||||
|
open-inspector comparison. After the host restart the canonical LaunchAgent
|
||||||
|
failed before application startup: launchd reported `posix_spawn(uv): Operation
|
||||||
|
not permitted`, exit 78, with no listener on 8000. Kickstart and re-registration
|
||||||
|
of the identical existing plist did not recover it. No permissions, plist,
|
||||||
|
data, raw recording, or other service were changed. No alternate backend was
|
||||||
|
started. This startup failure is not proof of a Rerun failure; its exact OS-level
|
||||||
|
cause is not established. OPS is not updated for this unfinished acceptance.
|
||||||
|
|
||||||
|
### Resumed acceptance and fractional cursor regression
|
||||||
|
|
||||||
|
The owner requested coordination with Mission Core - SIM. That task owns the
|
||||||
|
canonical service restart; this task does not launch another backend. SIM
|
||||||
|
recovered the existing LaunchAgent by moving only its startup log out of
|
||||||
|
Downloads to the user's Library/Logs directory. Canonical health subsequently
|
||||||
|
reported operational=true, one listener on 8000, none on 8765. Browser QA ran
|
||||||
|
with one prepared recording, no build/test overlap, and 53–75% reported free
|
||||||
|
memory. Original per-session settings (86.2%, 49 s, 180 s maximum) were restored
|
||||||
|
and verified through the profile API before closing the temporary viewer for
|
||||||
|
SIM's next explicitly coordinated restart.
|
||||||
|
|
||||||
|
Open-inspector 100% removed points and 0% restored the dense cloud; the camera
|
||||||
|
window appeared bottom-left. However, after seek and Follow on/off, 180 s and
|
||||||
|
10 s produced an unchanged view and later display requests stopped reaching the
|
||||||
|
blueprint endpoint. This is NOT accepted as working accumulation.
|
||||||
|
|
||||||
|
An additional concrete boundary defect was reproduced in a focused test:
|
||||||
|
`fetchRecordedBlueprintRrd` rejected a native fractional-nanosecond cursor such
|
||||||
|
as 536460021972.65625 before making the request. Pinned Rerun 0.36.3
|
||||||
|
`crates/viewer/re_viewer/src/web.rs::get_time_for_timeline` returns TimeReal as
|
||||||
|
f64, not an integer. A failed following-eye transition remains pending, so
|
||||||
|
later display-only updates retry that same invalid pose-query timestamp.
|
||||||
|
The request boundary now rounds to the nearest integer nanosecond (at most
|
||||||
|
0.5 ns); the native playback cursor is untouched. Negative, non-finite and
|
||||||
|
unsafe timestamps still fail. Aborted requests stay quiet; other blueprint
|
||||||
|
failures now produce a console warning instead of disappearing silently.
|
||||||
|
The new regression failed before the change and passed after it. Full gates
|
||||||
|
and resumed end-to-end accumulation verification are still pending; OPS remains
|
||||||
|
unchanged until they pass.
|
||||||
|
|
||||||
|
### Final acceptance after the fractional cursor fix
|
||||||
|
|
||||||
|
The sequential gates now pass: architecture 4/4, TypeScript, full frontend
|
||||||
|
903/903 (zero skipped), production Vite build and `git diff --check`. The build
|
||||||
|
retains only the existing large-chunk warning. Served asset
|
||||||
|
`app-Bm1TJUff.js` was confirmed before the final browser pass. No native/WASM
|
||||||
|
rebuild or further service restart was needed.
|
||||||
|
|
||||||
|
On JA-STROITEL-SUN-RING-002, with Display open and the same paused cursor/camera,
|
||||||
|
180 s showed the longer cloud history and 10 s visibly removed older geometry.
|
||||||
|
This also passed after seeking to 01:00.780 and toggling Follow on/off. 100%
|
||||||
|
removed only points; 0% restored them. 99.9% yielded a near-empty cloud while
|
||||||
|
keeping grid/trajectory, and returning to 86.2% restored the prepared subset.
|
||||||
|
During that replacement the preparation pill was visibly upper-right, aligned
|
||||||
|
with the source/expand controls. Camera default was bottom-left. Expanded view
|
||||||
|
and Escape returned to the same paused frame and accumulation. Console error
|
||||||
|
entries and the new recorded-scene failure warnings were both empty.
|
||||||
|
|
||||||
|
Original session profile values were restored and read back after closing
|
||||||
|
Display: 86.2% decimation, 49 s accumulation, 180 s scale maximum, 0.5 point size,
|
||||||
|
intensity/Turbo. Source recordings and sealed method.json were not rewritten.
|
||||||
|
No exact GPU point-count claim or physical trackpad-device qualification is
|
||||||
|
made by this browser pass.
|
||||||
|
|
||||||
|
The temporary viewer was then closed rather than left resident: the explicitly
|
||||||
|
coordinated Mission Core - SIM task needed the next single-viewer acceptance
|
||||||
|
window, and the host had recently exhausted RAM. SIM was notified that the
|
||||||
|
frontend gates had already passed and must not be duplicated unnecessarily.
|
||||||
|
Final local checks: operational=true, one canonical listener on 8000, none on
|
||||||
|
8765, 62% reported free memory and about 16.5 GB available disk. No background
|
||||||
|
build, second backend or large-recording stress test was started.
|
||||||
|
|
||||||
|
### Operator cleanup and repository packaging
|
||||||
|
|
||||||
|
At the owner's request the persistent LMB/RMB/wheel hint was removed from the
|
||||||
|
shared SpatialScene and both recorded/live callers, with its unused CSS and
|
||||||
|
readiness prop. Native camera input and renderer code were not changed.
|
||||||
|
Architecture/layout checks 9/9, frontend 904/904, typecheck and production build
|
||||||
|
passed. Lightweight browser inspection confirmed the hint was absent; the large
|
||||||
|
recording was not reloaded solely for this copy removal after host swap growth.
|
||||||
|
The canonical service remained healthy and the temporary browser was closed.
|
||||||
|
|
||||||
|
The accepted 0.36.3 native WASM is packaged through Git LFS, matching the existing
|
||||||
|
binary-artifact convention for the archived viewer. The source patch, paired
|
||||||
|
JS/types, licenses and hash-bound build manifest remain normal versioned files.
|
||||||
|
Raw captures, runtime state and simulation work-in-progress are outside this
|
||||||
|
publication scope. The long-lived Ops customization registry is not marked done.
|
||||||
|
|
||||||
|
Before publication, the selected Git index was exported into an isolated
|
||||||
|
temporary source snapshot, excluding every pending SIM change (including the
|
||||||
|
SIM hunks in App, dependency manifests and web/app). Against that snapshot,
|
||||||
|
154 focused frontend tests, TypeScript typecheck and 230 focused backend tests
|
||||||
|
passed. Dependencies were reused from the existing local installation; this is
|
||||||
|
not a clean dependency-install qualification. The only backend warning was the
|
||||||
|
existing Starlette/httpx deprecation. First-party staged diff checks passed;
|
||||||
|
generated vendor JS and source-patch context retain their hash-bound whitespace.
|
||||||
|
The staged WASM is a Git LFS pointer to the manifest-verified 50,469,154-byte
|
||||||
|
artifact. Working files were unchanged by staging. No service restart, new
|
||||||
|
physical scan, repeat full build or large-recording replay was needed for this
|
||||||
|
publication gate; canonical 8000 returned operational=true.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Admit a reviewed full map as the physical session's operator/LAB default.
|
||||||
|
|
||||||
|
Explicit offline maintenance command; no solver, no device commands, no new
|
||||||
|
catalog session and no change to raw capture or already pinned studies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from package_recorded_map_version import RecordedParent
|
||||||
|
|
||||||
|
from k1link.reconstruction.map_version import MapVersion
|
||||||
|
from k1link.reconstruction.session_versions import SessionMapVersions
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--version", type=Path, required=True)
|
||||||
|
parser.add_argument("--raw", type=Path, required=True)
|
||||||
|
parser.add_argument("--data-dir", type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
version = MapVersion(args.version, args.version.name)
|
||||||
|
parent = version.document["source"]
|
||||||
|
original = RecordedParent(args.raw, parent["session_id"], parent["generation"])
|
||||||
|
admitted = SessionMapVersions(args.data_dir).activate(version, original)
|
||||||
|
print(
|
||||||
|
f"Session: {parent['session_id']}\nDefault map: {admitted.generation}\n"
|
||||||
|
"Uses: recorded playback, laboratory reference. Vehicle control: false."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Read-only, chunk-bounded check of the actual point rows in a derived RRD."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pyarrow.compute as pc
|
||||||
|
from rerun.experimental import RrdReader
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("recording", type=Path)
|
||||||
|
parser.add_argument("--expected-frames", type=int, required=True)
|
||||||
|
parser.add_argument("--expected-points", type=int, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
frames = points = 0
|
||||||
|
for chunk in RrdReader(args.recording).stream():
|
||||||
|
if chunk.entity_path != "/world/points":
|
||||||
|
continue
|
||||||
|
batch = chunk.to_record_batch()
|
||||||
|
if "Points3D:positions" not in batch.schema.names:
|
||||||
|
continue
|
||||||
|
positions = batch.column("Points3D:positions")
|
||||||
|
frames += len(positions) - positions.null_count
|
||||||
|
points += pc.sum(pc.list_value_length(positions)).as_py() or 0
|
||||||
|
result = {
|
||||||
|
"recording": str(args.recording),
|
||||||
|
"point_frames": frames,
|
||||||
|
"points": points,
|
||||||
|
"expected_point_frames": args.expected_frames,
|
||||||
|
"expected_points": args.expected_points,
|
||||||
|
"passed": frames == args.expected_frames and points == args.expected_points,
|
||||||
|
}
|
||||||
|
print(json.dumps(result, indent=2))
|
||||||
|
return 0 if result["passed"] else 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
"""Package the reviewed ring derivative for explicit offline planning consumption.
|
||||||
|
|
||||||
|
Reads the canonical source API, but never writes to it or imports the web app.
|
||||||
|
Verifies the raw transport, clocks, reviewed code/artifacts and all corrected
|
||||||
|
frames against the frozen correction field before publishing a separate bundle.
|
||||||
|
No refit, capture, reference switch, source-catalog entry or vehicle authority.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from k1link.missions.versioned_sources import VersionedPlanningSources
|
||||||
|
from k1link.reconstruction.map_version import publish_map_version, sha256
|
||||||
|
from k1link.reconstruction.smooth_correction import CorrectionField
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedParent:
|
||||||
|
"""A read-only generation-bound parent; all native clock artifacts are checked."""
|
||||||
|
|
||||||
|
def __init__(self, raw, session_id, generation):
|
||||||
|
self.raw, self.session_id, self.generation = raw, session_id, generation
|
||||||
|
|
||||||
|
def get(self, session_id):
|
||||||
|
if session_id != self.session_id:
|
||||||
|
raise ValueError("Unexpected source session.")
|
||||||
|
url = (
|
||||||
|
"http://127.0.0.1:8000/api/v1/mission-planner/sources/"
|
||||||
|
+ quote(session_id, safe="")
|
||||||
|
+ "?generation="
|
||||||
|
+ quote(self.generation, safe="")
|
||||||
|
)
|
||||||
|
with urlopen(url, timeout=30) as response:
|
||||||
|
source = json.load(response)
|
||||||
|
if source["generation"] != self.generation:
|
||||||
|
raise ValueError("Original source generation changed.")
|
||||||
|
return source
|
||||||
|
|
||||||
|
def verify(self, session_id, generation):
|
||||||
|
if generation != self.generation:
|
||||||
|
raise ValueError("Original source generation changed.")
|
||||||
|
source = self.get(session_id)
|
||||||
|
# Plugin-specific recorded-ring adapter, not a generic Core discovery rule.
|
||||||
|
expected = source["source_digests"]
|
||||||
|
if expected.get("raw-transport-primary") != sha256(self.raw):
|
||||||
|
raise ValueError("Original transport identity changed.")
|
||||||
|
index = self.raw.with_name("mqtt.metadata.jsonl")
|
||||||
|
origin = self.raw.with_name("mqtt.timeline.origin.json")
|
||||||
|
if sha256(index) != expected.get("raw-transport-index") or sha256(origin) != expected.get(
|
||||||
|
"raw-transport-clock-origin"
|
||||||
|
):
|
||||||
|
raise ValueError("Original receipt clocks changed.")
|
||||||
|
clocks = [
|
||||||
|
self.raw.with_name("mqtt.timeline.json"),
|
||||||
|
self.raw.with_name(
|
||||||
|
"mqtt.timeline.session-" + expected["raw-transport-clock"] + ".json"
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if not any(
|
||||||
|
p.is_file() and sha256(p) == expected.get("raw-transport-clock") for p in clocks
|
||||||
|
):
|
||||||
|
raise ValueError("Original capture clock changed.")
|
||||||
|
if self.get(session_id) != source:
|
||||||
|
raise ValueError("Original source changed during verification.")
|
||||||
|
return source
|
||||||
|
|
||||||
|
bound = verify
|
||||||
|
|
||||||
|
|
||||||
|
def check_closure_review(result, summary):
|
||||||
|
"""New producers must carry a complete acquisition and a positive frozen review.
|
||||||
|
|
||||||
|
Retain explicit v1 compatibility for previously reviewed/pinned bundles.
|
||||||
|
A missing v2 review cannot silently fall back to the legacy contract.
|
||||||
|
"""
|
||||||
|
schema = summary.get("schema_version")
|
||||||
|
if schema == "missioncore.recorded-ring-experiment/v1":
|
||||||
|
return []
|
||||||
|
if schema != "missioncore.recorded-ring-experiment/v2":
|
||||||
|
raise ValueError("Unsupported closure producer contract.")
|
||||||
|
search = json.loads((result / "closure-search.json").read_text())
|
||||||
|
review = json.loads((result / "review.json").read_text())
|
||||||
|
acceptance = review.get("acceptance", {})
|
||||||
|
required = {
|
||||||
|
"all_local_windows_qualified",
|
||||||
|
"heldout_seam_present",
|
||||||
|
"heldout_seam_quality",
|
||||||
|
"heldout_seam_not_degraded",
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
search.get("status") != "candidate"
|
||||||
|
or search.get("complete") is not True
|
||||||
|
or len(search.get("attempts", [])) != search.get("expected_attempts")
|
||||||
|
or acceptance.get("schema_version") != "missioncore.closure-review/v1"
|
||||||
|
or acceptance.get("accepted") is not True
|
||||||
|
or set(acceptance.get("checks", {})) != required
|
||||||
|
or any(acceptance["checks"][key] is not True for key in required)
|
||||||
|
or summary.get("closure_acquisition") != "candidate"
|
||||||
|
):
|
||||||
|
raise ValueError("Closure acquisition or held-out review is not qualified.")
|
||||||
|
return ["closure-search.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(args):
|
||||||
|
started = time.monotonic()
|
||||||
|
result = args.result.resolve()
|
||||||
|
seal_path = result / "review.json.seal.json"
|
||||||
|
if sha256(seal_path) != args.review_seal_sha256:
|
||||||
|
raise ValueError("Reviewed evidence seal identity differs.")
|
||||||
|
sealed = json.loads(seal_path.read_text())
|
||||||
|
for path, expected in sealed.items():
|
||||||
|
if sha256(Path(path)) != expected:
|
||||||
|
raise ValueError("Reviewed artifact or producer changed: " + Path(path).name)
|
||||||
|
names = [
|
||||||
|
"summary.json",
|
||||||
|
"correction.json",
|
||||||
|
"validation.json",
|
||||||
|
"registrations.json",
|
||||||
|
"surface-links.json",
|
||||||
|
"review.json",
|
||||||
|
"corrected-points.f32",
|
||||||
|
"corrected-trajectory.npz",
|
||||||
|
]
|
||||||
|
summary = json.loads((result / "summary.json").read_text())
|
||||||
|
additional_evidence = check_closure_review(result, summary)
|
||||||
|
if any(str(result / name) not in sealed for name in names + additional_evidence):
|
||||||
|
raise ValueError("Review seal does not bind the complete candidate.")
|
||||||
|
if (
|
||||||
|
summary["status"] != "experimental-candidate-not-promoted"
|
||||||
|
or summary["production_promotion"]
|
||||||
|
or summary["vehicle_control"]
|
||||||
|
):
|
||||||
|
raise ValueError("Unsupported experiment contract or authority.")
|
||||||
|
parent = RecordedParent(args.raw, summary["source_session"], args.source_generation)
|
||||||
|
source = parent.verify(summary["source_session"], args.source_generation)
|
||||||
|
if sha256(args.raw) != summary["source"]["source_sha256"]:
|
||||||
|
raise ValueError("Experiment belongs to another physical recording.")
|
||||||
|
correction = json.loads((result / "correction.json").read_text())
|
||||||
|
if (
|
||||||
|
correction["schema_version"] != "missioncore.smooth-map-correction/v2"
|
||||||
|
or not correction["converged"]
|
||||||
|
):
|
||||||
|
raise ValueError("A converged, reviewed v2 correction is required.")
|
||||||
|
field = CorrectionField(correction["knots_m"], correction["parameters"], correction["origin_m"])
|
||||||
|
cache = args.cache.resolve()
|
||||||
|
cache_seal = json.loads((cache / "seal.json").read_text())
|
||||||
|
for name in ("source-points.f32", "source-intensity.u8", "index.npz", "source.json"):
|
||||||
|
if sha256(cache / name) != cache_seal[name]:
|
||||||
|
raise ValueError("Decoded source cache changed.")
|
||||||
|
if json.loads((cache / "source.json").read_text()) != summary["source"]:
|
||||||
|
raise ValueError("Decoded cache belongs to another experiment source.")
|
||||||
|
with np.load(cache / "index.npz", allow_pickle=False) as original:
|
||||||
|
poses, frames = original["poses"], original["frames"]
|
||||||
|
distances, frame_distances = original["distance"], original["frame_distance"]
|
||||||
|
with np.load(result / "corrected-trajectory.npz", allow_pickle=False) as data:
|
||||||
|
trajectory = {k: data[k] for k in data.files}
|
||||||
|
if (
|
||||||
|
len(poses) != len(source["poses"])
|
||||||
|
or not np.allclose(
|
||||||
|
poses[:, 1:4], [p["position"] for p in source["poses"]], atol=1e-10, rtol=0
|
||||||
|
)
|
||||||
|
or not np.allclose(
|
||||||
|
poses[:, 0] - poses[0, 0], [p["elapsed_s"] for p in source["poses"]], atol=1e-6, rtol=0
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("Source trajectory or clock ownership differs from the reviewed cache.")
|
||||||
|
positions, orientations = field.poses(poses[:, 1:4], poses[:, 4:8], distances)
|
||||||
|
if (
|
||||||
|
not np.allclose(positions, trajectory["positions"], atol=1e-9, rtol=0)
|
||||||
|
or not np.allclose(orientations, trajectory["orientations_xyzw"], atol=1e-9, rtol=0)
|
||||||
|
or not np.array_equal(frames, trajectory["frames"])
|
||||||
|
or not np.array_equal(poses[:, 0], trajectory["receipt_time_s"])
|
||||||
|
or not np.array_equal(distances, trajectory["distance_m"])
|
||||||
|
or not np.array_equal(frame_distances, trajectory["frame_distance_m"])
|
||||||
|
):
|
||||||
|
raise ValueError("Corrected poses/frames do not reproduce the reviewed field.")
|
||||||
|
maximum_error = 0.0
|
||||||
|
with (
|
||||||
|
(cache / "source-points.f32").open("rb") as raw_points,
|
||||||
|
(result / "corrected-points.f32").open("rb") as corrected,
|
||||||
|
):
|
||||||
|
for frame, distance in zip(frames, frame_distances, strict=True):
|
||||||
|
count = int(frame[3])
|
||||||
|
original = np.frombuffer(raw_points.read(count * 12), dtype="<f4").reshape(-1, 3)
|
||||||
|
actual = np.frombuffer(corrected.read(count * 12), dtype="<f4").reshape(-1, 3)
|
||||||
|
expected = field.points(original, distance).astype("<f4")
|
||||||
|
if not np.array_equal(expected, actual):
|
||||||
|
raise ValueError("Corrected full-resolution frame differs from its frozen field.")
|
||||||
|
maximum_error = max(
|
||||||
|
maximum_error, float(np.max(abs(actual - field.points(original, distance))))
|
||||||
|
)
|
||||||
|
if raw_points.read(1) or corrected.read(1):
|
||||||
|
raise ValueError("Unindexed points remain after complete frame verification.")
|
||||||
|
print(f"Verified {len(frames)} full-resolution frames and {len(poses)} poses.", flush=True)
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
with TemporaryDirectory(prefix=".package-", dir=args.output) as temporary:
|
||||||
|
stage = Path(temporary)
|
||||||
|
normalized = stage / "trajectory.npz"
|
||||||
|
np.savez(
|
||||||
|
normalized,
|
||||||
|
positions=positions,
|
||||||
|
orientations_xyzw=orientations,
|
||||||
|
receipt_time_s=poses[:, 0],
|
||||||
|
source_distance_m=distances,
|
||||||
|
frame_source_distance_m=frame_distances,
|
||||||
|
frames=frames,
|
||||||
|
)
|
||||||
|
# Preserve measured reports byte-for-byte; the normalized trajectory is new.
|
||||||
|
evidence = {
|
||||||
|
name: (result / name, sealed[str(result / name)])
|
||||||
|
for name in names[:6] + additional_evidence
|
||||||
|
}
|
||||||
|
evidence["source-intensity.u8"] = (
|
||||||
|
cache / "source-intensity.u8",
|
||||||
|
cache_seal["source-intensity.u8"],
|
||||||
|
)
|
||||||
|
producers = {
|
||||||
|
Path(path).name: digest for path, digest in sealed.items() if path.endswith(".py")
|
||||||
|
}
|
||||||
|
receipt = dict(
|
||||||
|
schema_version="missioncore.map-version-packaging-check/v1",
|
||||||
|
review_seal_sha256=args.review_seal_sha256,
|
||||||
|
source=source["source_digests"],
|
||||||
|
checked_frames=len(frames),
|
||||||
|
checked_poses=len(poses),
|
||||||
|
full_resolution_frames_equal=True,
|
||||||
|
maximum_float32_error_m=maximum_error,
|
||||||
|
producer_sha256=sha256(Path(__file__)),
|
||||||
|
bundle_contract_sha256=sha256(
|
||||||
|
Path(__file__).parents[1] / "src/k1link/reconstruction/map_version.py"
|
||||||
|
),
|
||||||
|
source_cache_seal_sha256=sha256(cache / "seal.json"),
|
||||||
|
)
|
||||||
|
(stage / "packaging-check.json").write_text(json.dumps(receipt, indent=2, allow_nan=False))
|
||||||
|
evidence["packaging-check.json"] = (
|
||||||
|
stage / "packaging-check.json",
|
||||||
|
sha256(stage / "packaging-check.json"),
|
||||||
|
)
|
||||||
|
version = publish_map_version(
|
||||||
|
args.output,
|
||||||
|
source,
|
||||||
|
result / "corrected-points.f32",
|
||||||
|
normalized,
|
||||||
|
expected_points_sha256=sealed[str(result / "corrected-points.f32")],
|
||||||
|
expected_trajectory_sha256=sha256(normalized),
|
||||||
|
evidence=evidence,
|
||||||
|
method=dict(
|
||||||
|
algorithm=correction["policy"],
|
||||||
|
producer_sha256=producers,
|
||||||
|
review_seal_sha256=args.review_seal_sha256,
|
||||||
|
),
|
||||||
|
label=source["label"] + " · коррекция v2",
|
||||||
|
)
|
||||||
|
parent.verify(source["session_id"], source["generation"])
|
||||||
|
for path, expected in sealed.items():
|
||||||
|
if sha256(Path(path)) != expected:
|
||||||
|
raise ValueError("Experiment changed while packaging; do not consume this candidate.")
|
||||||
|
if args.check_map:
|
||||||
|
adapter = VersionedPlanningSources(parent, version, args.output / "scratch")
|
||||||
|
doc = adapter.bound(source["session_id"], version.generation)
|
||||||
|
cloud, provenance = adapter.reference_map(
|
||||||
|
source["session_id"], version.generation, 0, len(doc["poses"]) - 1
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
dict(
|
||||||
|
map_points=len(cloud),
|
||||||
|
tiles=len(provenance["tiles"]),
|
||||||
|
corrected_path_m=doc["path_m"],
|
||||||
|
source_path_m=source["path_m"],
|
||||||
|
)
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
version.verify()
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
dict(
|
||||||
|
version_sha256=version.generation,
|
||||||
|
directory=str(version.directory),
|
||||||
|
elapsed_s=time.monotonic() - started,
|
||||||
|
runtime_promoted=False,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
flush=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--result", required=True, type=Path)
|
||||||
|
parser.add_argument("--review-seal-sha256", required=True)
|
||||||
|
parser.add_argument("--source-generation", required=True)
|
||||||
|
parser.add_argument("--raw", required=True, type=Path)
|
||||||
|
parser.add_argument("--cache", required=True, type=Path)
|
||||||
|
parser.add_argument("--output", required=True, type=Path)
|
||||||
|
parser.add_argument("--check-map", action="store_true")
|
||||||
|
prepare(parser.parse_args())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Attach a source-bound paired preview to an existing session overview.
|
||||||
|
|
||||||
|
No fitting, raw/session writes, planner selection or map promotion. The runtime
|
||||||
|
reads the resulting small vendor-neutral pair, never experiment cache paths.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import quote
|
||||||
|
from urllib.request import urlopen
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from package_recorded_map_version import RecordedParent
|
||||||
|
|
||||||
|
from k1link.reconstruction.map_version import MapVersion, sha256
|
||||||
|
from k1link.sessions.overview_comparison import MAX_POINTS, MAX_POSES, publish_comparison
|
||||||
|
|
||||||
|
|
||||||
|
def publish(args):
|
||||||
|
version = MapVersion(args.version, args.version.name)
|
||||||
|
doc = version.verify()
|
||||||
|
source_id = doc["source"]
|
||||||
|
parent = RecordedParent(args.raw, source_id["session_id"], source_id["generation"])
|
||||||
|
source = parent.verify(source_id["session_id"], source_id["generation"])
|
||||||
|
if source["source_digests"] != source_id["source_digests"]:
|
||||||
|
raise ValueError("Map source identity differs.")
|
||||||
|
cache = args.cache
|
||||||
|
check = json.loads((version.directory / "packaging-check.json").read_text())
|
||||||
|
seal_path = cache / "seal.json"
|
||||||
|
if sha256(seal_path) != check["source_cache_seal_sha256"]:
|
||||||
|
raise ValueError("Decoded source cache seal differs from admitted map.")
|
||||||
|
sealed = json.loads(seal_path.read_text())
|
||||||
|
|
||||||
|
def verify_cache():
|
||||||
|
for name in ("source.json", "source-points.f32", "index.npz"):
|
||||||
|
if sha256(cache / name) != sealed[name]:
|
||||||
|
raise ValueError("Decoded source cache changed: " + name)
|
||||||
|
|
||||||
|
verify_cache()
|
||||||
|
original_doc = json.loads((cache / "source.json").read_text())
|
||||||
|
if original_doc["source_sha256"] != source_id["source_digests"]["raw-transport-primary"]:
|
||||||
|
raise ValueError("Decoded source belongs to another recording.")
|
||||||
|
points = np.memmap(cache / "source-points.f32", dtype="<f4", mode="r").reshape(-1, 3)
|
||||||
|
corrected = np.memmap(version.directory / "points.f32", dtype="<f4", mode="r").reshape(-1, 3)
|
||||||
|
if points.shape != corrected.shape or len(points) != doc["point_count"]:
|
||||||
|
raise ValueError("Point correspondence differs.")
|
||||||
|
arrays = version.arrays()
|
||||||
|
with np.load(cache / "index.npz", allow_pickle=False) as data:
|
||||||
|
poses = data["poses"]
|
||||||
|
if (
|
||||||
|
not np.array_equal(data["frames"], arrays["frames"])
|
||||||
|
or not np.array_equal(poses[:, 0], arrays["receipt_time_s"])
|
||||||
|
or not np.allclose(
|
||||||
|
poses[:, 1:4], [p["position"] for p in source["poses"]], rtol=0, atol=1e-10
|
||||||
|
)
|
||||||
|
):
|
||||||
|
raise ValueError("Source observation ownership differs.")
|
||||||
|
point_indices = np.linspace(0, len(points) - 1, min(MAX_POINTS, len(points)), dtype=np.int64)
|
||||||
|
pose_indices = np.linspace(0, len(poses) - 1, min(MAX_POSES, len(poses)), dtype=np.int64)
|
||||||
|
pair = dict(
|
||||||
|
original=np.array(points[point_indices]),
|
||||||
|
corrected=np.array(corrected[point_indices]),
|
||||||
|
original_route=poses[pose_indices, 1:4],
|
||||||
|
corrected_route=arrays["positions"][pose_indices],
|
||||||
|
)
|
||||||
|
del points, corrected
|
||||||
|
url = (
|
||||||
|
"http://127.0.0.1:8000/api/v1/observation-sessions/"
|
||||||
|
+ quote(source_id["session_id"], safe="")
|
||||||
|
+ "/overview"
|
||||||
|
)
|
||||||
|
with urlopen(url, timeout=30) as response:
|
||||||
|
overview = json.load(response)
|
||||||
|
if overview["state"] != "ready":
|
||||||
|
raise ValueError("Prepare the existing source overview before attaching a comparison.")
|
||||||
|
generation = overview["generation"]
|
||||||
|
report = json.loads(
|
||||||
|
(args.data_dir / "session-overviews" / generation / "overview.json").read_text()
|
||||||
|
)
|
||||||
|
if report["source_digests"] != source_id["source_digests"]:
|
||||||
|
raise ValueError("Overview source differs from map source.")
|
||||||
|
# Recheck every mutable input after sampling, before atomic view-only publication.
|
||||||
|
verify_cache()
|
||||||
|
version.verify()
|
||||||
|
if parent.verify(source_id["session_id"], source_id["generation"]) != source:
|
||||||
|
raise ValueError("Source changed during preparation.")
|
||||||
|
preview = publish_comparison(
|
||||||
|
args.data_dir / "session-map-previews",
|
||||||
|
session_id=source_id["session_id"],
|
||||||
|
overview_generation=generation,
|
||||||
|
source_digests=source_id["source_digests"],
|
||||||
|
map_generation=version.generation,
|
||||||
|
source_points=doc["point_count"],
|
||||||
|
original_path_m=source["path_m"],
|
||||||
|
corrected_path_m=doc["path_m"],
|
||||||
|
**pair,
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
dict(
|
||||||
|
overview_generation=generation,
|
||||||
|
comparison_generation=preview,
|
||||||
|
sampled_points=len(point_indices),
|
||||||
|
sampled_poses=len(pose_indices),
|
||||||
|
view_only=True,
|
||||||
|
),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
for name in ("version", "cache", "raw", "data-dir"):
|
||||||
|
parser.add_argument("--" + name, type=Path, required=True)
|
||||||
|
publish(parser.parse_args())
|
||||||
@@ -0,0 +1,452 @@
|
|||||||
|
"""Offline first-loop experiment on an immutable K1 recording, never an API action.
|
||||||
|
|
||||||
|
Run with the project's map-correction extra. All outputs are private derivatives;
|
||||||
|
the caller supplies a fresh output directory, source session identity and digest.
|
||||||
|
No raw overwrites, scene publication, hardware commands or planner mutations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from dataclasses import asdict
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from importlib.metadata import version
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
from k1link.device_plugins.xgrids_k1.protocol.streams import decode_lio_pcl, decode_lio_pose
|
||||||
|
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||||
|
from k1link.missions.registration import POLICY, PreparedReference, transform
|
||||||
|
from k1link.reconstruction.closure import ClosurePolicy, ClosureUnavailable, acquire_closure
|
||||||
|
from k1link.reconstruction.smooth_correction import (
|
||||||
|
CorrectionField,
|
||||||
|
CorrectionPolicy,
|
||||||
|
SurfaceLink,
|
||||||
|
fit_correction,
|
||||||
|
)
|
||||||
|
|
||||||
|
PROFILE = dict(
|
||||||
|
version="recorded-ring-experiment/v2",
|
||||||
|
sample_stride=8,
|
||||||
|
holdout_period_s=10.0,
|
||||||
|
holdout_start_s=4.0,
|
||||||
|
holdout_duration_s=2.0,
|
||||||
|
seam_reference_s=20.0,
|
||||||
|
seam_query_s=5.0,
|
||||||
|
seam_radius_m=25.0,
|
||||||
|
local_validation_radius_m=40.0,
|
||||||
|
neighbor_radius_m=30.0,
|
||||||
|
voxel_m=0.25,
|
||||||
|
seam_translation_weight_m=0.03,
|
||||||
|
seam_rotation_weight_deg=0.05,
|
||||||
|
neighbor_translation_weight_m=0.2,
|
||||||
|
neighbor_rotation_weight_deg=0.5,
|
||||||
|
)
|
||||||
|
|
||||||
|
# A separate first-fit policy, never a mutation of the live tracking policy.
|
||||||
|
# Quality/shape/information/correspondence gates remain identical.
|
||||||
|
CLOSURE_REGISTRATION_POLICY = {
|
||||||
|
**POLICY,
|
||||||
|
"version": "offline-closure-gicp/v1",
|
||||||
|
"maximum_correction_m": 25.0,
|
||||||
|
"maximum_correction_deg": 180.0,
|
||||||
|
}
|
||||||
|
DECODE_KEYS = ("sample_stride", "holdout_period_s", "holdout_start_s", "holdout_duration_s")
|
||||||
|
|
||||||
|
|
||||||
|
def compatible_cache_profile(profile):
|
||||||
|
# Earlier sealed caches include fitting settings, though decoding never uses
|
||||||
|
# them. Reuse is safe only when every actual decode/split setting agrees.
|
||||||
|
return all(profile.get(key) == PROFILE[key] for key in DECODE_KEYS)
|
||||||
|
|
||||||
|
|
||||||
|
def digest(path):
|
||||||
|
with path.open("rb") as stream:
|
||||||
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def write_json(path, value):
|
||||||
|
with path.open("x") as stream:
|
||||||
|
json.dump(value, stream, indent=2, allow_nan=False)
|
||||||
|
|
||||||
|
|
||||||
|
def extract(raw, output, expected):
|
||||||
|
if digest(raw) != expected:
|
||||||
|
raise ValueError("Source digest mismatch before decoding.")
|
||||||
|
output.mkdir() # Exclusive new directory, never replace an earlier experiment.
|
||||||
|
started = time.monotonic()
|
||||||
|
poses, frames, samples, sample_ids = [], [], [], []
|
||||||
|
first = None
|
||||||
|
total = 0
|
||||||
|
sequences = {"lio_pose": [], "lio_pcl": []}
|
||||||
|
with (
|
||||||
|
(output / "source-points.f32").open("xb") as points_file,
|
||||||
|
(output / "source-intensity.u8").open("xb") as intensity_file,
|
||||||
|
):
|
||||||
|
for message in iter_replay_messages(raw):
|
||||||
|
if message.received_monotonic_ns is None:
|
||||||
|
raise ValueError("Source has no monotonic receipt timestamp.")
|
||||||
|
clock = message.received_monotonic_ns / 1e9
|
||||||
|
if first is None:
|
||||||
|
first = clock
|
||||||
|
t = clock - first
|
||||||
|
if message.topic.endswith("/lio_pose"):
|
||||||
|
pose = decode_lio_pose(message.payload)
|
||||||
|
sequences["lio_pose"].append(pose.header.seq)
|
||||||
|
poses.append(
|
||||||
|
[
|
||||||
|
t,
|
||||||
|
*pose.position_xyz,
|
||||||
|
*pose.orientation_xyzw,
|
||||||
|
pose.pose_stamp,
|
||||||
|
pose.header.seq,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
elif message.topic.endswith("/lio_pcl"):
|
||||||
|
cloud = decode_lio_pcl(message.payload)
|
||||||
|
sequences["lio_pcl"].append(cloud.header.seq)
|
||||||
|
data = np.asarray(cloud.points, dtype=np.int64).reshape(-1, 4)
|
||||||
|
xyz = (data[:, :3] / cloud.header.scaler).astype("<f4")
|
||||||
|
if not np.isfinite(xyz).all():
|
||||||
|
raise ValueError("Non-finite source geometry.")
|
||||||
|
xyz.tofile(points_file)
|
||||||
|
(data[:, 3] & 255).astype("u1").tofile(intensity_file)
|
||||||
|
sampled = xyz[:: PROFILE["sample_stride"]]
|
||||||
|
samples.append(sampled)
|
||||||
|
sample_ids.append(np.full(len(sampled), len(frames), dtype=np.int32))
|
||||||
|
frames.append([t, cloud.header.seq, total, len(xyz)])
|
||||||
|
total += len(xyz)
|
||||||
|
if len(frames) % 500 == 0:
|
||||||
|
print(f"decode {len(frames)} frames, {total} points", flush=True)
|
||||||
|
p, f = np.asarray(poses), np.asarray(frames)
|
||||||
|
if min(len(p), len(f)) < 2 or (np.diff(p[:, 0]) <= 0).any():
|
||||||
|
raise ValueError("Missing or unordered source trajectory.")
|
||||||
|
if (np.diff(f[:, 0]) < 0).any():
|
||||||
|
raise ValueError("Cloud receipt clock moved backwards.")
|
||||||
|
if any((np.diff(seq) != 1).any() for seq in sequences.values()):
|
||||||
|
raise ValueError("Source sequence gaps or resets require a separate review.")
|
||||||
|
distance = np.r_[0.0, np.cumsum(np.linalg.norm(np.diff(p[:, 1:4], axis=0), axis=1))]
|
||||||
|
frame_distance = np.interp(f[:, 0], p[:, 0], distance)
|
||||||
|
elapsed = f[:, 0] - f[0, 0]
|
||||||
|
phase = elapsed % PROFILE["holdout_period_s"]
|
||||||
|
held = (phase >= PROFILE["holdout_start_s"]) & (
|
||||||
|
phase < PROFILE["holdout_start_s"] + PROFILE["holdout_duration_s"]
|
||||||
|
)
|
||||||
|
nearest = np.clip(np.searchsorted(p[:, 0], f[:, 0]), 1, len(p) - 1)
|
||||||
|
gap = np.minimum(abs(p[nearest, 0] - f[:, 0]), abs(p[nearest - 1, 0] - f[:, 0]))
|
||||||
|
if digest(raw) != expected:
|
||||||
|
raise ValueError("Source changed while decoding; derivative is not admissible.")
|
||||||
|
np.savez(
|
||||||
|
output / "index.npz",
|
||||||
|
poses=p,
|
||||||
|
frames=f,
|
||||||
|
distance=distance,
|
||||||
|
frame_distance=frame_distance,
|
||||||
|
heldout=held,
|
||||||
|
sample_points=np.concatenate(samples),
|
||||||
|
sample_frame=np.concatenate(sample_ids),
|
||||||
|
)
|
||||||
|
meta = dict(
|
||||||
|
source_sha256=expected,
|
||||||
|
profile=PROFILE,
|
||||||
|
frames=len(f),
|
||||||
|
poses=len(p),
|
||||||
|
points=total,
|
||||||
|
seconds=time.monotonic() - started,
|
||||||
|
path_m=float(distance[-1]),
|
||||||
|
receipt_pose_nearest_gap_p95_s=float(np.quantile(gap, 0.95)),
|
||||||
|
receipt_pose_nearest_gap_max_s=float(gap.max()),
|
||||||
|
clock_binding="host-monotonic interpolation; NOT hardware synchronization",
|
||||||
|
mapped_increment_not_native_sweep=True,
|
||||||
|
heldout_frames=int(held.sum()),
|
||||||
|
training_frames=int((~held).sum()),
|
||||||
|
)
|
||||||
|
write_json(output / "source.json", meta)
|
||||||
|
return meta
|
||||||
|
|
||||||
|
|
||||||
|
def voxel(points):
|
||||||
|
if not len(points):
|
||||||
|
return points
|
||||||
|
_, idx = np.unique(
|
||||||
|
np.floor(points / PROFILE["voxel_m"]).astype(np.int64), axis=0, return_index=True
|
||||||
|
)
|
||||||
|
return points[np.sort(idx)]
|
||||||
|
|
||||||
|
|
||||||
|
def register(reference, query, seed, *, acquisition=False):
|
||||||
|
try:
|
||||||
|
result = PreparedReference(reference).register(
|
||||||
|
query, seed, policy=CLOSURE_REGISTRATION_POLICY if acquisition else POLICY
|
||||||
|
)
|
||||||
|
result.pop("matched_query_indices", None)
|
||||||
|
return result
|
||||||
|
except ValueError as exc:
|
||||||
|
return dict(status="unavailable", reasons=[str(exc)])
|
||||||
|
|
||||||
|
|
||||||
|
def links_for(data):
|
||||||
|
p, s = data["poses"], data["frame_distance"]
|
||||||
|
pts, ids, held = data["sample_points"], data["sample_frame"], data["heldout"]
|
||||||
|
training = ~held[ids]
|
||||||
|
|
||||||
|
def take(frame_mask, center, radius):
|
||||||
|
mask = training & frame_mask[ids]
|
||||||
|
chunk = pts[mask]
|
||||||
|
return chunk[np.linalg.norm(chunk - center, axis=1) <= radius]
|
||||||
|
|
||||||
|
link, search = acquire_closure(data, register)
|
||||||
|
selected = search["attempts"][search["selected_attempt"]]
|
||||||
|
links = [link]
|
||||||
|
audits = [dict(kind="seam", **selected, search=search)]
|
||||||
|
# Disjoint time/distance windows: no shared frame can self-match across an edge.
|
||||||
|
centers = np.linspace(0, data["distance"][-1], int(np.ceil(data["distance"][-1] / 20)) + 1)
|
||||||
|
for i, (sa, sb) in enumerate(zip(centers[:-1], centers[1:], strict=True)):
|
||||||
|
width = (sb - sa) / 3
|
||||||
|
amask, bmask = abs(s - sa) <= width, abs(s - sb) <= width
|
||||||
|
assert not np.any(amask & bmask)
|
||||||
|
pivot = np.array([np.interp((sa + sb) / 2, data["distance"], p[:, j]) for j in range(1, 4)])
|
||||||
|
a = take(amask, pivot, PROFILE["neighbor_radius_m"])
|
||||||
|
b = take(bmask, pivot, PROFILE["neighbor_radius_m"])
|
||||||
|
fit = register(a, b, np.eye(4))
|
||||||
|
audits.append(dict(kind="neighbor", distances_m=[float(sa), float(sb)], fit=fit))
|
||||||
|
if fit["status"] == "candidate":
|
||||||
|
links.append(
|
||||||
|
SurfaceLink(
|
||||||
|
float(np.mean(s[amask & ~held])),
|
||||||
|
float(np.mean(s[bmask & ~held])),
|
||||||
|
np.asarray(fit["T_reference_query"]),
|
||||||
|
np.median(b, axis=0),
|
||||||
|
PROFILE["neighbor_translation_weight_m"],
|
||||||
|
PROFILE["neighbor_rotation_weight_deg"],
|
||||||
|
f"neighbor-{i}",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
print(f"neighbor {i + 1}/{len(centers) - 1}: {fit['status']}", flush=True)
|
||||||
|
return links, audits
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(data, field, label):
|
||||||
|
pts, ids = data["sample_points"], data["sample_frame"]
|
||||||
|
s, f, p = data["frame_distance"], data["frames"], data["poses"]
|
||||||
|
train = ~data["heldout"][ids]
|
||||||
|
corrected = np.empty_like(pts)
|
||||||
|
offsets = np.searchsorted(ids, np.arange(len(f) + 1))
|
||||||
|
for i, distance in enumerate(s):
|
||||||
|
start, end = offsets[i : i + 2]
|
||||||
|
corrected[start:end] = field.points(pts[start:end], distance)
|
||||||
|
target = voxel(corrected[train])
|
||||||
|
tree = cKDTree(target)
|
||||||
|
groups = np.floor((f[:, 0] - f[0, 0]) / PROFILE["holdout_period_s"]).astype(int)
|
||||||
|
seed, rows = np.eye(4), []
|
||||||
|
for group in np.unique(groups[data["heldout"]]):
|
||||||
|
frames = data["heldout"] & (groups == group)
|
||||||
|
seconds, distance = float(np.mean(f[frames, 0])), float(np.mean(s[frames]))
|
||||||
|
position = np.array([np.interp(seconds, p[:, 0], p[:, j]) for j in range(1, 4)])
|
||||||
|
query = pts[frames[ids]] # Uncorrected, held-out scanner output.
|
||||||
|
radius = PROFILE["local_validation_radius_m"]
|
||||||
|
query = query[np.linalg.norm(query - position, axis=1) <= radius]
|
||||||
|
estimated = transform(position[None], seed)[0]
|
||||||
|
reference = target[tree.query_ball_point(estimated, radius + 5)]
|
||||||
|
fit = register(reference, query, seed)
|
||||||
|
row = dict(
|
||||||
|
group=int(group),
|
||||||
|
distance_m=distance,
|
||||||
|
source_frames=int(frames.sum()),
|
||||||
|
source_query_points=len(query),
|
||||||
|
fit=fit,
|
||||||
|
)
|
||||||
|
if "T_reference_query" in fit:
|
||||||
|
fitted = np.asarray(fit["T_reference_query"])
|
||||||
|
implied = field.matrices(distance)[0]
|
||||||
|
row["model_consistency_m"] = float(
|
||||||
|
np.linalg.norm(
|
||||||
|
transform(position[None], fitted) - transform(position[None], implied)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
row["model_consistency_deg"] = float(
|
||||||
|
np.rad2deg(
|
||||||
|
np.linalg.norm(
|
||||||
|
Rotation.from_matrix(fitted[:3, :3].T @ implied[:3, :3]).as_rotvec()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# All-point tails remain visible, not just accepted correspondences.
|
||||||
|
dist, _ = tree.query(transform(query, fitted), workers=1)
|
||||||
|
row["all_point_distance_p95_m"] = float(np.quantile(dist, 0.95))
|
||||||
|
if fit["status"] == "candidate":
|
||||||
|
seed = fitted # causal last accepted transform, never field oracle.
|
||||||
|
rows.append(row)
|
||||||
|
if len(rows) % 10 == 0:
|
||||||
|
print(f"validation {label}: {len(rows)} windows", flush=True)
|
||||||
|
return dict(
|
||||||
|
label=label,
|
||||||
|
training_map_points=len(target),
|
||||||
|
windows=rows,
|
||||||
|
candidate_count=sum(r["fit"]["status"] == "candidate" for r in rows),
|
||||||
|
total=len(rows),
|
||||||
|
interpretation="same-source held-out-frame local matching, not independent truth",
|
||||||
|
seed="identity then previous accepted transform; no current-field seed",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def materialize(cache, data, field, output):
|
||||||
|
frames = data["frames"]
|
||||||
|
count = int(frames[-1, 2] + frames[-1, 3])
|
||||||
|
source = np.memmap(cache / "source-points.f32", dtype="<f4", mode="r", shape=(count, 3))
|
||||||
|
with (output / "corrected-points.f32").open("xb") as stream:
|
||||||
|
for frame, distance in zip(frames, data["frame_distance"], strict=True):
|
||||||
|
offset, size = int(frame[2]), int(frame[3])
|
||||||
|
field.points(source[offset : offset + size], distance).astype("<f4").tofile(stream)
|
||||||
|
pos, q = field.poses(data["poses"][:, 1:4], data["poses"][:, 4:8], data["distance"])
|
||||||
|
np.savez(
|
||||||
|
output / "corrected-trajectory.npz",
|
||||||
|
positions=pos,
|
||||||
|
orientations_xyzw=q,
|
||||||
|
receipt_time_s=data["poses"][:, 0],
|
||||||
|
distance_m=data["distance"],
|
||||||
|
frame_distance_m=data["frame_distance"],
|
||||||
|
frames=frames,
|
||||||
|
)
|
||||||
|
return dict(
|
||||||
|
points=count,
|
||||||
|
path_before_m=float(data["distance"][-1]),
|
||||||
|
path_after_m=float(np.linalg.norm(np.diff(pos, axis=0), axis=1).sum()),
|
||||||
|
endpoint_delta_before_m=(data["poses"][-1, 1:4] - data["poses"][0, 1:4]).tolist(),
|
||||||
|
endpoint_delta_after_m=(pos[-1] - pos[0]).tolist(),
|
||||||
|
corrected_points_sha256=digest(output / "corrected-points.f32"),
|
||||||
|
corrected_trajectory_sha256=digest(output / "corrected-trajectory.npz"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("--raw", type=Path, required=True)
|
||||||
|
parser.add_argument("--sha256", required=True)
|
||||||
|
parser.add_argument("--session-id", required=True)
|
||||||
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
|
parser.add_argument("--cache", type=Path)
|
||||||
|
args = parser.parse_args()
|
||||||
|
if args.raw.resolve().is_relative_to(args.output.resolve()):
|
||||||
|
raise ValueError("Output must not contain the source recording.")
|
||||||
|
args.output.mkdir(parents=True, exist_ok=False)
|
||||||
|
started = time.monotonic()
|
||||||
|
cache = args.cache or args.output / "decoded"
|
||||||
|
if args.cache:
|
||||||
|
meta = json.loads((cache / "source.json").read_text())
|
||||||
|
if (
|
||||||
|
meta["source_sha256"] != args.sha256
|
||||||
|
or digest(args.raw) != args.sha256
|
||||||
|
or not compatible_cache_profile(meta["profile"])
|
||||||
|
):
|
||||||
|
raise ValueError("Cached source identity mismatch.")
|
||||||
|
seal = json.loads((cache / "seal.json").read_text())
|
||||||
|
if any(digest(cache / name) != value for name, value in seal.items()):
|
||||||
|
raise ValueError("Decoded cache integrity mismatch.")
|
||||||
|
else:
|
||||||
|
meta = extract(args.raw, cache, args.sha256)
|
||||||
|
write_json(
|
||||||
|
cache / "seal.json",
|
||||||
|
{
|
||||||
|
name: digest(cache / name)
|
||||||
|
for name in ["source-points.f32", "source-intensity.u8", "index.npz", "source.json"]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
with np.load(cache / "index.npz") as stored:
|
||||||
|
data = dict(stored)
|
||||||
|
try:
|
||||||
|
links, audits = links_for(data)
|
||||||
|
except ClosureUnavailable as exc:
|
||||||
|
write_json(args.output / "closure-search.json", exc.report)
|
||||||
|
raise
|
||||||
|
write_json(args.output / "closure-search.json", audits[0]["search"])
|
||||||
|
write_json(args.output / "registrations.json", audits)
|
||||||
|
# Keep each measured link and its explicit weights reproducible.
|
||||||
|
link_doc = []
|
||||||
|
for e in links:
|
||||||
|
d = asdict(e)
|
||||||
|
d["T_reference_query"] = e.T_reference_query.tolist()
|
||||||
|
d["query_center"] = e.query_center.tolist()
|
||||||
|
link_doc.append(d)
|
||||||
|
write_json(args.output / "surface-links.json", link_doc)
|
||||||
|
length = float(data["distance"][-1])
|
||||||
|
field, fit = fit_correction(length, links)
|
||||||
|
write_json(args.output / "correction.json", fit)
|
||||||
|
if not fit["converged"]:
|
||||||
|
raise ValueError("Correction solver did not converge; do not materialize.")
|
||||||
|
original = CorrectionField([0, length], np.zeros((2, 6)))
|
||||||
|
validation = [evaluate(data, original, "original"), evaluate(data, field, "corrected")]
|
||||||
|
write_json(args.output / "validation.json", validation)
|
||||||
|
sensitivity = []
|
||||||
|
grid = np.linspace(0, length, 1001)
|
||||||
|
route = np.stack(
|
||||||
|
[np.interp(grid, data["distance"], data["poses"][:, j]) for j in range(1, 4)], axis=1
|
||||||
|
)
|
||||||
|
for strength in [0.5, 2.0]:
|
||||||
|
other, report = fit_correction(length, links, CorrectionPolicy(strain_weight=strength))
|
||||||
|
delta = np.linalg.norm(other.points(route, grid) - field.points(route, grid), axis=1)
|
||||||
|
sensitivity.append(
|
||||||
|
dict(
|
||||||
|
strain_weight=strength,
|
||||||
|
converged=report["converged"],
|
||||||
|
maximum_route_difference_m=float(delta.max()),
|
||||||
|
p95_route_difference_m=float(np.quantile(delta, 0.95)),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
corrected_route = field.points(route, grid)
|
||||||
|
displacement = corrected_route - route
|
||||||
|
gradient = np.linalg.norm(np.diff(displacement, axis=0), axis=1) / np.diff(grid)
|
||||||
|
rotation_gradient = np.rad2deg(np.linalg.norm(field.spline(grid, 1)[:, 3:], axis=1))
|
||||||
|
product = materialize(cache, data, field, args.output)
|
||||||
|
if digest(args.raw) != args.sha256:
|
||||||
|
raise ValueError("Raw source changed during experiment.")
|
||||||
|
summary = dict(
|
||||||
|
schema_version="missioncore.recorded-ring-experiment/v2",
|
||||||
|
created_at=datetime.now(UTC).isoformat(),
|
||||||
|
source_session=args.session_id,
|
||||||
|
source=meta,
|
||||||
|
profile=PROFILE,
|
||||||
|
registration_policy=POLICY,
|
||||||
|
closure_registration_policy=CLOSURE_REGISTRATION_POLICY,
|
||||||
|
closure_policy=asdict(ClosurePolicy()),
|
||||||
|
closure_acquisition=audits[0]["search"]["status"],
|
||||||
|
versions={m: version(m) for m in ["numpy", "scipy", "small-gicp"]},
|
||||||
|
elapsed_s=time.monotonic() - started,
|
||||||
|
source_cache=str(cache.resolve()),
|
||||||
|
product=product,
|
||||||
|
surface_links=len(links),
|
||||||
|
sensitivity=sensitivity,
|
||||||
|
deformation=dict(
|
||||||
|
maximum_route_displacement_m=float(np.linalg.norm(displacement, axis=1).max()),
|
||||||
|
max_route_displacement_gradient_m_per_m=float(gradient.max()),
|
||||||
|
p95_route_displacement_gradient_m_per_m=float(np.quantile(gradient, 0.95)),
|
||||||
|
max_rotation_parameter_gradient_deg_per_m=float(rotation_gradient.max()),
|
||||||
|
individual_frame_transform="rigid; no internal scale/shear",
|
||||||
|
),
|
||||||
|
validation=[
|
||||||
|
dict(label=v["label"], candidates=v["candidate_count"], windows=v["total"])
|
||||||
|
for v in validation
|
||||||
|
],
|
||||||
|
status="experimental-candidate-not-promoted",
|
||||||
|
production_promotion=False,
|
||||||
|
vehicle_control=False,
|
||||||
|
original_modified=False,
|
||||||
|
limitations=[
|
||||||
|
"Single source: frame holdout is not an independent pass or ground truth.",
|
||||||
|
"K1 mapped increments, no per-point motion reconstruction.",
|
||||||
|
"Known start-area revisit; no automatic arbitrary-loop discovery.",
|
||||||
|
"Weights are engineering priors, not calibrated sensor covariance.",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
write_json(args.output / "summary.json", summary)
|
||||||
|
print(json.dumps(summary, indent=2), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"""Additional checks for an existing correction, never a re-fit or threshold change."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from reconstruct_recorded_ring import PROFILE, digest, register, voxel, write_json
|
||||||
|
from scipy.spatial import cKDTree
|
||||||
|
|
||||||
|
from k1link.missions.registration import transform
|
||||||
|
from k1link.reconstruction.closure import review_acceptance
|
||||||
|
from k1link.reconstruction.smooth_correction import CorrectionField
|
||||||
|
|
||||||
|
|
||||||
|
def stats(values):
|
||||||
|
return dict(
|
||||||
|
zip(
|
||||||
|
["min", "median", "p95", "max"],
|
||||||
|
np.quantile(values, [0, 0.5, 0.95, 1]).tolist(),
|
||||||
|
strict=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument("result", type=Path)
|
||||||
|
parser.add_argument("--output-name", default="review.json")
|
||||||
|
parser.add_argument("--include-group", type=int, action="append", default=[])
|
||||||
|
args = parser.parse_args()
|
||||||
|
root = args.result
|
||||||
|
summary = json.loads((root / "summary.json").read_text())
|
||||||
|
fit = json.loads((root / "correction.json").read_text())
|
||||||
|
with np.load(Path(summary["source_cache"]) / "index.npz") as d:
|
||||||
|
data = dict(d)
|
||||||
|
p, f, s = data["poses"], data["frames"], data["frame_distance"]
|
||||||
|
pts, ids, held = data["sample_points"], data["sample_frame"], data["heldout"]
|
||||||
|
field = CorrectionField(fit["knots_m"], fit["parameters"], fit.get("origin_m"))
|
||||||
|
length = float(data["distance"][-1])
|
||||||
|
baseline = CorrectionField([0, length], np.zeros((2, 6)))
|
||||||
|
runs = json.loads((root / "validation.json").read_text())
|
||||||
|
failures = sorted(
|
||||||
|
set(args.include_group)
|
||||||
|
| {r["group"] for run in runs for r in run["windows"] if r["fit"]["status"] != "candidate"}
|
||||||
|
)
|
||||||
|
offsets = np.searchsorted(ids, np.arange(len(f) + 1))
|
||||||
|
results = []
|
||||||
|
for label, correction, run in zip(
|
||||||
|
["original", "corrected"], [baseline, field], runs, strict=True
|
||||||
|
):
|
||||||
|
transformed = np.empty_like(pts)
|
||||||
|
for i, distance in enumerate(s):
|
||||||
|
start, end = offsets[i : i + 2]
|
||||||
|
transformed[start:end] = correction.points(pts[start:end], distance)
|
||||||
|
target = voxel(transformed[~held[ids]])
|
||||||
|
tree = cKDTree(target)
|
||||||
|
# Cross-visit check: last held-out frames against ONLY first training window.
|
||||||
|
first = (f[:, 0] <= f[0, 0] + PROFILE["seam_reference_s"]) & ~held
|
||||||
|
last = (f[:, 0] >= f[-1, 0] - 15) & held
|
||||||
|
# Identical source point membership before/after, selected before correction.
|
||||||
|
near = np.linalg.norm(pts - p[0, 1:4], axis=1) <= 25
|
||||||
|
first_pts = transformed[first[ids] & near]
|
||||||
|
last_pts = transformed[last[ids] & near]
|
||||||
|
distances, _ = cKDTree(voxel(first_pts)).query(last_pts, workers=1)
|
||||||
|
seam = dict(
|
||||||
|
query_frames=int(last.sum()),
|
||||||
|
points=len(last_pts),
|
||||||
|
overlap_05m=float(np.mean(distances <= 0.5)),
|
||||||
|
all_point_distances_m=stats(distances),
|
||||||
|
inlier_rmse_m=float(np.sqrt(np.mean(distances[distances <= 0.5] ** 2))),
|
||||||
|
refit=False,
|
||||||
|
)
|
||||||
|
focused = []
|
||||||
|
for group in failures:
|
||||||
|
# Same prior as the original two-second query; then only fresh held-out halves.
|
||||||
|
full = next(r for r in run["windows"] if r["group"] == group)
|
||||||
|
prior = np.asarray(full["fit"]["initial_T_reference_query"])
|
||||||
|
t0 = f[0, 0] + group * PROFILE["holdout_period_s"] + PROFILE["holdout_start_s"]
|
||||||
|
for half in [0, 1]:
|
||||||
|
mask = held & (f[:, 0] >= t0 + half) & (f[:, 0] < t0 + half + 1)
|
||||||
|
position = np.array(
|
||||||
|
[np.interp(float(np.mean(f[mask, 0])), p[:, 0], p[:, j]) for j in range(1, 4)]
|
||||||
|
)
|
||||||
|
query = pts[mask[ids]]
|
||||||
|
query = query[np.linalg.norm(query - position, axis=1) <= 40]
|
||||||
|
estimated = transform(position[None], prior)[0]
|
||||||
|
reference = target[tree.query_ball_point(estimated, 45)]
|
||||||
|
match = register(reference, query, prior)
|
||||||
|
focused.append(dict(group=group, half=half, frames=int(mask.sum()), fit=match))
|
||||||
|
if match["status"] == "candidate":
|
||||||
|
prior = np.asarray(match["T_reference_query"])
|
||||||
|
results.append(
|
||||||
|
dict(
|
||||||
|
label=label,
|
||||||
|
seam_holdout=seam,
|
||||||
|
focused=focused,
|
||||||
|
overlap=stats([r["fit"]["overlap"] for r in run["windows"]]),
|
||||||
|
inlier_rmse_m=stats([r["fit"]["inlier_rmse_m"] for r in run["windows"]]),
|
||||||
|
model_consistency_m=stats([r["model_consistency_m"] for r in run["windows"]]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
acceptance = review_acceptance(results, runs)
|
||||||
|
write_json(
|
||||||
|
root / args.output_name,
|
||||||
|
dict(
|
||||||
|
frozen_correction=True,
|
||||||
|
unchanged_registration_policy=True,
|
||||||
|
selected_failure_groups=failures,
|
||||||
|
results=results,
|
||||||
|
acceptance=acceptance,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sources = [
|
||||||
|
Path(__file__),
|
||||||
|
Path(__file__).with_name("reconstruct_recorded_ring.py"),
|
||||||
|
Path(__file__).parents[1] / "src/k1link/reconstruction/smooth_correction.py",
|
||||||
|
Path(__file__).parents[1] / "src/k1link/reconstruction/closure.py",
|
||||||
|
Path(__file__).parents[1] / "src/k1link/missions/registration.py",
|
||||||
|
]
|
||||||
|
evidence = [
|
||||||
|
root / name
|
||||||
|
for name in [
|
||||||
|
"summary.json",
|
||||||
|
"correction.json",
|
||||||
|
"validation.json",
|
||||||
|
"registrations.json",
|
||||||
|
"surface-links.json",
|
||||||
|
"corrected-points.f32",
|
||||||
|
"corrected-trajectory.npz",
|
||||||
|
args.output_name,
|
||||||
|
]
|
||||||
|
]
|
||||||
|
if summary["schema_version"] == "missioncore.recorded-ring-experiment/v2":
|
||||||
|
evidence.append(root / "closure-search.json")
|
||||||
|
write_json(
|
||||||
|
root / (args.output_name + ".seal.json"),
|
||||||
|
{str(path.resolve()): digest(path) for path in sources + evidence},
|
||||||
|
)
|
||||||
|
print(json.dumps(results, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -43,8 +43,7 @@ export function initialObservationWindowRect(
|
|||||||
const row = Math.floor(index / columns);
|
const row = Math.floor(index / columns);
|
||||||
const column = index % columns;
|
const column = index % columns;
|
||||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
||||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
const rowStart = Math.min(WINDOW_INSET, Math.max(0, bounds.width - width));
|
||||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||||
|
import { MAX_ACCUMULATION_SECONDS } from "./sceneSettings";
|
||||||
|
|
||||||
type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||||
|
|
||||||
@@ -18,6 +19,7 @@ export function ObservationTimeline({
|
|||||||
playbackRate,
|
playbackRate,
|
||||||
onPlaybackRateChange,
|
onPlaybackRateChange,
|
||||||
accumulationSeconds,
|
accumulationSeconds,
|
||||||
|
accumulationMaxSeconds = MAX_ACCUMULATION_SECONDS,
|
||||||
onAccumulationChange,
|
onAccumulationChange,
|
||||||
onAccumulationCommit,
|
onAccumulationCommit,
|
||||||
className = "",
|
className = "",
|
||||||
@@ -37,6 +39,7 @@ export function ObservationTimeline({
|
|||||||
playbackRate?: number;
|
playbackRate?: number;
|
||||||
onPlaybackRateChange?: (rate: number) => void;
|
onPlaybackRateChange?: (rate: number) => void;
|
||||||
accumulationSeconds?: number;
|
accumulationSeconds?: number;
|
||||||
|
accumulationMaxSeconds?: number;
|
||||||
onAccumulationChange?: (value: number) => void;
|
onAccumulationChange?: (value: number) => void;
|
||||||
onAccumulationCommit?: () => void;
|
onAccumulationCommit?: () => void;
|
||||||
className?: string;
|
className?: string;
|
||||||
@@ -75,7 +78,7 @@ export function ObservationTimeline({
|
|||||||
className="observation-timeline__track"
|
className="observation-timeline__track"
|
||||||
type="range"
|
type="range"
|
||||||
min={0}
|
min={0}
|
||||||
max={120}
|
max={Math.max(1, accumulationMaxSeconds, accumulationValue)}
|
||||||
step={1}
|
step={1}
|
||||||
value={accumulationValue}
|
value={accumulationValue}
|
||||||
aria-label="Окно накопления облака точек"
|
aria-label="Окно накопления облака точек"
|
||||||
@@ -165,12 +168,15 @@ export function ObservationTimeline({
|
|||||||
|
|
||||||
export function normalizeAccumulationSeconds(value: number): number {
|
export function normalizeAccumulationSeconds(value: number): number {
|
||||||
if (!Number.isFinite(value)) return 0;
|
if (!Number.isFinite(value)) return 0;
|
||||||
return Math.min(120, Math.max(0, Math.round(value)));
|
return Math.max(0, Math.round(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatAccumulationDuration(value: number): string {
|
export function formatAccumulationDuration(value: number): string {
|
||||||
const seconds = normalizeAccumulationSeconds(value);
|
const seconds = normalizeAccumulationSeconds(value);
|
||||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
if (seconds === 0) return "Кадр";
|
||||||
|
if (seconds < 60) return `${seconds} с`;
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
return seconds % 60 === 0 ? `${minutes} мин` : `${minutes} мин ${seconds % 60} с`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTimelineRange(
|
export function normalizeTimelineRange(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {Checker,ColorField,ControlRow,Inspector,RangeControl,Select} from '@nodedc/ui-react';
|
import {Checker,ColorField,ControlRow,Inspector,RangeControl,Select} from '@nodedc/ui-react';
|
||||||
import type {SceneSettings,PointColorMode,PointPalette} from './sceneSettings';
|
import type {SceneSettings,PointColorMode,PointPalette} from './sceneSettings';
|
||||||
|
import {MAX_ACCUMULATION_SECONDS} from './sceneSettings';
|
||||||
|
import {formatAccumulationDuration} from './ObservationTimeline';
|
||||||
|
|
||||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
||||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||||
@@ -28,7 +30,7 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
|||||||
{
|
{
|
||||||
id: "points",
|
id: "points",
|
||||||
label: "Облако точек",
|
label: "Облако точек",
|
||||||
description: "Размер и способ окрашивания",
|
description: "Размер, плотность и окрашивание",
|
||||||
content: (
|
content: (
|
||||||
<div className="inspector-control-stack">
|
<div className="inspector-control-stack">
|
||||||
<div
|
<div
|
||||||
@@ -47,6 +49,17 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
|||||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{replayPresented ? <>
|
||||||
|
<RangeControl
|
||||||
|
label="Прореживание облака"
|
||||||
|
value={displayDraft.pointDecimationPercent ?? 0}
|
||||||
|
min={0} max={100} step={0.1}
|
||||||
|
exactValueBounds={{min: 0, max: 100}}
|
||||||
|
formatValue={(value) => `${value.toLocaleString('ru-RU')} %`}
|
||||||
|
onChange={(pointDecimationPercent) => stageDisplayPatch({pointDecimationPercent})}
|
||||||
|
/>
|
||||||
|
<p className="scene-window-note">0% — все точки, 100% — без точек. Предпросмотр обновляется после изменения. Настройки сохраняются при закрытии окна; исходная запись не меняется.</p>
|
||||||
|
</> : null}
|
||||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
||||||
<ControlRow label="Атрибут цвета">
|
<ControlRow label="Атрибут цвета">
|
||||||
<Select
|
<Select
|
||||||
@@ -85,12 +98,6 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
|||||||
</ControlRow>
|
</ControlRow>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{replayPresented ? (
|
|
||||||
<p className="scene-window-note">
|
|
||||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
|
||||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -100,6 +107,18 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
|||||||
description: "История облака и траектория",
|
description: "История облака и траектория",
|
||||||
content: (
|
content: (
|
||||||
<div className="inspector-control-stack">
|
<div className="inspector-control-stack">
|
||||||
|
<RangeControl
|
||||||
|
label="Диапазон шкалы, мин"
|
||||||
|
value={(displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS) / 60}
|
||||||
|
min={1} max={30} step={0.1}
|
||||||
|
exactValueBounds={{min: 1 / 60}}
|
||||||
|
formatValue={(value) => `${value.toLocaleString('ru-RU', {maximumFractionDigits: 2})} мин`}
|
||||||
|
onChange={(minutes) => {
|
||||||
|
const accumulationMaxSeconds = Math.max(1, Math.round(minutes * 60));
|
||||||
|
stageDisplayPatch({accumulationMaxSeconds,
|
||||||
|
accumulationSeconds: Math.min(displayDraft.accumulationSeconds, accumulationMaxSeconds)});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div
|
<div
|
||||||
className="scene-settings-commit-field"
|
className="scene-settings-commit-field"
|
||||||
onPointerUp={flushDisplaySettings}
|
onPointerUp={flushDisplaySettings}
|
||||||
@@ -110,10 +129,12 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
|||||||
label="Окно накопления"
|
label="Окно накопления"
|
||||||
value={displayDraft.accumulationSeconds}
|
value={displayDraft.accumulationSeconds}
|
||||||
min={0}
|
min={0}
|
||||||
max={120}
|
max={displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS}
|
||||||
|
exactValueBounds={{min: 0}}
|
||||||
step={1}
|
step={1}
|
||||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
formatValue={formatAccumulationDuration}
|
||||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds,
|
||||||
|
accumulationMaxSeconds: Math.max(accumulationSeconds, displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS) })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="nodedc-field">
|
<div className="nodedc-field">
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ import {StatusBadge} from '@nodedc/ui-react';
|
|||||||
|
|
||||||
/** The existing scene composition. Hosts supply transport/renderers and authority. */
|
/** The existing scene composition. Hosts supply transport/renderers and authority. */
|
||||||
export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximized, toolbar,
|
export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximized, toolbar,
|
||||||
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer,
|
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer}: {
|
||||||
navigationReady=false}: {
|
|
||||||
viewportRef: RefObject<HTMLDivElement|null>; focused?:boolean; primaryFocused?:boolean;
|
viewportRef: RefObject<HTMLDivElement|null>; focused?:boolean; primaryFocused?:boolean;
|
||||||
mediaMaximized?:boolean; toolbar:ReactNode; renderer:ReactNode; deviceControls?:ReactNode;
|
mediaMaximized?:boolean; toolbar:ReactNode; renderer:ReactNode; deviceControls?:ReactNode;
|
||||||
sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||||
metrics:ReactNode; timeline?:ReactNode; media?:ReactNode; overlays?:ReactNode; footer?:ReactNode;
|
metrics:ReactNode; timeline?:ReactNode; media?:ReactNode; overlays?:ReactNode; footer?:ReactNode;
|
||||||
navigationReady?:boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const detailsHidden = primaryFocused || mediaMaximized;
|
const detailsHidden = primaryFocused || mediaMaximized;
|
||||||
return <div className="spatial-workspace" data-focused={focused?'true':undefined}>
|
return <div className="spatial-workspace" data-focused={focused?'true':undefined}>
|
||||||
@@ -28,7 +26,6 @@ export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximiz
|
|||||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">{metrics}</div>
|
<div className="scene-metrics" aria-label="Метрики пространственной сцены">{metrics}</div>
|
||||||
</div>
|
</div>
|
||||||
{overlays}
|
{overlays}
|
||||||
{navigationReady&&!mediaMaximized&&<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
|
||||||
{timeline}{media}
|
{timeline}{media}
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
{footer}
|
||||||
|
|||||||
@@ -32,25 +32,6 @@
|
|||||||
backdrop-filter: blur(18px);
|
backdrop-filter: blur(18px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-navigation-hint {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 11;
|
|
||||||
right: 0.85rem;
|
|
||||||
bottom: 4.9rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
border: 1px solid rgb(255 255 255 / 0.07);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(9 10 13 / 0.7);
|
|
||||||
color: var(--nodedc-text-muted);
|
|
||||||
padding: 0.42rem 0.65rem;
|
|
||||||
font-size: 0.52rem;
|
|
||||||
font-weight: 680;
|
|
||||||
pointer-events: none;
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.scene-source-picker__trigger {
|
.scene-source-picker__trigger {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,14 @@ export type SceneProjection = "3d" | "2d" | "map";
|
|||||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||||
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
export type PointPalette = "turbo" | "viridis" | "plasma" | "grayscale" | "custom";
|
||||||
|
|
||||||
|
/** Default slider travel, not a limit on a recording or exact entry. */
|
||||||
|
export const MAX_ACCUMULATION_SECONDS = 3 * 60;
|
||||||
|
|
||||||
export interface SceneSettings {
|
export interface SceneSettings {
|
||||||
projection: SceneProjection;
|
projection: SceneProjection;
|
||||||
pointSize: number;
|
pointSize: number;
|
||||||
|
pointDecimationPercent?: number;
|
||||||
|
accumulationMaxSeconds?: number;
|
||||||
colorMode: PointColorMode;
|
colorMode: PointColorMode;
|
||||||
palette: PointPalette;
|
palette: PointPalette;
|
||||||
customColor: string;
|
customColor: string;
|
||||||
@@ -19,6 +24,8 @@ export interface SceneSettings {
|
|||||||
export const defaultSceneSettings: SceneSettings = {
|
export const defaultSceneSettings: SceneSettings = {
|
||||||
projection: "3d",
|
projection: "3d",
|
||||||
pointSize: 2.5,
|
pointSize: 2.5,
|
||||||
|
pointDecimationPercent: 0,
|
||||||
|
accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS,
|
||||||
colorMode: "intensity",
|
colorMode: "intensity",
|
||||||
palette: "turbo",
|
palette: "turbo",
|
||||||
customColor: "#35d7c1",
|
customColor: "#35d7c1",
|
||||||
|
|||||||
@@ -367,16 +367,19 @@
|
|||||||
.scene-operation-status-stack {
|
.scene-operation-status-stack {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
z-index: 11;
|
z-index: 11;
|
||||||
left: 0.85rem;
|
right: 0.85rem;
|
||||||
bottom: 7.15rem;
|
top: 0.85rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
max-width: calc(100% - 8rem);
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: start;
|
align-content: center;
|
||||||
|
justify-items: end;
|
||||||
gap: 0.3rem;
|
gap: 0.3rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.scene-operation-status {
|
.scene-operation-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
max-width: min(24rem, calc(100% - 1.7rem));
|
max-width: 100%;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.42rem;
|
gap: 0.42rem;
|
||||||
border: 1px solid rgb(255 255 255 / 0.07);
|
border: 1px solid rgb(255 255 255 / 0.07);
|
||||||
@@ -388,7 +391,7 @@
|
|||||||
font-size: 0.52rem;
|
font-size: 0.52rem;
|
||||||
font-weight: 680;
|
font-weight: 680;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
white-space: nowrap;
|
white-space: normal;
|
||||||
backdrop-filter: blur(14px);
|
backdrop-filter: blur(14px);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ export function K1LiveView({ device, transport, createRerunHost, enabled, scene,
|
|||||||
<div><span>Точек</span><strong>{status.presented?status.points.toLocaleString('ru-RU'):'—'}</strong></div>
|
<div><span>Точек</span><strong>{status.presented?status.points.toLocaleString('ru-RU'):'—'}</strong></div>
|
||||||
<div><span>До публикации</span><strong>{status.presented&&device.frames?.mqtt_to_publish_ms!=null?device.frames.mqtt_to_publish_ms.toFixed(1):'—'}<small> мс</small></strong></div>
|
<div><span>До публикации</span><strong>{status.presented&&device.frames?.mqtt_to_publish_ms!=null?device.frames.mqtt_to_publish_ms.toFixed(1):'—'}<small> мс</small></strong></div>
|
||||||
</>}
|
</>}
|
||||||
navigationReady={hasScene}
|
|
||||||
timeline={!focusedAny&&<ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds=>scene.stage({accumulationSeconds})} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
|
timeline={!focusedAny&&<ObservationTimeline active={status.presented} sourceCount={visible.size} mode="live-only" accumulationSeconds={scene.draft.accumulationSeconds} onAccumulationChange={accumulationSeconds=>scene.stage({accumulationSeconds})} onAccumulationCommit={scene.flush} className="scene-timeline"/>}
|
||||||
media={<FloatingMediaWindow title="K1 · камера справа" subtitle="Видеоканал, опубликованный активным устройством" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={focused||!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={()=>{}} onClose={()=>{setCameraMaximized(false);setVisible(current=>{const next=new Set(current);next.delete('camera');return next;});}}
|
media={<FloatingMediaWindow title="K1 · камера справа" subtitle="Видеоканал, опубликованный активным устройством" boundsRef={viewport} rect={cameraRect} maximized={cameraMaximized} active={cameraMaximized} hidden={focused||!visible.has('camera')} onRectChange={setCameraRect} onMaximizedChange={setCameraMaximized} onActivate={()=>{}} onClose={()=>{setCameraMaximized(false);setVisible(current=>{const next=new Set(current);next.delete('camera');return next;});}}
|
||||||
status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
|
status={<span className="floating-observation-window__status">{status.cameraPresented?'Эфир':'Ожидание'}</span>}
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ node-device-media = ["aiortc==1.14.0"]
|
|||||||
localization = [
|
localization = [
|
||||||
"small-gicp==1.0.1",
|
"small-gicp==1.0.1",
|
||||||
]
|
]
|
||||||
|
map-correction = [
|
||||||
|
"scipy==1.16.2",
|
||||||
|
"small-gicp==1.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
k1link = "k1link.device_plugins.xgrids_k1.cli:app"
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build-only container on Worker006. Never run on the operator OS.
|
||||||
|
set -euo pipefail
|
||||||
|
test -f /.dockerenv
|
||||||
|
mkdir -p /work/source
|
||||||
|
tar -xzf /work/patched-source.tar.gz -C /work/source
|
||||||
|
# macOS AppleDouble metadata is not source. Strip only that metadata inside
|
||||||
|
# this disposable, fixed build workspace, including on a resumed build.
|
||||||
|
find /work/source -type f -name '._*' -delete
|
||||||
|
cd /work/source
|
||||||
|
export CARGO_BUILD_JOBS=4
|
||||||
|
export CARGO_PROFILE_DEV_DEBUG=0
|
||||||
|
export CARGO_PROFILE_TEST_DEBUG=0
|
||||||
|
export CARGO_INCREMENTAL=0
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y --no-install-recommends clang libudev-dev nodejs ca-certificates curl
|
||||||
|
curl -fsSL --retry 3 https://github.com/WebAssembly/binaryen/releases/download/version_117/binaryen-version_117-x86_64-linux.tar.gz -o /work/binaryen.tar.gz
|
||||||
|
printf '%s\n' '3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212 /work/binaryen.tar.gz' | sha256sum --check
|
||||||
|
tar -xzf /work/binaryen.tar.gz -C /work
|
||||||
|
export PATH="/work/binaryen-version_117/bin:$PATH"
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
rustc --version
|
||||||
|
wasm-opt --version
|
||||||
|
cargo test --locked -p re_view_spatial --lib nodedc_navigation_tests -- --nocapture
|
||||||
|
node rerun_js/web-viewer/build-wasm.mjs --mode release
|
||||||
|
sha256sum rerun_js/web-viewer/re_viewer.js rerun_js/web-viewer/re_viewer_bg.wasm
|
||||||
@@ -15,6 +15,7 @@ from k1link.device_plugins.xgrids_k1.archive import (
|
|||||||
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
|
from k1link.device_plugins.xgrids_k1.recorded_point_colors import (
|
||||||
RecordedPointColorOverlayStore,
|
RecordedPointColorOverlayStore,
|
||||||
)
|
)
|
||||||
|
from k1link.device_plugins.xgrids_k1.recorded_point_display import render_point_display
|
||||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||||
RrdExportCancelled,
|
RrdExportCancelled,
|
||||||
RrdExportError,
|
RrdExportError,
|
||||||
@@ -74,6 +75,7 @@ def build_xgrids_k1_observation(repository_root: Path, live_planning_source=None
|
|||||||
),
|
),
|
||||||
recording_exporter=_export_recording,
|
recording_exporter=_export_recording,
|
||||||
point_color_renderer=point_colors.render,
|
point_color_renderer=point_colors.render,
|
||||||
|
point_display_renderer=render_point_display,
|
||||||
overview_exporter=export_session_overview,
|
overview_exporter=export_session_overview,
|
||||||
planning_exporter=export_planning_source,
|
planning_exporter=export_planning_source,
|
||||||
submap_extractor=extract_submap,
|
submap_extractor=extract_submap,
|
||||||
@@ -267,6 +269,8 @@ def _export_recording(
|
|||||||
cancel_event: threading.Event | None = None,
|
cancel_event: threading.Event | None = None,
|
||||||
activity_callback: object | None = None,
|
activity_callback: object | None = None,
|
||||||
) -> dict[str, object]:
|
) -> dict[str, object]:
|
||||||
|
from k1link.reconstruction.recorded_geometry import RecordedMapGeometry
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return dict(
|
return dict(
|
||||||
export_k1mqtt_to_rrd(
|
export_k1mqtt_to_rrd(
|
||||||
@@ -280,9 +284,14 @@ def _export_recording(
|
|||||||
),
|
),
|
||||||
cancel_event=cancel_event,
|
cancel_event=cancel_event,
|
||||||
activity_callback=activity_callback if callable(activity_callback) else None,
|
activity_callback=activity_callback if callable(activity_callback) else None,
|
||||||
|
map_geometry=RecordedMapGeometry.optional(artifacts),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
except RrdExportCancelled as exc:
|
except RrdExportCancelled as exc:
|
||||||
raise PluginRecordingExportCancelled("K1 recording export was cancelled") from exc
|
raise PluginRecordingExportCancelled("K1 recording export was cancelled") from exc
|
||||||
except RrdExportError as exc:
|
except RrdExportError as exc:
|
||||||
raise PluginRecordingExportError("K1 recording export failed") from exc
|
raise PluginRecordingExportError("K1 recording export failed") from exc
|
||||||
|
|
||||||
|
|
||||||
|
# Explicit exporter capability: other plugins must not silently ignore a pinned map.
|
||||||
|
_export_recording.supports_map_versions = True
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Read display rows from the pinned, already verified operator RRD.
|
||||||
|
|
||||||
|
The canonical exporter bakes intensity/Turbo colors into this derived file.
|
||||||
|
Reading Arrow arrays avoids normalizing the complete raw transport again, and
|
||||||
|
retains the exact corrected geometry and colors already used by the viewer.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pyarrow as pa
|
||||||
|
import rerun_bindings as bindings
|
||||||
|
|
||||||
|
|
||||||
|
def prepared_point_rows(path: Path):
|
||||||
|
reader = bindings.RrdReaderInternal(str(path))
|
||||||
|
stores = [entry for entry in reader.store_entries() if entry.kind == "recording"]
|
||||||
|
if len(stores) != 1:
|
||||||
|
raise ValueError("display source must contain exactly one recording")
|
||||||
|
count = 0
|
||||||
|
for chunk in reader.stream(stores[0]):
|
||||||
|
if str(chunk.entity_path) != "/world/points":
|
||||||
|
continue
|
||||||
|
batch = chunk.to_record_batch()
|
||||||
|
names = batch.column_names
|
||||||
|
if "Points3D:positions" not in names:
|
||||||
|
continue
|
||||||
|
if not {"session_time", "message_sequence", "Points3D:colors"}.issubset(names):
|
||||||
|
raise ValueError("prepared points are missing temporal/color ownership")
|
||||||
|
times = batch.column(names.index("session_time")).cast(pa.int64()).to_numpy()
|
||||||
|
sequences = batch.column(names.index("message_sequence")).to_numpy()
|
||||||
|
positions = batch.column(names.index("Points3D:positions"))
|
||||||
|
colors = batch.column(names.index("Points3D:colors"))
|
||||||
|
for row, time_ns in enumerate(times):
|
||||||
|
if not positions[row].is_valid:
|
||||||
|
continue
|
||||||
|
xyz = positions[row].values.flatten().to_numpy().reshape(-1, 3)
|
||||||
|
if not colors[row].is_valid:
|
||||||
|
raise ValueError("prepared point colors are unavailable")
|
||||||
|
rgba = colors[row].values.to_numpy()
|
||||||
|
if len(rgba) == 1:
|
||||||
|
rgba = np.repeat(rgba, len(xyz))
|
||||||
|
if len(rgba) != len(xyz):
|
||||||
|
raise ValueError("prepared color count does not match positions")
|
||||||
|
count += 1
|
||||||
|
yield int(sequences[row]), int(time_ns), xyz, rgba
|
||||||
|
if not count:
|
||||||
|
raise ValueError("prepared recording has no point frames")
|
||||||
@@ -6,6 +6,7 @@ import hashlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Iterator
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -121,6 +122,16 @@ class RecordedPointColorOverlayStore:
|
|||||||
capture_clock,
|
capture_clock,
|
||||||
capture_clock_origin,
|
capture_clock_origin,
|
||||||
)
|
)
|
||||||
|
geometry = None
|
||||||
|
if command.map_version is not None:
|
||||||
|
from k1link.reconstruction.recorded_geometry import RecordedMapGeometry
|
||||||
|
|
||||||
|
paths = {
|
||||||
|
"map-version-" + name: command.map_version.directory / name
|
||||||
|
for name in ("manifest.json", "points.f32", "trajectory.npz")
|
||||||
|
}
|
||||||
|
geometry = RecordedMapGeometry(paths)
|
||||||
|
source_identity += (geometry.generation, *_source_identity(*paths.values()))
|
||||||
settings = RerunSceneSettings(
|
settings = RerunSceneSettings(
|
||||||
color_mode=color_mode,
|
color_mode=color_mode,
|
||||||
palette=palette,
|
palette=palette,
|
||||||
@@ -129,11 +140,7 @@ class RecordedPointColorOverlayStore:
|
|||||||
settings_key = (
|
settings_key = (
|
||||||
color_mode,
|
color_mode,
|
||||||
palette,
|
palette,
|
||||||
(
|
(custom_color.casefold() if palette == "custom" or color_mode == "class" else "-"),
|
||||||
custom_color.casefold()
|
|
||||||
if palette == "custom" or color_mode == "class"
|
|
||||||
else "-"
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
payload_key = (*source_identity, application_id, recording_id, *settings_key)
|
payload_key = (*source_identity, application_id, recording_id, *settings_key)
|
||||||
|
|
||||||
@@ -156,6 +163,7 @@ class RecordedPointColorOverlayStore:
|
|||||||
metadata,
|
metadata,
|
||||||
capture_clock,
|
capture_clock,
|
||||||
capture_clock_origin,
|
capture_clock_origin,
|
||||||
|
geometry,
|
||||||
)
|
)
|
||||||
payload = _render_color_overlay(
|
payload = _render_color_overlay(
|
||||||
index.frames,
|
index.frames,
|
||||||
@@ -173,6 +181,7 @@ class RecordedPointColorOverlayStore:
|
|||||||
metadata: Path | None,
|
metadata: Path | None,
|
||||||
capture_clock: Path | None,
|
capture_clock: Path | None,
|
||||||
capture_clock_origin: Path | None,
|
capture_clock_origin: Path | None,
|
||||||
|
geometry=None,
|
||||||
) -> _PointColorIndex:
|
) -> _PointColorIndex:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
cached = self._indexes.get(source_identity)
|
cached = self._indexes.get(source_identity)
|
||||||
@@ -187,6 +196,7 @@ class RecordedPointColorOverlayStore:
|
|||||||
capture_clock,
|
capture_clock,
|
||||||
capture_clock_origin,
|
capture_clock_origin,
|
||||||
source_identity=source_identity,
|
source_identity=source_identity,
|
||||||
|
geometry=geometry,
|
||||||
)
|
)
|
||||||
if index.byte_length > self._index_cache_bytes:
|
if index.byte_length > self._index_cache_bytes:
|
||||||
return index
|
return index
|
||||||
@@ -222,7 +232,17 @@ def _build_index(
|
|||||||
capture_clock_origin: Path | None,
|
capture_clock_origin: Path | None,
|
||||||
*,
|
*,
|
||||||
source_identity: tuple[object, ...],
|
source_identity: tuple[object, ...],
|
||||||
|
geometry=None,
|
||||||
) -> _PointColorIndex:
|
) -> _PointColorIndex:
|
||||||
|
frames = tuple(_iter_point_frames(source, metadata, capture_clock, capture_clock_origin,
|
||||||
|
geometry=geometry))
|
||||||
|
return _PointColorIndex(source_identity, frames, sum(frame.byte_length for frame in frames))
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_point_frames(
|
||||||
|
source: Path, metadata: Path, capture_clock: Path | None,
|
||||||
|
capture_clock_origin: Path | None, *, geometry=None,
|
||||||
|
) -> Iterator[_PointColorFrame]:
|
||||||
try:
|
try:
|
||||||
envelope = None if capture_clock is None else read_capture_clock_envelope(capture_clock)
|
envelope = None if capture_clock is None else read_capture_clock_envelope(capture_clock)
|
||||||
origin = (
|
origin = (
|
||||||
@@ -232,9 +252,13 @@ def _build_index(
|
|||||||
)
|
)
|
||||||
except CaptureFormatError as exc:
|
except CaptureFormatError as exc:
|
||||||
raise RecordedPointColorError("native point-color clock is invalid") from exc
|
raise RecordedPointColorError("native point-color clock is invalid") from exc
|
||||||
if envelope is not None and origin is not None and (
|
if (
|
||||||
envelope.started_at_epoch_ns != origin.started_at_epoch_ns
|
envelope is not None
|
||||||
or envelope.started_monotonic_ns != origin.started_monotonic_ns
|
and origin is not None
|
||||||
|
and (
|
||||||
|
envelope.started_at_epoch_ns != origin.started_at_epoch_ns
|
||||||
|
or envelope.started_monotonic_ns != origin.started_monotonic_ns
|
||||||
|
)
|
||||||
):
|
):
|
||||||
raise RecordedPointColorError("native point-color clocks do not match")
|
raise RecordedPointColorError("native point-color clocks do not match")
|
||||||
session_origin_ns = (
|
session_origin_ns = (
|
||||||
@@ -245,11 +269,11 @@ def _build_index(
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
frames: list[_PointColorFrame] = []
|
frame_count = 0
|
||||||
total_bytes = 0
|
|
||||||
point_frame_number = 0
|
point_frame_number = 0
|
||||||
previous_sequence = 0
|
previous_sequence = 0
|
||||||
previous_monotonic_ns: int | None = None
|
previous_monotonic_ns: int | None = None
|
||||||
|
first_raw_receipt_s = None
|
||||||
try:
|
try:
|
||||||
source_size = source.stat().st_size
|
source_size = source.stat().st_size
|
||||||
with source.open("rb") as raw_stream, metadata.open("r", encoding="utf-8") as index_stream:
|
with source.open("rb") as raw_stream, metadata.open("r", encoding="utf-8") as index_stream:
|
||||||
@@ -274,6 +298,8 @@ def _build_index(
|
|||||||
raise RecordedPointColorError("native point-color timeline decreases")
|
raise RecordedPointColorError("native point-color timeline decreases")
|
||||||
previous_sequence = sequence
|
previous_sequence = sequence
|
||||||
previous_monotonic_ns = monotonic_ns
|
previous_monotonic_ns = monotonic_ns
|
||||||
|
if first_raw_receipt_s is None:
|
||||||
|
first_raw_receipt_s = monotonic_ns / 1e9
|
||||||
if session_origin_ns is None:
|
if session_origin_ns is None:
|
||||||
session_origin_ns = monotonic_ns
|
session_origin_ns = monotonic_ns
|
||||||
topic = record.get("topic")
|
topic = record.get("topic")
|
||||||
@@ -333,6 +359,12 @@ def _build_index(
|
|||||||
if decoded.colors_rgb is None
|
if decoded.colors_rgb is None
|
||||||
else np.frombuffer(decoded.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
else np.frombuffer(decoded.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||||
)
|
)
|
||||||
|
if geometry is not None:
|
||||||
|
positions = geometry.points(
|
||||||
|
point_frame_number - 1,
|
||||||
|
monotonic_ns / 1e9 - first_raw_receipt_s,
|
||||||
|
decoded.point_count,
|
||||||
|
)
|
||||||
positions, intensities, rgb = _recorded_view_points(
|
positions, intensities, rgb = _recorded_view_points(
|
||||||
positions,
|
positions,
|
||||||
intensities,
|
intensities,
|
||||||
@@ -346,17 +378,12 @@ def _build_index(
|
|||||||
intensities=intensities.copy(),
|
intensities=intensities.copy(),
|
||||||
rgb=None if rgb is None else rgb.copy(),
|
rgb=None if rgb is None else rgb.copy(),
|
||||||
)
|
)
|
||||||
frames.append(frame)
|
frame_count += 1
|
||||||
total_bytes += frame.byte_length
|
yield frame
|
||||||
except (OSError, json.JSONDecodeError, UnicodeError) as exc:
|
except (OSError, json.JSONDecodeError, UnicodeError) as exc:
|
||||||
raise RecordedPointColorError("native point-color index could not be read") from exc
|
raise RecordedPointColorError("native point-color index could not be read") from exc
|
||||||
if session_origin_ns is None or not frames:
|
if session_origin_ns is None or not frame_count:
|
||||||
raise RecordedPointColorError("native point-color index contains no point frames")
|
raise RecordedPointColorError("native point-color index contains no point frames")
|
||||||
return _PointColorIndex(
|
|
||||||
source_identity=source_identity,
|
|
||||||
frames=tuple(frames),
|
|
||||||
byte_length=total_bytes,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _render_color_overlay(
|
def _render_color_overlay(
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Streaming, display-only point thinning. Never edits raw or mapping evidence."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import struct
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from contextlib import suppress
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import rerun as rr
|
||||||
|
|
||||||
|
from k1link.sessions import ReplayCommand
|
||||||
|
from k1link.viewer.rerun_bridge import RerunSceneSettings, _point_colors
|
||||||
|
from .recorded_point_colors import _artifact_path, _iter_point_frames
|
||||||
|
from .rrd_export import APPLICATION_ID, SESSION_TIMELINE
|
||||||
|
|
||||||
|
DISPLAY_POINTS_PATH = "/world/display_points"
|
||||||
|
_render_slot = threading.BoundedSemaphore(1)
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def retained_indices(count: int, decimation: float, sequence: int) -> np.ndarray:
|
||||||
|
"""Exact per-frame count; stable ranked samples, not a LiDAR-ring stride."""
|
||||||
|
if not math.isfinite(decimation) or not 0 <= decimation <= 100:
|
||||||
|
raise ValueError("invalid point decimation")
|
||||||
|
keep = int(math.floor(count * (100 - decimation) / 100 + 0.5))
|
||||||
|
if keep == count:
|
||||||
|
return np.arange(count)
|
||||||
|
if keep == 0:
|
||||||
|
return np.empty(0, dtype=np.int64)
|
||||||
|
# A bijective integer mix gives a reproducible order and nested samples.
|
||||||
|
keys = np.arange(count, dtype=np.uint32) ^ np.uint32(sequence & 0xFFFFFFFF)
|
||||||
|
keys ^= keys >> 16
|
||||||
|
keys *= np.uint32(0x7FEB352D)
|
||||||
|
keys ^= keys >> 15
|
||||||
|
keys *= np.uint32(0x846CA68B)
|
||||||
|
keys ^= keys >> 16
|
||||||
|
return np.sort(np.argpartition(keys, keep - 1)[:keep])
|
||||||
|
|
||||||
|
|
||||||
|
def render_point_display(command: ReplayCommand, *, application_id: str, recording_id: str,
|
||||||
|
color_mode: str, palette: str, custom_color: str,
|
||||||
|
point_decimation_percent: float, display_bank: str,
|
||||||
|
prepared_recording_path: Path | None = None) -> Iterator[bytes]:
|
||||||
|
if application_id != APPLICATION_ID or not 0 < point_decimation_percent < 100 or not re.fullmatch(r"[a-f0-9]{32}", display_bank):
|
||||||
|
raise ValueError("invalid point display request")
|
||||||
|
settings = RerunSceneSettings(color_mode=color_mode, palette=palette, custom_color=custom_color)
|
||||||
|
frames = _display_rows(command, settings, prepared_recording_path)
|
||||||
|
if not _render_slot.acquire(timeout=30):
|
||||||
|
raise RuntimeError("point display preparation is busy")
|
||||||
|
recording = None
|
||||||
|
started = time.monotonic()
|
||||||
|
input_points = output_points = frame_count = 0
|
||||||
|
try:
|
||||||
|
yield b'NPD1'
|
||||||
|
batch_points = 0
|
||||||
|
for index, (sequence, time_ns, positions, colors) in enumerate(frames):
|
||||||
|
if recording is None:
|
||||||
|
recording = rr.RecordingStream(application_id, recording_id=recording_id, send_properties=False)
|
||||||
|
stream = rr.binary_stream(recording)
|
||||||
|
selected = retained_indices(len(positions), point_decimation_percent, sequence)
|
||||||
|
input_points += len(positions)
|
||||||
|
output_points += len(selected)
|
||||||
|
frame_count += 1
|
||||||
|
recording.set_time(SESSION_TIMELINE, duration=np.timedelta64(time_ns, "ns"))
|
||||||
|
recording.log(f"{DISPLAY_POINTS_PATH}/{display_bank}", rr.Points3D(positions[selected], colors=colors[selected]))
|
||||||
|
batch_points += len(selected)
|
||||||
|
if index % 64 == 63 or batch_points >= 131072:
|
||||||
|
payload = stream.read(flush=True, flush_timeout_sec=30.0)
|
||||||
|
recording.disconnect()
|
||||||
|
recording = None
|
||||||
|
batch_points = 0
|
||||||
|
yield struct.pack('<I', len(payload)) + payload
|
||||||
|
if recording is not None:
|
||||||
|
payload = stream.read(flush=True, flush_timeout_sec=30.0)
|
||||||
|
recording.disconnect()
|
||||||
|
recording = None
|
||||||
|
yield struct.pack('<I', len(payload)) + payload
|
||||||
|
_logger.info("Point display ready: bank=%s frames=%d input_points=%d output_points=%d decimation=%s elapsed_s=%.3f",
|
||||||
|
display_bank, frame_count, input_points, output_points,
|
||||||
|
point_decimation_percent, time.monotonic() - started)
|
||||||
|
yield struct.pack('<I', 0)
|
||||||
|
finally:
|
||||||
|
frames.close()
|
||||||
|
with suppress(Exception):
|
||||||
|
if recording is not None:
|
||||||
|
recording.disconnect()
|
||||||
|
_render_slot.release()
|
||||||
|
|
||||||
|
|
||||||
|
def _display_rows(command, settings, prepared_recording_path):
|
||||||
|
if prepared_recording_path is not None and settings.color_mode == "intensity" and settings.palette == "turbo":
|
||||||
|
from .prepared_point_display import prepared_point_rows
|
||||||
|
yield from prepared_point_rows(prepared_recording_path)
|
||||||
|
return
|
||||||
|
metadata = _artifact_path(command, "raw-transport-index")
|
||||||
|
if metadata is None:
|
||||||
|
raise ValueError("point index is unavailable")
|
||||||
|
geometry = None
|
||||||
|
if command.map_version is not None:
|
||||||
|
from k1link.reconstruction.recorded_geometry import RecordedMapGeometry
|
||||||
|
geometry = RecordedMapGeometry({"map-version-" + name: command.map_version.directory / name
|
||||||
|
for name in ("manifest.json", "points.f32", "trajectory.npz")})
|
||||||
|
frames = _iter_point_frames(command.primary_artifact.path, metadata,
|
||||||
|
_artifact_path(command, "raw-transport-clock"),
|
||||||
|
_artifact_path(command, "raw-transport-clock-origin"), geometry=geometry)
|
||||||
|
try:
|
||||||
|
for frame in frames:
|
||||||
|
# Compute the same palette range on the full frame, then select the
|
||||||
|
# matching rows; thinning must not shift colors or corrected geometry.
|
||||||
|
colors = _point_colors(frame.positions, frame.intensities, frame.rgb, settings)
|
||||||
|
yield frame.sequence, frame.session_time_ns, frame.positions, colors
|
||||||
|
finally:
|
||||||
|
frames.close()
|
||||||
@@ -6,7 +6,7 @@ import os
|
|||||||
import threading
|
import threading
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from contextlib import suppress
|
from contextlib import suppress
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Literal, TypedDict
|
from typing import Any, Literal, TypedDict
|
||||||
from uuid import UUID, uuid4
|
from uuid import UUID, uuid4
|
||||||
@@ -52,9 +52,6 @@ APPLICATION_ID = "nodedc_mission_core_recorded"
|
|||||||
SESSION_TIMELINE = "session_time"
|
SESSION_TIMELINE = "session_time"
|
||||||
CAPTURE_TIMELINE = "capture_time"
|
CAPTURE_TIMELINE = "capture_time"
|
||||||
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||||
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD = 100_000
|
|
||||||
RECORDED_VIEW_POINT_STRIDE = 4
|
|
||||||
RECORDED_VIEW_POINT_FRAME_STRIDE = 5
|
|
||||||
RECORDED_RRD_IDENTITY_VERSION = "missioncore.recorded-rrd/v1"
|
RECORDED_RRD_IDENTITY_VERSION = "missioncore.recorded-rrd/v1"
|
||||||
|
|
||||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||||
@@ -195,16 +192,17 @@ def export_k1mqtt_to_rrd(
|
|||||||
capture_clock_origin_path: Path | None = None,
|
capture_clock_origin_path: Path | None = None,
|
||||||
cancel_event: threading.Event | None = None,
|
cancel_event: threading.Event | None = None,
|
||||||
activity_callback: Callable[[], None] | None = None,
|
activity_callback: Callable[[], None] | None = None,
|
||||||
|
map_geometry=None,
|
||||||
) -> RrdExportSummary:
|
) -> RrdExportSummary:
|
||||||
"""Project a bounded-rate view of K1 data into one operator RRD.
|
"""Project every captured K1 cloud frame into one operator RRD.
|
||||||
|
|
||||||
The raw capture remains the source of record. The derived RRD uses a
|
The raw capture remains the source of record. The derived RRD uses a
|
||||||
recording-local duration timeline whose zero is the durable capture-clock
|
recording-local duration timeline whose zero is the durable capture-clock
|
||||||
origin for v2 recordings (or the first raw message for legacy captures).
|
origin for v2 recordings (or the first raw message for legacy captures).
|
||||||
It never traverses the bounded live-preview queue. Point-cloud frames and
|
It never traverses the bounded live-preview queue or samples away points
|
||||||
very dense point batches are deterministically sampled for interactive
|
and frames. Counters, poses and capture boundaries remain complete, and
|
||||||
rendering while counters, poses, capture boundaries and the native capture
|
an admitted corrected map supplies its exact per-frame positions. AI jobs
|
||||||
remain complete. AI jobs always read the complete native capture.
|
still read the complete native capture, independently of this projection.
|
||||||
|
|
||||||
The destination is replaced only after the temporary RRD has been closed,
|
The destination is replaced only after the temporary RRD has been closed,
|
||||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||||
@@ -236,6 +234,8 @@ def export_k1mqtt_to_rrd(
|
|||||||
capture_clock,
|
capture_clock,
|
||||||
capture_clock_origin,
|
capture_clock_origin,
|
||||||
)
|
)
|
||||||
|
if map_geometry is not None:
|
||||||
|
recording_id = map_geometry.recording_id(recording_id)
|
||||||
temporary = destination.with_name(f".{destination.name}.{uuid4()}.tmp")
|
temporary = destination.with_name(f".{destination.name}.{uuid4()}.tmp")
|
||||||
settings = RerunSceneSettings()
|
settings = RerunSceneSettings()
|
||||||
blueprint = _recorded_blueprint(settings)
|
blueprint = _recorded_blueprint(settings)
|
||||||
@@ -264,6 +264,7 @@ def export_k1mqtt_to_rrd(
|
|||||||
previous_monotonic_ns: int | None = None
|
previous_monotonic_ns: int | None = None
|
||||||
last_source_time_ns: int | None = None
|
last_source_time_ns: int | None = None
|
||||||
last_source_capture_ns: int | None = None
|
last_source_capture_ns: int | None = None
|
||||||
|
first_raw_receipt_s: float | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
|
||||||
@@ -297,6 +298,9 @@ def export_k1mqtt_to_rrd(
|
|||||||
f"native capture message {message.sequence} is outside its clock envelope"
|
f"native capture message {message.sequence} is outside its clock envelope"
|
||||||
)
|
)
|
||||||
session_time_ns = monotonic_ns - session_origin_ns
|
session_time_ns = monotonic_ns - session_origin_ns
|
||||||
|
if first_raw_receipt_s is None:
|
||||||
|
first_raw_receipt_s = monotonic_ns / 1e9
|
||||||
|
map_time_s = monotonic_ns / 1e9 - first_raw_receipt_s
|
||||||
if session_time_ns > JS_MAX_SAFE_INTEGER:
|
if session_time_ns > JS_MAX_SAFE_INTEGER:
|
||||||
raise RrdExportError(
|
raise RrdExportError(
|
||||||
"session duration exceeds the exact JavaScript nanosecond range"
|
"session duration exceeds the exact JavaScript nanosecond range"
|
||||||
@@ -345,9 +349,19 @@ def export_k1mqtt_to_rrd(
|
|||||||
if isinstance(decoded, DecodedPointCloudView):
|
if isinstance(decoded, DecodedPointCloudView):
|
||||||
counters.point_frames += 1
|
counters.point_frames += 1
|
||||||
counters.points += decoded.point_count
|
counters.points += decoded.point_count
|
||||||
|
positions = (
|
||||||
|
None
|
||||||
|
if map_geometry is None
|
||||||
|
else map_geometry.points(
|
||||||
|
counters.point_frames - 1, map_time_s, decoded.point_count
|
||||||
|
)
|
||||||
|
)
|
||||||
if _should_publish_recorded_point_frame(counters.point_frames):
|
if _should_publish_recorded_point_frame(counters.point_frames):
|
||||||
_log_points(recording, decoded, settings)
|
_log_points(recording, decoded, settings, positions=positions)
|
||||||
elif isinstance(decoded, DecodedPoseView):
|
elif isinstance(decoded, DecodedPoseView):
|
||||||
|
if map_geometry is not None:
|
||||||
|
xyz, quaternion = map_geometry.pose(counters.pose_frames, map_time_s)
|
||||||
|
decoded = replace(decoded, position_xyz=xyz, orientation_xyzw=quaternion)
|
||||||
position = (
|
position = (
|
||||||
float(decoded.position_xyz[0]),
|
float(decoded.position_xyz[0]),
|
||||||
float(decoded.position_xyz[1]),
|
float(decoded.position_xyz[1]),
|
||||||
@@ -359,6 +373,8 @@ def export_k1mqtt_to_rrd(
|
|||||||
else:
|
else:
|
||||||
counters.ignored_messages += 1
|
counters.ignored_messages += 1
|
||||||
|
|
||||||
|
if map_geometry is not None:
|
||||||
|
map_geometry.complete(counters.point_frames, counters.pose_frames)
|
||||||
if session_origin_ns is None:
|
if session_origin_ns is None:
|
||||||
raise RrdExportError("native capture contains no messages")
|
raise RrdExportError("native capture contains no messages")
|
||||||
if counters.decoded_messages == 0:
|
if counters.decoded_messages == 0:
|
||||||
@@ -471,12 +487,8 @@ def _stable_recording_id(
|
|||||||
str(capture_clock.started_monotonic_ns) if capture_clock is not None else "-",
|
str(capture_clock.started_monotonic_ns) if capture_clock is not None else "-",
|
||||||
str(capture_clock.completed_at_epoch_ns) if capture_clock is not None else "-",
|
str(capture_clock.completed_at_epoch_ns) if capture_clock is not None else "-",
|
||||||
str(capture_clock.completed_monotonic_ns) if capture_clock is not None else "-",
|
str(capture_clock.completed_monotonic_ns) if capture_clock is not None else "-",
|
||||||
str(capture_clock_origin.started_at_epoch_ns)
|
str(capture_clock_origin.started_at_epoch_ns) if capture_clock_origin is not None else "-",
|
||||||
if capture_clock_origin is not None
|
str(capture_clock_origin.started_monotonic_ns) if capture_clock_origin is not None else "-",
|
||||||
else "-",
|
|
||||||
str(capture_clock_origin.started_monotonic_ns)
|
|
||||||
if capture_clock_origin is not None
|
|
||||||
else "-",
|
|
||||||
):
|
):
|
||||||
identity.update(value.encode("ascii"))
|
identity.update(value.encode("ascii"))
|
||||||
identity.update(b"\0")
|
identity.update(b"\0")
|
||||||
@@ -814,8 +826,11 @@ def _log_points(
|
|||||||
recording: rr.RecordingStream,
|
recording: rr.RecordingStream,
|
||||||
frame: DecodedPointCloudView,
|
frame: DecodedPointCloudView,
|
||||||
settings: RerunSceneSettings,
|
settings: RerunSceneSettings,
|
||||||
|
*,
|
||||||
|
positions: np.ndarray | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
if positions is None:
|
||||||
|
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
|
||||||
if frame.intensities is None:
|
if frame.intensities is None:
|
||||||
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||||
else:
|
else:
|
||||||
@@ -841,28 +856,18 @@ def _recorded_view_points(
|
|||||||
intensities: np.ndarray,
|
intensities: np.ndarray,
|
||||||
rgb: np.ndarray | None,
|
rgb: np.ndarray | None,
|
||||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
||||||
# The native K1 capture remains the complete source of record and all AI
|
# An archive is a faithful projection, not a lossy preview. Keep attributes
|
||||||
# jobs read that source directly. Rerun is the interactive operator
|
# aligned and use the visible time window to control displayed history.
|
||||||
# projection: bound the temporal frame rate, but preserve complete normal
|
# Resource admission must fail explicitly rather than silently drop points.
|
||||||
# K1 scans. The AI composition intentionally uses one latest point frame
|
return positions, intensities, rgb
|
||||||
# so dynamic cuboids do not stack; thinning a normal ~2.4k-point scan here
|
|
||||||
# made that view visibly bald. Keep spatial decimation only as an emergency
|
|
||||||
# guard for unusually large (>100k point) frames from future hardware.
|
|
||||||
if len(positions) <= RECORDED_VIEW_POINT_DECIMATION_THRESHOLD:
|
|
||||||
return positions, intensities, rgb
|
|
||||||
return (
|
|
||||||
positions[::RECORDED_VIEW_POINT_STRIDE],
|
|
||||||
intensities[::RECORDED_VIEW_POINT_STRIDE],
|
|
||||||
None if rgb is None else rgb[::RECORDED_VIEW_POINT_STRIDE],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _should_publish_recorded_point_frame(frame_number: int) -> bool:
|
def _should_publish_recorded_point_frame(frame_number: int) -> bool:
|
||||||
"""Keep the first point frame and then a stable 2 Hz operator cadence."""
|
"""Preserve every captured point frame, including its original timestamp."""
|
||||||
|
|
||||||
if frame_number < 1:
|
if frame_number < 1:
|
||||||
raise ValueError("point frame number must be positive")
|
raise ValueError("point frame number must be positive")
|
||||||
return frame_number == 1 or (frame_number - 1) % RECORDED_VIEW_POINT_FRAME_STRIDE == 0
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _log_pose(
|
def _log_pose(
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Project recorded acquisition ownership without moving or changing captures."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def reconcile_planning_captures(root, record_capture):
|
||||||
|
"""Backfill only explicit live-run bindings, including failed/deleted studies.
|
||||||
|
|
||||||
|
Recorded comparisons do not change the origin of an existing independent
|
||||||
|
survey. Project deletion is a presentation tombstone; its binding survives.
|
||||||
|
"""
|
||||||
|
for path in root.glob("*/report.json"):
|
||||||
|
doc = json.loads(path.read_text())
|
||||||
|
if doc.get("schema_version") != "missioncore.planning-live-test/v1":
|
||||||
|
continue
|
||||||
|
if doc.get("profile") != "planning" or not doc.get("query_session_id"):
|
||||||
|
continue
|
||||||
|
run_id = str(UUID(doc["id"]))
|
||||||
|
if path.parent.name != run_id:
|
||||||
|
raise ValueError("Planning capture report identity mismatch")
|
||||||
|
session_id = doc["query_session_id"]
|
||||||
|
if session_id in {doc["draft"]["zone"]["session_id"], doc.get("baseline_session_id")}:
|
||||||
|
raise ValueError("Planning capture cannot own its reference or baseline")
|
||||||
|
record_capture(session_id, run_id)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""Resolve new references by session default, existing studies by pinned version."""
|
||||||
|
|
||||||
|
from .versioned_sources import VersionedPlanningSources
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultPlanningSources:
|
||||||
|
def __init__(self, original, versions):
|
||||||
|
self.original, self.versions = original, versions
|
||||||
|
self.store, self.root = original.store, original.root
|
||||||
|
|
||||||
|
def get(self, session_id):
|
||||||
|
version = self.versions.selected(session_id)
|
||||||
|
return (
|
||||||
|
self.original.get(session_id)
|
||||||
|
if version is None
|
||||||
|
else self.bound(session_id, version.generation)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _reader(self, session_id, generation):
|
||||||
|
version = self.versions.version(session_id, generation)
|
||||||
|
if version is None:
|
||||||
|
return self.original
|
||||||
|
return VersionedPlanningSources(self.original, version, self.root / "map-snapshots")
|
||||||
|
|
||||||
|
def bound(self, session_id, generation):
|
||||||
|
doc = self._reader(session_id, generation).bound(session_id, generation)
|
||||||
|
# The catalog identity/name describes the physical session, not its revision.
|
||||||
|
return {**doc, "label": self.original.get(session_id)["label"]}
|
||||||
|
|
||||||
|
def verify(self, session_id, generation):
|
||||||
|
return self._reader(session_id, generation).verify(session_id, generation)
|
||||||
|
|
||||||
|
def prepared_submaps(self, session_id, generation, **options):
|
||||||
|
return self._reader(session_id, generation).prepared_submaps(
|
||||||
|
session_id, generation, **options
|
||||||
|
)
|
||||||
|
|
||||||
|
def submap(self, session_id, generation, start, end, **options):
|
||||||
|
return self._reader(session_id, generation).submap(
|
||||||
|
session_id, generation, start, end, **options
|
||||||
|
)
|
||||||
|
|
||||||
|
def reference_map(self, session_id, generation, start, end, **options):
|
||||||
|
return self._reader(session_id, generation).reference_map(
|
||||||
|
session_id, generation, start, end, **options
|
||||||
|
)
|
||||||
|
|
||||||
|
def scene_reference_map(self, session_id, generation, start, end, **options):
|
||||||
|
return self._reader(session_id, generation).scene_reference_map(
|
||||||
|
session_id, generation, start, end, **options
|
||||||
|
)
|
||||||
@@ -53,7 +53,13 @@ class MissionDrafts:
|
|||||||
|
|
||||||
def save(self, request):
|
def save(self, request):
|
||||||
source = self.sources.bound(request.session_id, request.generation)
|
source = self.sources.bound(request.session_id, request.generation)
|
||||||
route = route_from_source(source, request.start_index, request.end_index, request.direction)
|
if getattr(request, 'whole_recording', False):
|
||||||
|
start, end = 0, len(source['poses']) - 1
|
||||||
|
else:
|
||||||
|
start, end = request.start_index, request.end_index
|
||||||
|
if start is None or end is None:
|
||||||
|
raise ValueError('Выберите полную запись эталона.')
|
||||||
|
route = route_from_source(source, start, end, request.direction)
|
||||||
id = str(request.id or uuid4())
|
id = str(request.id or uuid4())
|
||||||
body = {'schema_version': 'missioncore.mission-draft/v1', 'name': request.name.strip(),
|
body = {'schema_version': 'missioncore.mission-draft/v1', 'name': request.name.strip(),
|
||||||
'vehicle_id': None, 'status': 'draft', 'zone': {key: source[key] for key in
|
'vehicle_id': None, 'status': 'draft', 'zone': {key: source[key] for key in
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ def acquire_entry(
|
|||||||
fitter=None,
|
fitter=None,
|
||||||
clock=time.monotonic,
|
clock=time.monotonic,
|
||||||
policy=ENTRY_POLICY,
|
policy=ENTRY_POLICY,
|
||||||
|
progress=None,
|
||||||
):
|
):
|
||||||
reference, query, initial = cloud(reference), cloud(query), rigid(initial)
|
reference, query, initial = cloud(reference), cloud(query), rigid(initial)
|
||||||
started = clock()
|
started = clock()
|
||||||
@@ -207,7 +208,9 @@ def acquire_entry(
|
|||||||
|
|
||||||
attempts = []
|
attempts = []
|
||||||
for seed in entry_seeds(initial, query_entry, reference_forward, policy=policy):
|
for seed in entry_seeds(initial, query_entry, reference_forward, policy=policy):
|
||||||
if clock() - started >= policy["deadline_s"]:
|
if progress is not None:
|
||||||
|
progress(dict(stage="dense-start", completed_fits=len(attempts)))
|
||||||
|
if policy["deadline_s"] is not None and clock() - started >= policy["deadline_s"]:
|
||||||
break
|
break
|
||||||
matrix = seed.pop("matrix")
|
matrix = seed.pop("matrix")
|
||||||
result = fitter(reference, query, matrix)
|
result = fitter(reference, query, matrix)
|
||||||
@@ -217,7 +220,7 @@ def acquire_entry(
|
|||||||
attempts,
|
attempts,
|
||||||
initial,
|
initial,
|
||||||
query_entry,
|
query_entry,
|
||||||
complete=elapsed <= policy["deadline_s"],
|
complete=policy["deadline_s"] is None or elapsed <= policy["deadline_s"],
|
||||||
policy=policy,
|
policy=policy,
|
||||||
)
|
)
|
||||||
result["initialization"]["elapsed_s"] = elapsed
|
result["initialization"]["elapsed_s"] = elapsed
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"""One admission and termination policy for a selected live route."""
|
"""Reference admission is independent from the lifetime of a live pass."""
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
LIVE_ROUTE_POLICY = dict(
|
LIVE_ROUTE_POLICY = dict(
|
||||||
version="selected-live-route/v1",
|
version="selected-live-route/v2",
|
||||||
minimum_m=3.0,
|
minimum_m=3.0,
|
||||||
maximum_m=None,
|
maximum_m=None,
|
||||||
maximum_seconds=None,
|
maximum_seconds=None,
|
||||||
@@ -16,6 +16,8 @@ def live_route_limits(length_m):
|
|||||||
raise ValueError("Для привязки выберите участок длиной не менее 3 м.")
|
raise ValueError("Для привязки выберите участок длиной не менее 3 м.")
|
||||||
return dict(
|
return dict(
|
||||||
route_policy=LIVE_ROUTE_POLICY.copy(),
|
route_policy=LIVE_ROUTE_POLICY.copy(),
|
||||||
maximum_distance_m=length,
|
# Reference length describes map coverage, never a travel budget.
|
||||||
|
# Keep null in the wire contract (and do not rewrite historical runs).
|
||||||
|
maximum_distance_m=None,
|
||||||
maximum_seconds=None,
|
maximum_seconds=None,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,10 +37,15 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class PlanningLiveTests:
|
class PlanningLiveTests:
|
||||||
def __init__(self, drafts, sources, compute_lock):
|
def __init__(self, drafts, sources, compute_lock, *, capture_recorder=None):
|
||||||
self.drafts, self.sources, self.compute_lock = drafts, sources, compute_lock
|
self.drafts, self.sources, self.compute_lock = drafts, sources, compute_lock
|
||||||
self.root = drafts.database.parent / "live-tests"
|
self.root = drafts.database.parent / "live-tests"
|
||||||
self.root.mkdir(exist_ok=True)
|
self.root.mkdir(exist_ok=True)
|
||||||
|
self.capture_recorder = capture_recorder
|
||||||
|
if capture_recorder is not None:
|
||||||
|
from .capture_catalog import reconcile_planning_captures
|
||||||
|
|
||||||
|
reconcile_planning_captures(self.root, capture_recorder)
|
||||||
self.lock = threading.RLock()
|
self.lock = threading.RLock()
|
||||||
self.run = None
|
self.run = None
|
||||||
self.sample = None
|
self.sample = None
|
||||||
@@ -177,6 +182,9 @@ class PlanningLiveTests:
|
|||||||
|
|
||||||
def update(self, **values):
|
def update(self, **values):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
capture = values.get("query_session_id")
|
||||||
|
if capture and self.capture_recorder is not None:
|
||||||
|
self.capture_recorder(capture, self.run["id"])
|
||||||
self.run.update(values)
|
self.run.update(values)
|
||||||
self.revision += 1
|
self.revision += 1
|
||||||
self.persist()
|
self.persist()
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
"""Staged stationary localisation before the normal fresh-data tracking gate.
|
"""Staged stationary localisation before the normal fresh-data tracking gate.
|
||||||
|
|
||||||
A known start is the reliable laboratory path, so it first receives a dense
|
A known start first receives a dense multi-start fit, but it cannot shortcut
|
||||||
multi-start fit. Only its honest rejection permits retrieval over the entire
|
comparison with the entire selected route. A finite queue, not elapsed wall
|
||||||
selected route. That preserves a repeatable start while retaining an auditable
|
time, defines completeness. The process owner handles cancellation and stalls.
|
||||||
recovery path for a restarted rover that must look for *where it is*.
|
|
||||||
|
|
||||||
Neither stage grants tracking or vehicle authority: both only produce a
|
Neither stage grants tracking or vehicle authority: both only produce a
|
||||||
provisional hypothesis for the separate, disjoint fresh-data gate.
|
provisional hypothesis for the separate, disjoint fresh-data gate.
|
||||||
@@ -13,7 +12,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import math
|
import math
|
||||||
import time
|
import time
|
||||||
from copy import deepcopy
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from itertools import product
|
from itertools import product
|
||||||
|
|
||||||
@@ -21,15 +19,17 @@ import numpy as np
|
|||||||
|
|
||||||
from .entry_acquisition import acquire_entry
|
from .entry_acquisition import acquire_entry
|
||||||
from .observation_profiles import TRACKING_INPUT
|
from .observation_profiles import TRACKING_INPUT
|
||||||
from .reference_window import reference_window
|
from .reference_window import ReferenceCoverageError, reference_window
|
||||||
from .registration import POLICY as TRACKING_POLICY
|
from .registration import POLICY as TRACKING_POLICY
|
||||||
from .registration import PreparedReference, angle_deg, cloud, rigid, transform
|
from .registration import PreparedReference, angle_deg, cloud, rigid, transform
|
||||||
from .stationary_entry import STATIONARY_POLICY
|
from .stationary_entry import STATIONARY_POLICY
|
||||||
|
|
||||||
ROUTE_RELOCALIZATION_POLICY = dict(
|
ROUTE_RELOCALIZATION_POLICY = dict(
|
||||||
version="route-relocalization/v6",
|
version="route-relocalization/v7",
|
||||||
scope="selected-route",
|
scope="selected-route",
|
||||||
strategy="dense-start-first-then-route-recovery/v1",
|
strategy="dense-start-and-complete-route-comparison/v2",
|
||||||
|
hypothesis_freshness="stationary-receipts-and-disjoint-confirmation/v1",
|
||||||
|
seed_modes=["pose-anchor", "cloud-median"],
|
||||||
# Local geometry is independent of the 80-m presentation envelope.
|
# Local geometry is independent of the 80-m presentation envelope.
|
||||||
query_radius_m=TRACKING_INPUT["radius_m"],
|
query_radius_m=TRACKING_INPUT["radius_m"],
|
||||||
anchor_spacing_m=5.0,
|
anchor_spacing_m=5.0,
|
||||||
@@ -54,13 +54,9 @@ ROUTE_RELOCALIZATION_POLICY = dict(
|
|||||||
cluster_rotation_deg=8.0,
|
cluster_rotation_deg=8.0,
|
||||||
ambiguity_overlap_margin=0.05,
|
ambiguity_overlap_margin=0.05,
|
||||||
ambiguity_rmse_margin_m=0.03,
|
ambiguity_rmse_margin_m=0.03,
|
||||||
# Keep the stationary prefix younger than the bootstrap's 40-s source-age
|
# No overall search timer: every admitted place must be compared. A child
|
||||||
# fence. A late exhaustive calculation is an explicit incomplete search,
|
# that makes NO progress is separately stopped, never called a map mismatch.
|
||||||
# never a stale provisional position.
|
worker_stall_s=60.0,
|
||||||
deadline_s=30.0,
|
|
||||||
maximum_search_wall_s=35.0,
|
|
||||||
# This is a numerical convergence envelope, not an operator start-radius
|
|
||||||
# admission rule. Reaching its wall deadline is reported as incomplete.
|
|
||||||
registration_policy={
|
registration_policy={
|
||||||
**TRACKING_POLICY,
|
**TRACKING_POLICY,
|
||||||
"version": "route-relocalization-gicp/v1",
|
"version": "route-relocalization-gicp/v1",
|
||||||
@@ -257,7 +253,8 @@ class RouteCandidate:
|
|||||||
|
|
||||||
|
|
||||||
def rank_route_candidates(
|
def rank_route_candidates(
|
||||||
reference, reference_path, query, *, policy=ROUTE_RELOCALIZATION_POLICY, grid=None
|
reference, reference_path, query, *, policy=ROUTE_RELOCALIZATION_POLICY, grid=None,
|
||||||
|
on_progress=None,
|
||||||
):
|
):
|
||||||
"""Rank every resampled route position against the stationary query cloud."""
|
"""Rank every resampled route position against the stationary query cloud."""
|
||||||
reference, query = route_reference_cloud(reference), cloud(query)
|
reference, query = route_reference_cloud(reference), cloud(query)
|
||||||
@@ -267,6 +264,9 @@ def rank_route_candidates(
|
|||||||
query_descriptor = radial_height_descriptor(query, query_center, policy=policy)
|
query_descriptor = radial_height_descriptor(query, query_center, policy=policy)
|
||||||
ranked = []
|
ranked = []
|
||||||
for index, (position, distance) in enumerate(zip(anchors, progress, strict=True)):
|
for index, (position, distance) in enumerate(zip(anchors, progress, strict=True)):
|
||||||
|
if on_progress is not None:
|
||||||
|
on_progress(dict(stage="route-index", completed_anchors=index,
|
||||||
|
total_anchors=len(anchors)))
|
||||||
target = local_submap(
|
target = local_submap(
|
||||||
grid,
|
grid,
|
||||||
position,
|
position,
|
||||||
@@ -454,6 +454,8 @@ def relocalize_route(
|
|||||||
*,
|
*,
|
||||||
clock=time.monotonic,
|
clock=time.monotonic,
|
||||||
policy=ROUTE_RELOCALIZATION_POLICY,
|
policy=ROUTE_RELOCALIZATION_POLICY,
|
||||||
|
on_progress=None,
|
||||||
|
additional_hypotheses=(),
|
||||||
):
|
):
|
||||||
"""Run complete candidate retrieval and qualification against a selected route."""
|
"""Run complete candidate retrieval and qualification against a selected route."""
|
||||||
started = clock()
|
started = clock()
|
||||||
@@ -461,7 +463,7 @@ def relocalize_route(
|
|||||||
query_entry = np.asarray(query_entry, dtype=float).reshape(3)
|
query_entry = np.asarray(query_entry, dtype=float).reshape(3)
|
||||||
grid = ReferenceGrid(reference, cell_m=policy["spatial_cell_m"])
|
grid = ReferenceGrid(reference, cell_m=policy["spatial_cell_m"])
|
||||||
ranked, coverage = rank_route_candidates(
|
ranked, coverage = rank_route_candidates(
|
||||||
reference, reference_path, query, policy=policy, grid=grid
|
reference, reference_path, query, policy=policy, grid=grid, on_progress=on_progress
|
||||||
)
|
)
|
||||||
attempts, evaluated, batches = [], [], []
|
attempts, evaluated, batches = [], [], []
|
||||||
query_center = np.median(query, axis=0)
|
query_center = np.median(query, axis=0)
|
||||||
@@ -470,11 +472,10 @@ def relocalize_route(
|
|||||||
float(np.linalg.norm(query - query_center, axis=1).max())
|
float(np.linalg.norm(query - query_center, axis=1).max())
|
||||||
+ policy["target_context_margin_m"],
|
+ policy["target_context_margin_m"],
|
||||||
)
|
)
|
||||||
expected = len(ranked) * policy["yaw_candidates_per_place"]
|
fits_per_place = policy["yaw_candidates_per_place"] * len(policy["seed_modes"])
|
||||||
|
expected = len(ranked) * fits_per_place
|
||||||
batch_size = policy["candidate_batch_size"]
|
batch_size = policy["candidate_batch_size"]
|
||||||
for candidate in ranked:
|
for candidate in ranked:
|
||||||
if clock() - started > policy["deadline_s"]:
|
|
||||||
break
|
|
||||||
if len(evaluated) % batch_size == 0:
|
if len(evaluated) % batch_size == 0:
|
||||||
batches.append([])
|
batches.append([])
|
||||||
target = local_submap(
|
target = local_submap(
|
||||||
@@ -487,12 +488,18 @@ def relocalize_route(
|
|||||||
target_center = np.median(target, axis=0)
|
target_center = np.median(target, axis=0)
|
||||||
count_before = len(attempts)
|
count_before = len(attempts)
|
||||||
prepared = None
|
prepared = None
|
||||||
for yaw_deg in _yaw_candidates(
|
for yaw_deg, seed_mode in product(
|
||||||
query, target, query_center, target_center, policy=policy
|
_yaw_candidates(query, target, query_center, target_center, policy=policy),
|
||||||
|
policy["seed_modes"],
|
||||||
):
|
):
|
||||||
if clock() - started > policy["deadline_s"]:
|
if on_progress is not None:
|
||||||
break
|
on_progress(dict(stage="route-search", completed_fits=len(attempts),
|
||||||
initial = _seed(query_center, target_center, yaw_deg)
|
total_fits=expected, candidate_index=candidate.index))
|
||||||
|
# The sensor pose is the spatial origin of this hypothesis. Cloud
|
||||||
|
# medians shift with occlusion/vegetation and are not scanner poses.
|
||||||
|
initial = (_seed(query_entry, candidate.position, yaw_deg)
|
||||||
|
if seed_mode == "pose-anchor"
|
||||||
|
else _seed(query_center, target_center, yaw_deg))
|
||||||
try:
|
try:
|
||||||
# Target preprocessing is independent of yaw. Keep one tree
|
# Target preprocessing is independent of yaw. Keep one tree
|
||||||
# per place; all seeds and all eligibility checks stay intact.
|
# per place; all seeds and all eligibility checks stay intact.
|
||||||
@@ -512,15 +519,21 @@ def relocalize_route(
|
|||||||
descriptor_distance=candidate.descriptor_distance,
|
descriptor_distance=candidate.descriptor_distance,
|
||||||
),
|
),
|
||||||
yaw_deg=yaw_deg,
|
yaw_deg=yaw_deg,
|
||||||
|
seed_mode=seed_mode,
|
||||||
result=result,
|
result=result,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if len(attempts) - count_before != policy["yaw_candidates_per_place"]:
|
if len(attempts) - count_before != fits_per_place:
|
||||||
break
|
break
|
||||||
evaluated.append(candidate.index)
|
evaluated.append(candidate.index)
|
||||||
batches[-1].append(candidate.index)
|
batches[-1].append(candidate.index)
|
||||||
complete = len(evaluated) == len(ranked) and clock() - started <= policy["deadline_s"]
|
complete = len(evaluated) == len(ranked)
|
||||||
result = choose_route_location(attempts, query_entry, complete=complete, policy=policy)
|
result = choose_route_location(
|
||||||
|
[*attempts, *additional_hypotheses], query_entry, complete=complete, policy=policy
|
||||||
|
)
|
||||||
|
# Dense-start evidence is accounted for by the caller, not counted twice as
|
||||||
|
# one extra route seed. It nevertheless participates in spatial ambiguity.
|
||||||
|
result["initialization"]["attempts"] = result["initialization"]["attempts"][:len(attempts)]
|
||||||
result["initialization"].update(
|
result["initialization"].update(
|
||||||
coverage,
|
coverage,
|
||||||
elapsed_s=clock() - started,
|
elapsed_s=clock() - started,
|
||||||
@@ -644,23 +657,26 @@ def relocalize_start_then_route(
|
|||||||
policy=ROUTE_RELOCALIZATION_POLICY,
|
policy=ROUTE_RELOCALIZATION_POLICY,
|
||||||
reference_position=None,
|
reference_position=None,
|
||||||
route_only=False,
|
route_only=False,
|
||||||
|
on_progress=None,
|
||||||
):
|
):
|
||||||
"""Use the proven start-area fit first, then a bounded route fallback.
|
"""Compare the proven dense start with every route place before deciding."""
|
||||||
|
|
||||||
This is deliberately not a looser acceptance rule. The dense start fit
|
|
||||||
runs every stationary multi-start seed against its high-resolution local
|
|
||||||
target. Only an honest rejection enters whole-route retrieval, whose
|
|
||||||
result remains provisional until the existing fresh-data gate confirms it.
|
|
||||||
"""
|
|
||||||
started = clock()
|
started = clock()
|
||||||
if route_only:
|
if route_only:
|
||||||
# A dense-start prior failed fresh confirmation. Recollect first, then
|
# A dense-start prior failed fresh confirmation. Recollect first, then
|
||||||
# search the route without repeatedly retrying that unconfirmed start.
|
# search the route without repeatedly retrying that unconfirmed start.
|
||||||
return relocalize_route(reference, reference_path, query, query_entry,
|
return relocalize_route(reference, reference_path, query, query_entry,
|
||||||
clock=clock, policy=policy)
|
clock=clock, policy=policy, on_progress=on_progress)
|
||||||
target, query, initial, entry, forward, window = _route_start_context(
|
try:
|
||||||
reference, reference_path, query, query_entry, reference_position
|
target, query, initial, entry, forward, window = _route_start_context(
|
||||||
)
|
reference, reference_path, query, query_entry, reference_position
|
||||||
|
)
|
||||||
|
except ReferenceCoverageError as exc:
|
||||||
|
# A sparse start patch is not proof that the whole known route is
|
||||||
|
# unusable. Keep the ordinary global proof and fresh confirmation.
|
||||||
|
result = relocalize_route(reference, reference_path, query, query_entry,
|
||||||
|
clock=clock, policy=policy, on_progress=on_progress)
|
||||||
|
result["initialization"]["dense_start_unavailable"] = str(exc)
|
||||||
|
return result
|
||||||
start_result = acquire_entry(
|
start_result = acquire_entry(
|
||||||
target,
|
target,
|
||||||
query,
|
query,
|
||||||
@@ -668,7 +684,8 @@ def relocalize_start_then_route(
|
|||||||
entry,
|
entry,
|
||||||
forward,
|
forward,
|
||||||
clock=clock,
|
clock=clock,
|
||||||
policy=STATIONARY_POLICY,
|
policy={**STATIONARY_POLICY, "deadline_s": None},
|
||||||
|
progress=on_progress,
|
||||||
)
|
)
|
||||||
start_result["initialization"].update(
|
start_result["initialization"].update(
|
||||||
scope=policy["scope"],
|
scope=policy["scope"],
|
||||||
@@ -686,35 +703,26 @@ def relocalize_start_then_route(
|
|||||||
]
|
]
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if start_result["status"] == "candidate":
|
|
||||||
start_result["initialization"] = _hybrid_initialization(policy, start_result)
|
|
||||||
return start_result
|
|
||||||
if not start_result["initialization"].get("complete"):
|
if not start_result["initialization"].get("complete"):
|
||||||
# Compute exhaustion is not evidence that this place did not match.
|
# Compute exhaustion is not evidence that this place did not match.
|
||||||
start_result["initialization"] = _hybrid_initialization(policy, start_result)
|
start_result["initialization"] = _hybrid_initialization(policy, start_result)
|
||||||
return start_result
|
return start_result
|
||||||
|
|
||||||
# A failed standard start may still be a valid mid-route or recovery
|
additional = []
|
||||||
# position. Give retrieval only the fresh-prefix time remaining: it must
|
if start_result["status"] == "candidate":
|
||||||
# never turn a late calculation into an apparently usable prior.
|
additional.append(dict(
|
||||||
remaining = policy["maximum_search_wall_s"] - (clock() - started)
|
candidate=dict(index=-1, position=start_result["initialization"]["reference_position"],
|
||||||
if remaining <= 0:
|
progress_m=start_result["initialization"]["route_progress_m"],
|
||||||
route_result = choose_route_location([], entry, complete=False, policy=policy)
|
descriptor_distance=0.0),
|
||||||
route_result["initialization"].update(
|
yaw_deg=0.0,
|
||||||
elapsed_s=0.0, worker_timeout_reason="start-stage-timeout"
|
result={key: value for key, value in start_result.items() if key != "initialization"},
|
||||||
)
|
))
|
||||||
else:
|
route_result = relocalize_route(
|
||||||
recovery_policy = deepcopy(policy)
|
reference, reference_path, query, entry, clock=clock, policy=policy,
|
||||||
recovery_policy["deadline_s"] = min(policy["deadline_s"], remaining)
|
on_progress=on_progress, additional_hypotheses=additional,
|
||||||
route_result = relocalize_route(
|
)
|
||||||
reference,
|
|
||||||
reference_path,
|
|
||||||
query,
|
|
||||||
entry,
|
|
||||||
clock=clock,
|
|
||||||
policy=recovery_policy,
|
|
||||||
)
|
|
||||||
route_result["initialization"] = _hybrid_initialization(policy, start_result, route_result)
|
route_result["initialization"] = _hybrid_initialization(policy, start_result, route_result)
|
||||||
|
route_result["initialization"]["elapsed_s"] = clock() - started
|
||||||
route_result["registration_seconds"] = start_result.get(
|
route_result["registration_seconds"] = start_result.get(
|
||||||
"registration_seconds", 0.0
|
"registration_seconds", 0.0
|
||||||
) + route_result.get("registration_seconds", 0.0)
|
) + route_result.get("registration_seconds", 0.0)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import json
|
|||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
|
from contextlib import suppress
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
@@ -38,9 +40,13 @@ def incomplete_result(reason):
|
|||||||
def run_route_relocalization(
|
def run_route_relocalization(
|
||||||
directory, reference, reference_path, query, query_entry, *, reference_position=None,
|
directory, reference, reference_path, query, query_entry, *, reference_position=None,
|
||||||
route_only=False,
|
route_only=False,
|
||||||
|
cancel_event=None,
|
||||||
):
|
):
|
||||||
source = directory / "route-relocalization-input.npz"
|
source = directory / "route-relocalization-input.npz"
|
||||||
destination = directory / "route-relocalization-result.json"
|
destination = directory / "route-relocalization-result.json"
|
||||||
|
progress_path = directory / "route-relocalization-progress.json"
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
return incomplete_result("worker-cancelled")
|
||||||
np.savez_compressed(
|
np.savez_compressed(
|
||||||
source,
|
source,
|
||||||
reference=reference,
|
reference=reference,
|
||||||
@@ -57,8 +63,7 @@ def run_route_relocalization(
|
|||||||
"VECLIB_MAXIMUM_THREADS": "1",
|
"VECLIB_MAXIMUM_THREADS": "1",
|
||||||
}
|
}
|
||||||
with (directory / "calculation.log").open("wb") as log:
|
with (directory / "calculation.log").open("wb") as log:
|
||||||
try:
|
with subprocess.Popen(
|
||||||
subprocess.run(
|
|
||||||
[
|
[
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
@@ -69,20 +74,70 @@ def run_route_relocalization(
|
|||||||
env=environment,
|
env=environment,
|
||||||
stdout=log,
|
stdout=log,
|
||||||
stderr=log,
|
stderr=log,
|
||||||
timeout=ROUTE_RELOCALIZATION_POLICY["maximum_search_wall_s"] + 5,
|
) as process:
|
||||||
check=True,
|
reason = supervise_search(process, progress_path, cancel_event=cancel_event)
|
||||||
)
|
if reason is not None:
|
||||||
except subprocess.TimeoutExpired:
|
destination.write_text(json.dumps(incomplete_result(reason), allow_nan=False))
|
||||||
# A process timeout says nothing about whether the scanner is at a
|
|
||||||
# known place. Return a normal, persisted incomplete-search result
|
|
||||||
# so the UI can distinguish it from a geometric rejection.
|
|
||||||
destination.write_text(json.dumps(incomplete_result("worker-timeout"), allow_nan=False))
|
|
||||||
return json.loads(destination.read_text())
|
return json.loads(destination.read_text())
|
||||||
|
|
||||||
|
|
||||||
|
def supervise_search(process, progress_path, *, cancel_event=None, clock=time.monotonic,
|
||||||
|
stall_s=ROUTE_RELOCALIZATION_POLICY["worker_stall_s"]):
|
||||||
|
"""Only inactivity is timed. Advancing a finite queue may take any duration.
|
||||||
|
|
||||||
|
Cancellation owns this exact child, never other workers or the scanner.
|
||||||
|
Reap it before returning so a retry cannot overlap the previous search.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _supervise_search(process, progress_path, cancel_event=cancel_event,
|
||||||
|
clock=clock, stall_s=stall_s)
|
||||||
|
finally:
|
||||||
|
# An unexpected supervisor/file error must not leave a finite but long
|
||||||
|
# numerical job running outside the planner's lifetime either.
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=2)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_search(process, progress_path, *, cancel_event, clock, stall_s):
|
||||||
|
last_progress = clock()
|
||||||
|
signature = None
|
||||||
|
while process.poll() is None:
|
||||||
|
try:
|
||||||
|
current = progress_path.stat().st_mtime_ns
|
||||||
|
except FileNotFoundError:
|
||||||
|
current = None
|
||||||
|
if current != signature:
|
||||||
|
last_progress, signature = clock(), current
|
||||||
|
reason = (
|
||||||
|
"worker-cancelled" if cancel_event is not None and cancel_event.is_set()
|
||||||
|
else "worker-stalled" if clock() - last_progress > stall_s else None
|
||||||
|
)
|
||||||
|
if reason:
|
||||||
|
return reason
|
||||||
|
with suppress(subprocess.TimeoutExpired):
|
||||||
|
process.wait(timeout=0.1)
|
||||||
|
if process.returncode:
|
||||||
|
raise subprocess.CalledProcessError(process.returncode, process.args)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
from .route_relocalization import relocalize_start_then_route
|
from .route_relocalization import relocalize_start_then_route
|
||||||
|
|
||||||
|
destination = Path(sys.argv[2])
|
||||||
|
progress_path = destination.with_name("route-relocalization-progress.json")
|
||||||
|
|
||||||
|
def progress(value):
|
||||||
|
temporary = progress_path.with_suffix(".tmp")
|
||||||
|
temporary.write_text(json.dumps({**value, "monotonic_ns": time.monotonic_ns()}))
|
||||||
|
temporary.replace(progress_path)
|
||||||
|
|
||||||
|
progress(dict(stage="loading"))
|
||||||
with np.load(Path(sys.argv[1]), allow_pickle=False) as data:
|
with np.load(Path(sys.argv[1]), allow_pickle=False) as data:
|
||||||
result = relocalize_start_then_route(
|
result = relocalize_start_then_route(
|
||||||
data["reference"],
|
data["reference"],
|
||||||
@@ -91,8 +146,10 @@ def main():
|
|||||||
data["query_entry"],
|
data["query_entry"],
|
||||||
reference_position=data.get("reference_position"),
|
reference_position=data.get("reference_position"),
|
||||||
route_only=bool(data.get("route_only", False)),
|
route_only=bool(data.get("route_only", False)),
|
||||||
|
on_progress=progress,
|
||||||
)
|
)
|
||||||
Path(sys.argv[2]).write_text(json.dumps(result, allow_nan=False))
|
progress(dict(stage="complete"))
|
||||||
|
destination.write_text(json.dumps(result, allow_nan=False))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ not prove SLAM frame continuity; this remains a laboratory-only protocol.
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from .causal_tracking import CausalTracking
|
from .causal_tracking import TRACKING_POLICY, CausalTracking
|
||||||
from .live_buffer import LiveCloudBuffer
|
from .live_buffer import LiveCloudBuffer
|
||||||
from .registration import rigid
|
from .registration import rigid
|
||||||
from .stationary_entry import STATIONARY_POLICY, StationaryPrefix
|
from .stationary_entry import STATIONARY_POLICY, StationaryPrefix
|
||||||
|
|
||||||
BOOTSTRAP_POLICY = dict(
|
BOOTSTRAP_POLICY = dict(
|
||||||
version="stationary-fresh-bootstrap/v3",
|
version="stationary-fresh-bootstrap/v4",
|
||||||
prefix_seconds=10.0,
|
prefix_seconds=10.0,
|
||||||
maximum_motion_m=0.10,
|
maximum_motion_m=0.10,
|
||||||
maximum_search_wall_s=30.0,
|
maximum_search_wall_s=30.0,
|
||||||
@@ -58,6 +58,15 @@ class StationaryBootstrap:
|
|||||||
self.candidate_index = None
|
self.candidate_index = None
|
||||||
self.dense_start_prior = False
|
self.dense_start_prior = False
|
||||||
self.retry_route_search = False
|
self.retry_route_search = False
|
||||||
|
self.last_pose_ns = None
|
||||||
|
self.last_cloud_ns = None
|
||||||
|
self.search_continuity_proven = False
|
||||||
|
|
||||||
|
@property
|
||||||
|
def continuous_search(self):
|
||||||
|
return self.initialization_policy.get("hypothesis_freshness") == (
|
||||||
|
"stationary-receipts-and-disjoint-confirmation/v1"
|
||||||
|
)
|
||||||
|
|
||||||
def stop(self, reason):
|
def stop(self, reason):
|
||||||
self.prior = None
|
self.prior = None
|
||||||
@@ -66,6 +75,7 @@ class StationaryBootstrap:
|
|||||||
self.candidate_queue = []
|
self.candidate_queue = []
|
||||||
self.retry_route_search = False
|
self.retry_route_search = False
|
||||||
self.gate.clear(reason)
|
self.gate.clear(reason)
|
||||||
|
self.search_continuity_proven = False
|
||||||
self.phase = "lost"
|
self.phase = "lost"
|
||||||
self.reason = reason
|
self.reason = reason
|
||||||
|
|
||||||
@@ -82,6 +92,16 @@ class StationaryBootstrap:
|
|||||||
self.stop("source-order-changed")
|
self.stop("source-order-changed")
|
||||||
raise ValueError("Source sequence or receipt clock regressed.")
|
raise ValueError("Source sequence or receipt clock regressed.")
|
||||||
self.last_event_ns, self.last_sequence = event.monotonic_ns, event.sequence
|
self.last_event_ns, self.last_sequence = event.monotonic_ns, event.sequence
|
||||||
|
if event.kind == "pose":
|
||||||
|
self.last_pose_ns = event.monotonic_ns
|
||||||
|
if self.phase == "searching" and self.continuous_search:
|
||||||
|
motion = float(np.linalg.norm(
|
||||||
|
np.asarray(event.position) - self.prefix.first_position
|
||||||
|
))
|
||||||
|
if not np.isfinite(motion) or motion > BOOTSTRAP_POLICY["maximum_motion_m"]:
|
||||||
|
self.stop("search-motion")
|
||||||
|
elif event.kind == "points":
|
||||||
|
self.last_cloud_ns = event.monotonic_ns
|
||||||
if segment != self.segment:
|
if segment != self.segment:
|
||||||
# Worker completion is not a data receipt. A gap straddling that
|
# Worker completion is not a data receipt. A gap straddling that
|
||||||
# instant may finish before the first fresh cloud. No validation has
|
# instant may finish before the first fresh cloud. No validation has
|
||||||
@@ -99,7 +119,9 @@ class StationaryBootstrap:
|
|||||||
if awaiting_first_cloud:
|
if awaiting_first_cloud:
|
||||||
self.segment = segment
|
self.segment = segment
|
||||||
self._reset_fresh(self.floor_ns)
|
self._reset_fresh(self.floor_ns)
|
||||||
elif self.phase in {"refreshing", "validating", "tracking"}:
|
elif self.phase in {"refreshing", "validating", "tracking"} or (
|
||||||
|
self.phase == "searching" and self.continuous_search
|
||||||
|
):
|
||||||
self.stop("receipt-gap")
|
self.stop("receipt-gap")
|
||||||
self.segment = segment
|
self.segment = segment
|
||||||
if self.phase == "collecting":
|
if self.phase == "collecting":
|
||||||
@@ -109,6 +131,16 @@ class StationaryBootstrap:
|
|||||||
|
|
||||||
def tick(self, now_ns, segment):
|
def tick(self, now_ns, segment):
|
||||||
self.gate.tick(now_ns, segment)
|
self.gate.tick(now_ns, segment)
|
||||||
|
if self.phase == "searching" and self.continuous_search:
|
||||||
|
# Old geometry may propose a place only while the live scanner is
|
||||||
|
# still stationary in the same receipt segment. Neither a cloud-only
|
||||||
|
# nor a pose-only stream can keep a long search alive.
|
||||||
|
if segment != self.initialization_sample["segment"]:
|
||||||
|
self.stop("receipt-gap")
|
||||||
|
elif any(stamp is None or not 0 <= (now_ns - stamp) / 1e9
|
||||||
|
<= TRACKING_POLICY["maximum_age_s"]
|
||||||
|
for stamp in (self.last_pose_ns, self.last_cloud_ns)):
|
||||||
|
self.stop("search-source-stale")
|
||||||
if self.phase in {"validating", "tracking"} and self.gate.reason == "stale":
|
if self.phase in {"validating", "tracking"} and self.gate.reason == "stale":
|
||||||
self.stop("stale")
|
self.stop("stale")
|
||||||
if self.phase == "refreshing" and (
|
if self.phase == "refreshing" and (
|
||||||
@@ -129,8 +161,10 @@ class StationaryBootstrap:
|
|||||||
return sample, initial, forward, meta
|
return sample, initial, forward, meta
|
||||||
|
|
||||||
def offer_prior(self, result, now_ns, segment):
|
def offer_prior(self, result, now_ns, segment):
|
||||||
|
self.tick(now_ns, segment)
|
||||||
if self.phase != "searching":
|
if self.phase != "searching":
|
||||||
return dict(accepted=False, reason="inactive-initialization", provisional=False)
|
return dict(accepted=False, reason=self.reason if self.phase == "lost"
|
||||||
|
else "inactive-initialization", provisional=False)
|
||||||
sample = self.initialization_sample
|
sample = self.initialization_sample
|
||||||
age = (now_ns - sample["monotonic_ns"]) / 1e9
|
age = (now_ns - sample["monotonic_ns"]) / 1e9
|
||||||
initialization = result.get("initialization", {})
|
initialization = result.get("initialization", {})
|
||||||
@@ -158,11 +192,14 @@ class StationaryBootstrap:
|
|||||||
or initialization.get("expected_attempts") != len(initialization.get("attempts", []))
|
or initialization.get("expected_attempts") != len(initialization.get("attempts", []))
|
||||||
):
|
):
|
||||||
reason = "initialization-incomplete"
|
reason = "initialization-incomplete"
|
||||||
elif not 0 <= (now_ns - self.search_started_ns) / 1e9 <= self.initialization_policy.get(
|
elif not self.continuous_search and not (
|
||||||
"maximum_search_wall_s", BOOTSTRAP_POLICY["maximum_search_wall_s"]
|
0 <= (now_ns - self.search_started_ns) / 1e9 <= self.initialization_policy.get(
|
||||||
|
"maximum_search_wall_s", BOOTSTRAP_POLICY["maximum_search_wall_s"]
|
||||||
|
)
|
||||||
):
|
):
|
||||||
reason = "initialization-expired"
|
reason = "initialization-expired"
|
||||||
elif not 0 <= age <= BOOTSTRAP_POLICY["maximum_prior_source_age_s"]:
|
elif age < 0 or (not self.continuous_search
|
||||||
|
and age > BOOTSTRAP_POLICY["maximum_prior_source_age_s"]):
|
||||||
reason = "prior-source-expired"
|
reason = "prior-source-expired"
|
||||||
elif not 0 <= segment - sample["segment"] <= BOOTSTRAP_POLICY["maximum_pre_ready_gaps"]:
|
elif not 0 <= segment - sample["segment"] <= BOOTSTRAP_POLICY["maximum_pre_ready_gaps"]:
|
||||||
reason = "too-many-receipt-gaps"
|
reason = "too-many-receipt-gaps"
|
||||||
@@ -178,6 +215,7 @@ class StationaryBootstrap:
|
|||||||
self.stop("initialization-incomplete")
|
self.stop("initialization-incomplete")
|
||||||
return dict(accepted=False, reason=self.reason, provisional=False, age_s=age)
|
return dict(accepted=False, reason=self.reason, provisional=False, age_s=age)
|
||||||
self.candidate_queue = [dict(item) for item in queue[1:]]
|
self.candidate_queue = [dict(item) for item in queue[1:]]
|
||||||
|
self.search_continuity_proven = self.continuous_search
|
||||||
self.candidate_trial = 1
|
self.candidate_trial = 1
|
||||||
self.candidate_index = initialization.get("selected_candidate_index")
|
self.candidate_index = initialization.get("selected_candidate_index")
|
||||||
stages = initialization.get("stages", [])
|
stages = initialization.get("stages", [])
|
||||||
@@ -196,6 +234,7 @@ class StationaryBootstrap:
|
|||||||
provisional=True,
|
provisional=True,
|
||||||
source_segment=sample["segment"],
|
source_segment=sample["segment"],
|
||||||
validation_segment=segment,
|
validation_segment=segment,
|
||||||
|
stationary_search_continuity=self.search_continuity_proven,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _reset_fresh(self, floor_ns):
|
def _reset_fresh(self, floor_ns):
|
||||||
@@ -208,10 +247,13 @@ class StationaryBootstrap:
|
|||||||
|
|
||||||
This is available only before tracking. Once tracking is established,
|
This is available only before tracking. Once tracking is established,
|
||||||
loss must use the ordinary last-confirmed-place recovery instead.
|
loss must use the ordinary last-confirmed-place recovery instead.
|
||||||
The original prefix's source-age fence is never extended by retries.
|
The legacy local protocol retains its source-age fence. Whole-route
|
||||||
|
hypotheses have proved stationary receipt continuity and still need a
|
||||||
|
new, disjoint current-data trial for EACH candidate.
|
||||||
"""
|
"""
|
||||||
age = (now_ns - self.initialization_sample["monotonic_ns"]) / 1e9
|
age = (now_ns - self.initialization_sample["monotonic_ns"]) / 1e9
|
||||||
if not 0 <= age <= BOOTSTRAP_POLICY["maximum_prior_source_age_s"]:
|
if age < 0 or (not self.search_continuity_proven
|
||||||
|
and age > BOOTSTRAP_POLICY["maximum_prior_source_age_s"]):
|
||||||
self.stop("prior-source-expired")
|
self.stop("prior-source-expired")
|
||||||
return
|
return
|
||||||
candidate = self.candidate_queue.pop(0)
|
candidate = self.candidate_queue.pop(0)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import threading
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
@@ -17,8 +18,7 @@ PHASE_MESSAGE = {
|
|||||||
"waiting-cloud": "Ожидание облака точек после подготовки сканера.",
|
"waiting-cloud": "Ожидание облака точек после подготовки сканера.",
|
||||||
"collecting": "Накопление данных. Сканер должен оставаться неподвижным.",
|
"collecting": "Накопление данных. Сканер должен оставаться неподвижным.",
|
||||||
"searching": (
|
"searching": (
|
||||||
"Точная привязка у стартовой зоны; при честном отказе — поиск по выбранному "
|
"Поиск по всему выбранному маршруту. Оставайтесь на месте до подтверждения привязки."
|
||||||
"маршруту. Ожидание на месте."
|
|
||||||
),
|
),
|
||||||
"refreshing": "Подтверждение привязки по новым кадрам. Ожидание на месте.",
|
"refreshing": "Подтверждение привязки по новым кадрам. Ожидание на месте.",
|
||||||
"validating": "Подтверждение привязки по новым кадрам. Ожидание на месте.",
|
"validating": "Подтверждение привязки по новым кадрам. Ожидание на месте.",
|
||||||
@@ -42,9 +42,15 @@ def phase_message(boot):
|
|||||||
)
|
)
|
||||||
if boot.reason in {"initialization-incomplete", "initialization-expired"}:
|
if boot.reason in {"initialization-incomplete", "initialization-expired"}:
|
||||||
return (
|
return (
|
||||||
"Синхронизация маршрута не завершилась. Остановите устройство и запись, "
|
"Поиск не завершён. Оставьте сканер неподвижно и нажмите "
|
||||||
"затем начните новое исследование и дождитесь неподвижной калибровки."
|
"«Переинициализировать». Запись продолжается."
|
||||||
)
|
)
|
||||||
|
if boot.reason == "search-motion":
|
||||||
|
return ("Сканер перемещён во время поиска. "
|
||||||
|
"Остановитесь и нажмите «Переинициализировать».")
|
||||||
|
if boot.reason == "search-source-stale":
|
||||||
|
return ("Данные сканера перестали поступать. "
|
||||||
|
"Проверьте поток и нажмите «Переинициализировать».")
|
||||||
return (
|
return (
|
||||||
"Синхронизация маршрута не выполнена. Убедитесь, что сканер находится "
|
"Синхронизация маршрута не выполнена. Убедитесь, что сканер находится "
|
||||||
"у исследованного участка; можно выбрать другую различимую точку, остановиться "
|
"у исследованного участка; можно выбрать другую различимую точку, остановиться "
|
||||||
@@ -74,6 +80,8 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
recovery_attempt = 0
|
recovery_attempt = 0
|
||||||
recovery_position = None
|
recovery_position = None
|
||||||
route_search_only = False
|
route_search_only = False
|
||||||
|
search_cancel = threading.Event()
|
||||||
|
requested_retry = None
|
||||||
|
|
||||||
def begin_recovery(reason):
|
def begin_recovery(reason):
|
||||||
# Retain only the last confirmed place as a SEARCH HINT. Neither the
|
# Retain only the last confirmed place as a SEARCH HINT. Neither the
|
||||||
@@ -360,18 +368,20 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
else "input-ended"
|
else "input-ended"
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
retry_attempt = service.consume_reinitialization(run_id)
|
requested_retry = service.consume_reinitialization(run_id) or requested_retry
|
||||||
if retry_attempt is not None:
|
if requested_retry is not None:
|
||||||
if future is not None:
|
if future is not None:
|
||||||
# The public operation admits only a terminal initial
|
search_cancel.set()
|
||||||
# failure. Keep this fence in case a caller races an
|
finish(active=False)
|
||||||
# internal state update.
|
if future is None:
|
||||||
raise RuntimeError("Нельзя переинициализировать во время расчёта привязки.")
|
reset_initialization(requested_retry)
|
||||||
reset_initialization(retry_attempt)
|
requested_retry = None
|
||||||
continue
|
continue
|
||||||
now = clock.monotonic()
|
now = clock.monotonic()
|
||||||
if boot is not None:
|
if boot is not None:
|
||||||
boot.tick(clock.monotonic_ns(), buffer.segment)
|
boot.tick(clock.monotonic_ns(), buffer.segment)
|
||||||
|
if boot.phase == "lost":
|
||||||
|
search_cancel.set()
|
||||||
finish()
|
finish()
|
||||||
current = source.snapshot()
|
current = source.snapshot()
|
||||||
if not current["active"] or current.get("spatial_stop_requested", False):
|
if not current["active"] or current.get("spatial_stop_requested", False):
|
||||||
@@ -510,12 +520,9 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
receipt_gaps=sample["gaps"],
|
receipt_gaps=sample["gaps"],
|
||||||
)
|
)
|
||||||
last_snapshot = now
|
last_snapshot = now
|
||||||
if buffer.distance >= service.run["maximum_distance_m"]:
|
# Travel is telemetry, not completion. Detours and additional
|
||||||
# A pose can reach the limit before the next cloud snapshot.
|
# laps must keep consuming the same capture and confirming the
|
||||||
service.update_sample(buffer.snapshot(), clock.monotonic_ns())
|
# same reference, even if an older run carried a distance cap.
|
||||||
service.update(distance_m=buffer.distance)
|
|
||||||
end_reason = "distance-limit"
|
|
||||||
break
|
|
||||||
if boot is None or future is not None:
|
if boot is None or future is not None:
|
||||||
continue
|
continue
|
||||||
# Do not freeze ahead of an already queued prefix receipt.
|
# Do not freeze ahead of an already queued prefix receipt.
|
||||||
@@ -548,6 +555,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
"initialization_policy": ROUTE_RELOCALIZATION_POLICY,
|
"initialization_policy": ROUTE_RELOCALIZATION_POLICY,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
search_cancel = threading.Event()
|
||||||
future = executor.submit(
|
future = executor.submit(
|
||||||
initialize,
|
initialize,
|
||||||
target,
|
target,
|
||||||
@@ -557,6 +565,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
sample["path"][0],
|
sample["path"][0],
|
||||||
**({"reference_position": recovery_position} if recovering else {}),
|
**({"reference_position": recovery_position} if recovering else {}),
|
||||||
**({"route_only": True} if route_search_only else {}),
|
**({"route_only": True} if route_search_only else {}),
|
||||||
|
cancel_event=search_cancel,
|
||||||
)
|
)
|
||||||
publish_phase()
|
publish_phase()
|
||||||
continue
|
continue
|
||||||
@@ -588,6 +597,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
sample, "fresh-validation", {"reference_window": window}
|
sample, "fresh-validation", {"reference_window": window}
|
||||||
)
|
)
|
||||||
future = executor.submit(calculate, target, reference, sample["points"], hint)
|
future = executor.submit(calculate, target, reference, sample["points"], hint)
|
||||||
|
search_cancel.set()
|
||||||
if boot is not None:
|
if boot is not None:
|
||||||
boot.stop(end_reason)
|
boot.stop(end_reason)
|
||||||
with service.lock:
|
with service.lock:
|
||||||
@@ -600,12 +610,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
tracking_state="lost",
|
tracking_state="lost",
|
||||||
tracking_reason=end_reason,
|
tracking_reason=end_reason,
|
||||||
termination_reason=end_reason,
|
termination_reason=end_reason,
|
||||||
message=(
|
message="Исследование завершено. Запись управляется штатными кнопками сканера.",
|
||||||
"Достигнут предел проверочного прохода. "
|
|
||||||
"Запись управляется штатными кнопками сканера."
|
|
||||||
if end_reason == "distance-limit"
|
|
||||||
else "Исследование завершено. Запись управляется штатными кнопками сканера."
|
|
||||||
),
|
|
||||||
finished_at_utc=utc_now_iso(),
|
finished_at_utc=utc_now_iso(),
|
||||||
)
|
)
|
||||||
# Publish ended before waiting for an already-running bounded fit.
|
# Publish ended before waiting for an already-running bounded fit.
|
||||||
@@ -627,3 +632,5 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
|||||||
"Завершите проход и начните повторную проверку."
|
"Завершите проход и начните повторную проверку."
|
||||||
) from exc
|
) from exc
|
||||||
raise
|
raise
|
||||||
|
finally:
|
||||||
|
search_cancel.set()
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
"""Explicit offline bridge from a pinned map candidate to existing planning APIs.
|
||||||
|
|
||||||
|
Not installed in the web composition. The original source remains the default;
|
||||||
|
only an explicitly pinned derivative generation selects corrected geometry.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from k1link.reconstruction.map_version import POINTS, MapVersion
|
||||||
|
|
||||||
|
EXTRACTION = dict(
|
||||||
|
version="map-version-submap/v1",
|
||||||
|
max_frames=120,
|
||||||
|
voxel_m=0.25,
|
||||||
|
radius_m=20.0,
|
||||||
|
height_relative_m=[-3.0, 6.0],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VersionedPlanningSources:
|
||||||
|
def __init__(self, original, version, scratch):
|
||||||
|
self.original, self.version = original, version
|
||||||
|
self.scratch = Path(scratch)
|
||||||
|
self.scratch.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def get(self, session_id):
|
||||||
|
# No implicit promotion and no changes to existing consumers.
|
||||||
|
return self.original.get(session_id)
|
||||||
|
|
||||||
|
def bound(self, session_id, generation):
|
||||||
|
if generation != self.version.generation:
|
||||||
|
return self.original.bound(session_id, generation)
|
||||||
|
parent = self.version.document["source"]
|
||||||
|
if session_id != parent["session_id"]:
|
||||||
|
raise ValueError("Map candidate belongs to another session.")
|
||||||
|
source = self.original.verify(session_id, parent["generation"])
|
||||||
|
return self.version.planning_source(source)
|
||||||
|
|
||||||
|
def verify(self, session_id, generation):
|
||||||
|
if generation != self.version.generation:
|
||||||
|
return self.original.verify(session_id, generation)
|
||||||
|
return self.bound(session_id, generation)
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def prepared_submaps(self, session_id, generation, *, presentation=False, cancel_event=None):
|
||||||
|
if generation != self.version.generation:
|
||||||
|
with self.original.prepared_submaps(
|
||||||
|
session_id, generation, presentation=presentation, cancel_event=cancel_event
|
||||||
|
) as extract:
|
||||||
|
yield extract
|
||||||
|
return
|
||||||
|
|
||||||
|
def cancelled():
|
||||||
|
if cancel_event is not None and cancel_event.is_set():
|
||||||
|
raise InterruptedError("Map version preparation cancelled.")
|
||||||
|
|
||||||
|
cancelled()
|
||||||
|
doc = self.verify(session_id, generation)
|
||||||
|
with TemporaryDirectory(prefix=".map-snapshot-", dir=self.scratch) as temporary:
|
||||||
|
stage = Path(temporary)
|
||||||
|
for path in self.version.directory.iterdir():
|
||||||
|
if path.name == "manifest.json" or path.name in self.version.document["artifacts"]:
|
||||||
|
cancelled()
|
||||||
|
shutil.copyfile(path, stage / path.name)
|
||||||
|
snapshot = MapVersion(stage, generation)
|
||||||
|
snapshot.verify()
|
||||||
|
arrays = snapshot.arrays()
|
||||||
|
|
||||||
|
def extract(start, end):
|
||||||
|
cancelled()
|
||||||
|
return _extract(snapshot, arrays, doc, start, end, presentation=presentation)
|
||||||
|
|
||||||
|
yield extract
|
||||||
|
cancelled()
|
||||||
|
# A changed parent or derivative invalidates the complete preparation.
|
||||||
|
self.verify(session_id, generation)
|
||||||
|
|
||||||
|
def submap(self, session_id, generation, start, end, *, presentation=False):
|
||||||
|
with self.prepared_submaps(session_id, generation, presentation=presentation) as extract:
|
||||||
|
return extract(start, end)
|
||||||
|
|
||||||
|
def reference_map(self, session_id, generation, start, end, **options):
|
||||||
|
from .reference_map import build_reference_map
|
||||||
|
|
||||||
|
return build_reference_map(self, session_id, generation, start, end, **options)
|
||||||
|
|
||||||
|
def scene_reference_map(self, session_id, generation, start, end, **options):
|
||||||
|
from .reference_map import build_reference_map
|
||||||
|
|
||||||
|
return build_reference_map(
|
||||||
|
self, session_id, generation, start, end, presentation=True, **options
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract(version, arrays, doc, start, end, *, presentation):
|
||||||
|
poses, frames = arrays["positions"], arrays["frames"]
|
||||||
|
t = arrays["receipt_time_s"]
|
||||||
|
if not 0 <= start < end < len(poses):
|
||||||
|
raise ValueError("Invalid map interval.")
|
||||||
|
eligible = np.flatnonzero((frames[:, 0] >= t[start]) & (frames[:, 0] <= t[end]))
|
||||||
|
if not len(eligible):
|
||||||
|
raise ValueError("No cloud frames in the selected map interval.")
|
||||||
|
selected = eligible[
|
||||||
|
np.linspace(0, len(eligible) - 1, min(len(eligible), EXTRACTION["max_frames"]), dtype=int)
|
||||||
|
]
|
||||||
|
profile = {
|
||||||
|
**EXTRACTION,
|
||||||
|
**(dict(radius_m=80.0, height_relative_m=None) if presentation else {}),
|
||||||
|
}
|
||||||
|
# A frame uses the interpolated corrected sensor position only for cropping;
|
||||||
|
# the stored cloud already is in map coordinates and is never transformed twice.
|
||||||
|
centers = np.column_stack(
|
||||||
|
[np.interp(frames[selected, 0], t, poses[:, axis]) for axis in range(3)]
|
||||||
|
)
|
||||||
|
chunks, provenance, raw_count, retained_count = [], [], 0, 0
|
||||||
|
with (version.directory / POINTS).open("rb") as stream:
|
||||||
|
for index, center in zip(selected, centers, strict=True):
|
||||||
|
_, sequence, offset, count = frames[index]
|
||||||
|
stream.seek(int(offset) * 12)
|
||||||
|
chunk = np.frombuffer(stream.read(int(count) * 12), dtype="<f4").reshape(-1, 3)
|
||||||
|
if len(chunk) != count or not np.isfinite(chunk).all():
|
||||||
|
raise ValueError("Invalid map frame payload.")
|
||||||
|
delta = chunk - center
|
||||||
|
keep = np.linalg.norm(delta, axis=1) <= profile["radius_m"]
|
||||||
|
if profile["height_relative_m"] is not None:
|
||||||
|
low, high = profile["height_relative_m"]
|
||||||
|
keep &= (delta[:, 2] >= low) & (delta[:, 2] <= high)
|
||||||
|
raw_count += len(chunk)
|
||||||
|
retained_count += int(keep.sum())
|
||||||
|
chunks.append(chunk[keep])
|
||||||
|
provenance.append(
|
||||||
|
dict(
|
||||||
|
frame_index=int(index),
|
||||||
|
sequence=int(sequence),
|
||||||
|
receipt_time_s=float(frames[index, 0]),
|
||||||
|
source_distance_m=float(arrays["frame_source_distance_m"][index]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
points = np.concatenate(chunks)
|
||||||
|
_, indices = np.unique(
|
||||||
|
np.floor(points / profile["voxel_m"]).astype(np.int64), axis=0, return_index=True
|
||||||
|
)
|
||||||
|
points = points[np.sort(indices)]
|
||||||
|
return points, {
|
||||||
|
**{
|
||||||
|
key: doc[key]
|
||||||
|
for key in ("session_id", "generation", "label", "frame_id", "units", "source_digests")
|
||||||
|
},
|
||||||
|
"reference_version": doc["reference_version"],
|
||||||
|
"extraction": profile,
|
||||||
|
"start_index": start,
|
||||||
|
"end_index": end,
|
||||||
|
"available_frames": len(eligible),
|
||||||
|
"frames": provenance,
|
||||||
|
"raw_points": raw_count,
|
||||||
|
"retained_points": retained_count,
|
||||||
|
"voxel_points": len(points),
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Offline, source-preserving map derivatives; no capture or vehicle authority."""
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
"""Training-only acquisition of a known start-area revisit, not live tracking.
|
||||||
|
|
||||||
|
The registrar is injected: this module has no device, planner, catalog or UI
|
||||||
|
dependency. Every declared support window is evaluated before selection. No
|
||||||
|
endpoint equality, session-name branch, or withheld-point selection is used.
|
||||||
|
This is not arbitrary-loop discovery or a guarantee for unbounded SLAM drift.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
from .smooth_correction import SurfaceLink
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClosurePolicy:
|
||||||
|
version: str = "known-revisit-acquisition/v2"
|
||||||
|
reference_seconds: tuple = (10.0, 20.0, 40.0)
|
||||||
|
query_seconds: tuple = (5.0, 10.0, 20.0, 30.0)
|
||||||
|
primary_reference_s: float = 20.0
|
||||||
|
primary_query_s: float = 5.0
|
||||||
|
radius_m: float = 25.0
|
||||||
|
cycle_m: float = 0.1
|
||||||
|
cycle_deg: float = 0.2
|
||||||
|
agreement_m: float = 0.5
|
||||||
|
agreement_deg: float = 1.0
|
||||||
|
translation_weight_m: float = 0.03
|
||||||
|
rotation_weight_deg: float = 0.05
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
values = [
|
||||||
|
*self.reference_seconds,
|
||||||
|
*self.query_seconds,
|
||||||
|
self.radius_m,
|
||||||
|
self.cycle_m,
|
||||||
|
self.cycle_deg,
|
||||||
|
self.agreement_m,
|
||||||
|
self.agreement_deg,
|
||||||
|
self.translation_weight_m,
|
||||||
|
self.rotation_weight_deg,
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
not self.reference_seconds
|
||||||
|
or not self.query_seconds
|
||||||
|
or not np.isfinite(values).all()
|
||||||
|
or min(values) <= 0
|
||||||
|
or len(set(self.reference_seconds)) != len(self.reference_seconds)
|
||||||
|
or len(set(self.query_seconds)) != len(self.query_seconds)
|
||||||
|
or self.primary_reference_s not in self.reference_seconds
|
||||||
|
or self.primary_query_s not in self.query_seconds
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid closure acquisition policy.")
|
||||||
|
|
||||||
|
|
||||||
|
class ClosureUnavailable(ValueError):
|
||||||
|
def __init__(self, report):
|
||||||
|
self.report = report
|
||||||
|
super().__init__(report["reason"])
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(points, matrix):
|
||||||
|
return points @ matrix[:3, :3].T + matrix[:3, 3]
|
||||||
|
|
||||||
|
|
||||||
|
def _difference(a, b, center):
|
||||||
|
return (
|
||||||
|
float(np.linalg.norm(_apply(center, a) - _apply(center, b))),
|
||||||
|
float(
|
||||||
|
np.rad2deg(np.linalg.norm(Rotation.from_matrix(a[:3, :3].T @ b[:3, :3]).as_rotvec()))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def acquire_closure(data, registrar, policy=None):
|
||||||
|
"""Registrar(reference, query, seed, acquisition=bool) returns a gated fit.
|
||||||
|
|
||||||
|
Acquisition may allow a larger first correction. Reverse refinement MUST
|
||||||
|
use the unchanged tracking-quality policy, starting from the inverse fit.
|
||||||
|
Numerical exceptions are recorded as failures, never converted to identity.
|
||||||
|
"""
|
||||||
|
policy = policy or ClosurePolicy()
|
||||||
|
f, p, s = data["frames"], data["poses"], data["frame_distance"]
|
||||||
|
pts, ids, held = data["sample_points"], data["sample_frame"], data["heldout"]
|
||||||
|
if (
|
||||||
|
len(f) < 2
|
||||||
|
or len(p) < 2
|
||||||
|
or held.dtype != bool
|
||||||
|
or held.shape != (len(f),)
|
||||||
|
or s.shape != (len(f),)
|
||||||
|
or ids.shape != (len(pts),)
|
||||||
|
or ids.dtype.kind not in "iu"
|
||||||
|
or (ids < 0).any()
|
||||||
|
or (ids >= len(f)).any()
|
||||||
|
or not all(np.isfinite(v).all() for v in (f, p, s, pts))
|
||||||
|
or (np.diff(f[:, 0]) < 0).any()
|
||||||
|
or (np.diff(s) < 0).any()
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid closure frame ownership or chronology.")
|
||||||
|
# One fixed spatial population in the original frame, independent of fits.
|
||||||
|
training = ~held[ids] & (np.linalg.norm(pts - p[0, 1:4], axis=1) <= policy.radius_m)
|
||||||
|
report = dict(
|
||||||
|
schema_version="missioncore.closure-acquisition/v1",
|
||||||
|
policy=asdict(policy),
|
||||||
|
training_only=True,
|
||||||
|
endpoint_constraint=False,
|
||||||
|
attempts=[],
|
||||||
|
status="rejected",
|
||||||
|
expected_attempts=len(policy.reference_seconds) * len(policy.query_seconds),
|
||||||
|
)
|
||||||
|
links = {}
|
||||||
|
for first in policy.reference_seconds:
|
||||||
|
amask = (f[:, 0] <= f[0, 0] + first) & ~held
|
||||||
|
a = pts[training & amask[ids]]
|
||||||
|
for last in policy.query_seconds:
|
||||||
|
bmask = (f[:, 0] >= f[-1, 0] - last) & ~held
|
||||||
|
b = pts[training & bmask[ids]]
|
||||||
|
row = dict(
|
||||||
|
reference_s=first,
|
||||||
|
query_s=last,
|
||||||
|
qualified=False,
|
||||||
|
reference_points=len(a),
|
||||||
|
query_points=len(b),
|
||||||
|
)
|
||||||
|
report["attempts"].append(row)
|
||||||
|
if np.any(amask & bmask):
|
||||||
|
row["reason"] = "overlapping-source-windows"
|
||||||
|
continue
|
||||||
|
if min(len(a), len(b)) < 300:
|
||||||
|
row["reason"] = "insufficient-training-geometry"
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fit = registrar(a, b, np.eye(4), acquisition=True)
|
||||||
|
row["fit"] = fit
|
||||||
|
if fit["status"] != "candidate":
|
||||||
|
row["reason"] = "forward-quality"
|
||||||
|
continue
|
||||||
|
t = np.asarray(fit["T_reference_query"])
|
||||||
|
reverse = registrar(b, a, np.linalg.inv(t), acquisition=False)
|
||||||
|
row["reverse"] = reverse
|
||||||
|
if reverse["status"] != "candidate":
|
||||||
|
row["reason"] = "reverse-quality"
|
||||||
|
continue
|
||||||
|
center = np.median(b, axis=0)
|
||||||
|
cycle = t @ np.asarray(reverse["T_reference_query"])
|
||||||
|
cm, cr = _difference(cycle, np.eye(4), center)
|
||||||
|
row.update(cycle_m=cm, cycle_deg=cr)
|
||||||
|
if cm > policy.cycle_m or cr > policy.cycle_deg:
|
||||||
|
row["reason"] = "bidirectional-inconsistency"
|
||||||
|
continue
|
||||||
|
link = SurfaceLink(
|
||||||
|
float(np.mean(s[amask])),
|
||||||
|
float(np.mean(s[bmask])),
|
||||||
|
t,
|
||||||
|
center,
|
||||||
|
policy.translation_weight_m,
|
||||||
|
policy.rotation_weight_deg,
|
||||||
|
"start-area/revisit",
|
||||||
|
)
|
||||||
|
except (ValueError, np.linalg.LinAlgError) as exc:
|
||||||
|
row["reason"] = "numerical-unavailable"
|
||||||
|
row["detail"] = str(exc)
|
||||||
|
continue
|
||||||
|
row["qualified"] = True
|
||||||
|
links[len(report["attempts"]) - 1] = link
|
||||||
|
report["complete"] = len(report["attempts"]) == report["expected_attempts"]
|
||||||
|
if not links:
|
||||||
|
report["reason"] = "No training-only closure passed quality and reverse consistency."
|
||||||
|
raise ClosureUnavailable(report)
|
||||||
|
# Preserve the established short-window measurement when it qualifies.
|
||||||
|
# Larger support is a fallback, not proof of a better measurement: mixing
|
||||||
|
# more motion/vegetation can change an otherwise stable registration.
|
||||||
|
# Still finish the full matrix and check competing fits before acceptance.
|
||||||
|
selected = max(
|
||||||
|
links,
|
||||||
|
key=lambda i: (
|
||||||
|
(
|
||||||
|
report["attempts"][i]["reference_s"] == policy.primary_reference_s
|
||||||
|
and report["attempts"][i]["query_s"] == policy.primary_query_s
|
||||||
|
),
|
||||||
|
report["attempts"][i]["reference_s"] * report["attempts"][i]["query_s"],
|
||||||
|
min(report["attempts"][i]["reference_points"], report["attempts"][i]["query_points"]),
|
||||||
|
-i,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
link = links[selected]
|
||||||
|
report["selection_rule"] = "qualified-primary-else-largest-support; complete-consistency-check"
|
||||||
|
agreement = []
|
||||||
|
for i, other in links.items():
|
||||||
|
# Check both patch centers; a rotation must not hide at one chosen pivot.
|
||||||
|
distances = [
|
||||||
|
_difference(link.T_reference_query, other.T_reference_query, c)
|
||||||
|
for c in (link.query_center, other.query_center)
|
||||||
|
]
|
||||||
|
dm, deg = max(x[0] for x in distances), max(x[1] for x in distances)
|
||||||
|
agreement.append(dict(attempt=i, distance_m=dm, angle_deg=deg))
|
||||||
|
report.update(selected_attempt=selected, qualified_attempts=list(links), agreement=agreement)
|
||||||
|
if any(
|
||||||
|
r["distance_m"] > policy.agreement_m or r["angle_deg"] > policy.agreement_deg
|
||||||
|
for r in agreement
|
||||||
|
):
|
||||||
|
report["reason"] = "Qualified support windows disagree; closure is ambiguous."
|
||||||
|
raise ClosureUnavailable(report)
|
||||||
|
report.update(status="candidate", reason=None)
|
||||||
|
return link, report
|
||||||
|
|
||||||
|
|
||||||
|
def review_acceptance(results, validation):
|
||||||
|
"""Frozen same-source checks authorize packaging, never vehicle operation."""
|
||||||
|
before, after = results
|
||||||
|
seam = after["seam_holdout"]
|
||||||
|
previous = before["seam_holdout"]
|
||||||
|
checks = {
|
||||||
|
"all_local_windows_qualified": validation[1]["total"] > 0
|
||||||
|
and validation[1]["candidate_count"] == validation[1]["total"],
|
||||||
|
"heldout_seam_present": seam["points"] >= 300 and seam["query_frames"] >= 2,
|
||||||
|
"heldout_seam_quality": seam["overlap_05m"] >= 0.55
|
||||||
|
and seam["inlier_rmse_m"] is not None
|
||||||
|
and seam["inlier_rmse_m"] <= 0.25,
|
||||||
|
"heldout_seam_not_degraded": seam["overlap_05m"] >= previous["overlap_05m"] - 0.02
|
||||||
|
and seam["all_point_distances_m"]["median"]
|
||||||
|
<= previous["all_point_distances_m"]["median"] + 0.02,
|
||||||
|
}
|
||||||
|
return dict(
|
||||||
|
schema_version="missioncore.closure-review/v1",
|
||||||
|
checks=checks,
|
||||||
|
accepted=all(checks.values()),
|
||||||
|
independent_accuracy=False,
|
||||||
|
vehicle_control=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
"""Immutable, vendor-neutral map candidates. Publication is not promotion.
|
||||||
|
|
||||||
|
No session catalog writes or automatic latest-version selection. Readers pin a
|
||||||
|
manifest digest, verify every artifact, and never reinterpret source traversal
|
||||||
|
coordinates as corrected path length. No solver dependency is needed to read.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from copy import deepcopy
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.map-reference-version/v1"
|
||||||
|
POINTS = "points.f32"
|
||||||
|
TRAJECTORY = "trajectory.npz"
|
||||||
|
AUTHORITY = dict(production_promotion=False, vehicle_control=False, independent_pass_verified=False)
|
||||||
|
|
||||||
|
|
||||||
|
def sha256(path):
|
||||||
|
with Path(path).open("rb") as stream:
|
||||||
|
return hashlib.file_digest(stream, "sha256").hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def json_bytes(value):
|
||||||
|
return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False).encode()
|
||||||
|
|
||||||
|
|
||||||
|
def _digest(value):
|
||||||
|
if not isinstance(value, str) or not re.fullmatch(r"[a-f0-9]{64}", value):
|
||||||
|
raise ValueError("Invalid map identity.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _plain_file(path):
|
||||||
|
if path.is_symlink() or not path.is_file():
|
||||||
|
raise ValueError("Map artifact must be a regular file, not a link.")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _trajectory(path, point_count):
|
||||||
|
with np.load(path, allow_pickle=False) as data:
|
||||||
|
arrays = {
|
||||||
|
key: np.array(data[key], dtype=float)
|
||||||
|
for key in (
|
||||||
|
"positions",
|
||||||
|
"orientations_xyzw",
|
||||||
|
"receipt_time_s",
|
||||||
|
"source_distance_m",
|
||||||
|
"frame_source_distance_m",
|
||||||
|
"frames",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
p, q, t, s, fs, f = arrays.values()
|
||||||
|
if (
|
||||||
|
p.ndim != 2
|
||||||
|
or p.shape[1:] != (3,)
|
||||||
|
or len(p) < 2
|
||||||
|
or q.shape != (len(p), 4)
|
||||||
|
or t.shape != (len(p),)
|
||||||
|
or s.shape != (len(p),)
|
||||||
|
or f.ndim != 2
|
||||||
|
or f.shape[1:] != (4,)
|
||||||
|
or len(f) < 1
|
||||||
|
or fs.shape != (len(f),)
|
||||||
|
or not all(np.isfinite(a).all() for a in arrays.values())
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid map trajectory dimensions or values.")
|
||||||
|
if (
|
||||||
|
(np.diff(t) <= 0).any()
|
||||||
|
or s[0] != 0
|
||||||
|
or (np.diff(s) < 0).any()
|
||||||
|
or not np.allclose(np.linalg.norm(q, axis=1), 1, atol=1e-6, rtol=0)
|
||||||
|
or (np.diff(f[:, 0]) < 0).any()
|
||||||
|
or (np.diff(f[:, 1]) <= 0).any()
|
||||||
|
or not np.equal(f[:, 1:], np.floor(f[:, 1:])).all()
|
||||||
|
or (f[:, 1:] < 0).any()
|
||||||
|
or f[0, 2] != 0
|
||||||
|
or not np.array_equal(f[1:, 2], f[:-1, 2] + f[:-1, 3])
|
||||||
|
or f[-1, 2] + f[-1, 3] != point_count
|
||||||
|
or not np.allclose(fs, np.interp(f[:, 0], t, s), rtol=0, atol=1e-7)
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid map frame ownership, clocks or source traversal binding.")
|
||||||
|
arrays["distance_m"] = np.r_[0.0, np.cumsum(np.linalg.norm(np.diff(p, axis=0), axis=1))]
|
||||||
|
return arrays
|
||||||
|
|
||||||
|
|
||||||
|
def _source_identity(source):
|
||||||
|
if (
|
||||||
|
source.get("schema_version") != "missioncore.planning-source/v1"
|
||||||
|
or source.get("reference_version")
|
||||||
|
or source.get("units") != "m"
|
||||||
|
or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", source["session_id"])
|
||||||
|
or not source.get("source_digests")
|
||||||
|
):
|
||||||
|
raise ValueError("A map version requires an original, metre-based planning source.")
|
||||||
|
return dict(
|
||||||
|
session_id=source["session_id"],
|
||||||
|
generation=_digest(source["generation"]),
|
||||||
|
source_digests={k: _digest(v) for k, v in source["source_digests"].items()},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def publish_map_version(
|
||||||
|
root,
|
||||||
|
source,
|
||||||
|
points,
|
||||||
|
trajectory,
|
||||||
|
*,
|
||||||
|
expected_points_sha256,
|
||||||
|
expected_trajectory_sha256,
|
||||||
|
evidence,
|
||||||
|
method,
|
||||||
|
label,
|
||||||
|
):
|
||||||
|
"""Seal an already reviewed derivative in an exclusive content-addressed directory.
|
||||||
|
|
||||||
|
Evidence maps a simple filename to (source path, expected SHA-256). Caller
|
||||||
|
owns scientific review and source verification; integrity is not accuracy.
|
||||||
|
The trajectory input uses the explicit v1 keys checked by `_trajectory`.
|
||||||
|
"""
|
||||||
|
root = Path(root)
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
source_id = _source_identity(source)
|
||||||
|
if not label.strip() or not method or not evidence:
|
||||||
|
raise ValueError("A labelled candidate requires method and review evidence.")
|
||||||
|
inputs = {
|
||||||
|
POINTS: (Path(points), expected_points_sha256),
|
||||||
|
TRAJECTORY: (Path(trajectory), expected_trajectory_sha256),
|
||||||
|
**evidence,
|
||||||
|
}
|
||||||
|
if len(inputs) != len(evidence) + 2:
|
||||||
|
raise ValueError("Evidence may not replace map geometry.")
|
||||||
|
if any(
|
||||||
|
not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", name) or name == "manifest.json"
|
||||||
|
for name in inputs
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid artifact name.")
|
||||||
|
# Only this private staging directory is cleaned up on failure.
|
||||||
|
with TemporaryDirectory(prefix=".map-version-", dir=root) as temporary:
|
||||||
|
stage = Path(temporary)
|
||||||
|
artifacts = {}
|
||||||
|
for name, (path, expected) in inputs.items():
|
||||||
|
path = _plain_file(Path(path))
|
||||||
|
_digest(expected)
|
||||||
|
shutil.copyfile(path, stage / name)
|
||||||
|
if sha256(stage / name) != expected or sha256(path) != expected:
|
||||||
|
raise ValueError("Map input changed or failed its expected digest: " + name)
|
||||||
|
artifacts[name] = dict(sha256=expected, bytes=(stage / name).stat().st_size)
|
||||||
|
size = artifacts[POINTS]["bytes"]
|
||||||
|
if not size or size % 12:
|
||||||
|
raise ValueError("Map points must be a nonempty little-endian Nx3 float32 array.")
|
||||||
|
arrays = _trajectory(stage / TRAJECTORY, size // 12)
|
||||||
|
if len(arrays["positions"]) != len(source["poses"]):
|
||||||
|
raise ValueError("Map pose indices must preserve original source ownership.")
|
||||||
|
if not np.allclose(
|
||||||
|
arrays["source_distance_m"],
|
||||||
|
[p["distance_m"] for p in source["poses"]],
|
||||||
|
rtol=0,
|
||||||
|
atol=1e-7,
|
||||||
|
):
|
||||||
|
raise ValueError("Map source traversal does not match the selected recording.")
|
||||||
|
# Chunked finiteness check; never allocate the full cloud twice.
|
||||||
|
with (stage / POINTS).open("rb") as stream:
|
||||||
|
while block := stream.read(12 * 65536):
|
||||||
|
if not np.isfinite(np.frombuffer(block, dtype="<f4")).all():
|
||||||
|
raise ValueError("Map contains non-finite points.")
|
||||||
|
doc = dict(
|
||||||
|
schema_version=SCHEMA,
|
||||||
|
source=source_id,
|
||||||
|
label=label.strip(),
|
||||||
|
units="m",
|
||||||
|
kind="corrected-map-candidate",
|
||||||
|
method=method,
|
||||||
|
authority=AUTHORITY,
|
||||||
|
point_count=size // 12,
|
||||||
|
pose_count=len(arrays["positions"]),
|
||||||
|
frame_count=len(arrays["frames"]),
|
||||||
|
path_m=float(arrays["distance_m"][-1]),
|
||||||
|
artifacts=artifacts,
|
||||||
|
)
|
||||||
|
payload = json_bytes(doc)
|
||||||
|
generation = hashlib.sha256(payload).hexdigest()
|
||||||
|
target = root / generation
|
||||||
|
(stage / "manifest.json").write_bytes(payload)
|
||||||
|
if target.exists():
|
||||||
|
MapVersion(target, generation).verify()
|
||||||
|
else:
|
||||||
|
# Renaming a complete directory makes partial candidates undiscoverable.
|
||||||
|
stage.rename(target)
|
||||||
|
return MapVersion(target, generation)
|
||||||
|
|
||||||
|
|
||||||
|
class MapVersion:
|
||||||
|
def __init__(self, directory, generation):
|
||||||
|
self.directory = Path(directory)
|
||||||
|
self.generation = _digest(generation)
|
||||||
|
self.document = self._manifest()
|
||||||
|
|
||||||
|
def _manifest(self):
|
||||||
|
path = _plain_file(self.directory / "manifest.json")
|
||||||
|
payload = path.read_bytes()
|
||||||
|
if hashlib.sha256(payload).hexdigest() != self.generation:
|
||||||
|
raise ValueError("Map manifest identity changed.")
|
||||||
|
doc = json.loads(payload)
|
||||||
|
if (
|
||||||
|
doc.get("schema_version") != SCHEMA
|
||||||
|
or doc.get("units") != "m"
|
||||||
|
or doc.get("authority") != AUTHORITY
|
||||||
|
or doc.get("kind") != "corrected-map-candidate"
|
||||||
|
or not {POINTS, TRAJECTORY}.issubset(doc.get("artifacts", {}))
|
||||||
|
):
|
||||||
|
raise ValueError("Unsupported map version contract or authority.")
|
||||||
|
for name, artifact in doc["artifacts"].items():
|
||||||
|
if (
|
||||||
|
not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9._-]*", name)
|
||||||
|
or name == "manifest.json"
|
||||||
|
or type(artifact["bytes"]) is not int
|
||||||
|
or artifact["bytes"] < 0
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid map artifact metadata.")
|
||||||
|
_digest(artifact["sha256"])
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def verify(self):
|
||||||
|
doc = self._manifest()
|
||||||
|
for name, meta in doc["artifacts"].items():
|
||||||
|
path = _plain_file(self.directory / name)
|
||||||
|
if path.stat().st_size != meta["bytes"] or sha256(path) != meta["sha256"]:
|
||||||
|
raise ValueError("Map artifact identity changed: " + name)
|
||||||
|
return doc
|
||||||
|
|
||||||
|
def planning_source(self, original):
|
||||||
|
original = deepcopy(original)
|
||||||
|
doc = self.verify()
|
||||||
|
if _source_identity(original) != doc["source"]:
|
||||||
|
raise ValueError("Map version belongs to another source generation.")
|
||||||
|
arrays = _trajectory(self.directory / TRAJECTORY, doc["point_count"])
|
||||||
|
self.verify()
|
||||||
|
if len(original["poses"]) != len(arrays["positions"]):
|
||||||
|
raise ValueError("Map and original pose ownership differ.")
|
||||||
|
poses = [
|
||||||
|
{**pose, "position": xyz.tolist(), "distance_m": float(distance)}
|
||||||
|
for pose, xyz, distance in zip(
|
||||||
|
original["poses"], arrays["positions"], arrays["distance_m"], strict=True
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
**original,
|
||||||
|
"poses": poses,
|
||||||
|
"generation": self.generation,
|
||||||
|
"frame_id": "map/" + self.generation,
|
||||||
|
"label": doc["label"],
|
||||||
|
"path_m": float(arrays["distance_m"][-1]),
|
||||||
|
"reference_version": dict(
|
||||||
|
schema_version=SCHEMA,
|
||||||
|
source=doc["source"],
|
||||||
|
authority=doc["authority"],
|
||||||
|
method=doc["method"],
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
def arrays(self):
|
||||||
|
"""Only use within a verified private snapshot for a multi-tile preparation."""
|
||||||
|
return _trajectory(self.directory / TRAJECTORY, self.document["point_count"])
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Read-only projection of complete, sealed per-frame map geometry.
|
||||||
|
|
||||||
|
The capture clock stays untouched. Map receipt times are relative to the first
|
||||||
|
raw message, NOT to recording start or the first pose. No fitting occurs here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from .map_version import POINTS, TRAJECTORY, _trajectory, sha256
|
||||||
|
|
||||||
|
|
||||||
|
class RecordedMapGeometry:
|
||||||
|
def __init__(self, artifacts):
|
||||||
|
manifest = Path(artifacts["map-version-manifest.json"])
|
||||||
|
self.generation = sha256(manifest)
|
||||||
|
self.document = json.loads(manifest.read_text())
|
||||||
|
paths = {name: Path(artifacts["map-version-" + name]) for name in (POINTS, TRAJECTORY)}
|
||||||
|
for name, path in paths.items():
|
||||||
|
meta = self.document["artifacts"][name]
|
||||||
|
if path.stat().st_size != meta["bytes"] or sha256(path) != meta["sha256"]:
|
||||||
|
raise ValueError("Corrected recording geometry failed integrity validation.")
|
||||||
|
self.arrays = _trajectory(paths[TRAJECTORY], self.document["point_count"])
|
||||||
|
self._points = np.memmap(paths[POINTS], dtype="<f4", mode="r").reshape(-1, 3)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def optional(cls, artifacts):
|
||||||
|
if not artifacts or not any(key.startswith("map-version-") for key in artifacts):
|
||||||
|
return None
|
||||||
|
return cls(artifacts)
|
||||||
|
|
||||||
|
def recording_id(self, original_id):
|
||||||
|
return hashlib.sha256((original_id + ":map:" + self.generation).encode()).hexdigest()
|
||||||
|
|
||||||
|
def points(self, index, receipt_time_s, count):
|
||||||
|
t, _sequence, offset, length = self.arrays["frames"][index]
|
||||||
|
self._check_time(t, receipt_time_s)
|
||||||
|
if length != count:
|
||||||
|
raise ValueError("Corrected cloud point ownership mismatch.")
|
||||||
|
return self._points[int(offset) : int(offset + length)]
|
||||||
|
|
||||||
|
def pose(self, index, receipt_time_s):
|
||||||
|
self._check_time(self.arrays["receipt_time_s"][index], receipt_time_s)
|
||||||
|
return tuple(self.arrays["positions"][index]), tuple(
|
||||||
|
self.arrays["orientations_xyzw"][index]
|
||||||
|
)
|
||||||
|
|
||||||
|
def complete(self, point_frames, poses):
|
||||||
|
if point_frames != len(self.arrays["frames"]) or poses != len(self.arrays["positions"]):
|
||||||
|
raise ValueError("Corrected recording does not cover all native frames.")
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _check_time(expected, actual):
|
||||||
|
if not np.isfinite(actual) or abs(expected - actual) > 1e-6:
|
||||||
|
raise ValueError("Corrected frame receipt clock mismatch.")
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
"""One explicit default map per physical session, shared by playback and planning.
|
||||||
|
|
||||||
|
Candidate publication is not activation. Activation copies an immutable reviewed
|
||||||
|
bundle into durable application storage, then replaces a small selection pointer.
|
||||||
|
This admits laboratory reference use, not autonomous vehicle control.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from .map_version import MapVersion, _digest, json_bytes
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.session-map-default/v1"
|
||||||
|
|
||||||
|
|
||||||
|
class SessionMapVersions:
|
||||||
|
def __init__(self, data_dir):
|
||||||
|
self.root = Path(data_dir) / "session-map-versions"
|
||||||
|
|
||||||
|
def _selection(self, session_id):
|
||||||
|
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}", session_id):
|
||||||
|
raise ValueError("Invalid session identity.")
|
||||||
|
return self.root / "selected" / (session_id + ".json")
|
||||||
|
|
||||||
|
def version(self, session_id, generation):
|
||||||
|
directory = self.root / "versions" / _digest(generation)
|
||||||
|
if not directory.exists():
|
||||||
|
return None
|
||||||
|
if directory.is_symlink():
|
||||||
|
raise ValueError("Map version directory may not be a link.")
|
||||||
|
version = MapVersion(directory, generation)
|
||||||
|
if version.document["source"]["session_id"] != session_id:
|
||||||
|
raise ValueError("Map belongs to another session.")
|
||||||
|
return version
|
||||||
|
|
||||||
|
def selected(self, session_id):
|
||||||
|
path = self._selection(session_id)
|
||||||
|
if not path.exists() and not path.is_symlink():
|
||||||
|
return None
|
||||||
|
if path.is_symlink() or path.stat().st_size > 8192:
|
||||||
|
raise ValueError("Invalid map selection.")
|
||||||
|
doc = json.loads(path.read_text())
|
||||||
|
if doc.get("schema_version") != SCHEMA or doc.get("session_id") != session_id:
|
||||||
|
raise ValueError("Invalid map selection identity.")
|
||||||
|
version = self.version(session_id, doc["generation"])
|
||||||
|
if version is None:
|
||||||
|
raise ValueError("Selected corrected map is unavailable; original was not substituted.")
|
||||||
|
return version
|
||||||
|
|
||||||
|
def activate(self, version, original_sources):
|
||||||
|
"""Explicit operator admission; keep original captures and old map versions."""
|
||||||
|
doc = version.verify()
|
||||||
|
session_id = doc["source"]["session_id"]
|
||||||
|
parent = original_sources.verify(session_id, doc["source"]["generation"])
|
||||||
|
version.planning_source(parent)
|
||||||
|
root = self.root / "versions"
|
||||||
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
|
target = root / version.generation
|
||||||
|
if not target.exists():
|
||||||
|
with TemporaryDirectory(prefix=".admit-", dir=root) as temporary:
|
||||||
|
stage = Path(temporary) / version.generation
|
||||||
|
stage.mkdir()
|
||||||
|
for name in ["manifest.json", *doc["artifacts"]]:
|
||||||
|
shutil.copyfile(version.directory / name, stage / name)
|
||||||
|
with (stage / name).open("rb") as stream:
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
MapVersion(stage, version.generation).verify()
|
||||||
|
stage.rename(target)
|
||||||
|
_sync_directory(root)
|
||||||
|
self.version(session_id, version.generation).verify()
|
||||||
|
original_sources.verify(session_id, doc["source"]["generation"])
|
||||||
|
path = self._selection(session_id)
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
candidate = path.with_name("." + uuid4().hex + ".json")
|
||||||
|
try:
|
||||||
|
candidate.write_bytes(
|
||||||
|
json_bytes(
|
||||||
|
dict(
|
||||||
|
schema_version=SCHEMA,
|
||||||
|
session_id=session_id,
|
||||||
|
generation=version.generation,
|
||||||
|
uses=["recorded-playback", "laboratory-reference"],
|
||||||
|
vehicle_control=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
with candidate.open("rb") as stream:
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(candidate, path)
|
||||||
|
_sync_directory(path.parent)
|
||||||
|
finally:
|
||||||
|
candidate.unlink(missing_ok=True)
|
||||||
|
return self.selected(session_id)
|
||||||
|
|
||||||
|
def resolve_replay(self, command):
|
||||||
|
from k1link.sessions.models import ReplayMapVersion
|
||||||
|
|
||||||
|
# An already pinned launch remains pinned even if the default changes.
|
||||||
|
if command.map_version is not None:
|
||||||
|
return command
|
||||||
|
version = self.selected(command.session_id)
|
||||||
|
if version is None:
|
||||||
|
return command
|
||||||
|
return replace(command, map_version=ReplayMapVersion(version.directory, version.generation))
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_directory(path):
|
||||||
|
descriptor = os.open(path, os.O_RDONLY)
|
||||||
|
try:
|
||||||
|
os.fsync(descriptor)
|
||||||
|
finally:
|
||||||
|
os.close(descriptor)
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
"""Experimental smooth correction of already mapped, provenance-bearing frames.
|
||||||
|
|
||||||
|
This is NOT a replacement LiDAR odometer or an automatic loop detector. A caller
|
||||||
|
must supply independently checked registrations between disjoint source windows.
|
||||||
|
The unknown C(s) maps original-map coordinates into a corrected map. Its six
|
||||||
|
parameters are cubic splines over traveled distance: translation and a rotation
|
||||||
|
vector. Every individual frame receives ONE rigid C(s), preserving its geometry.
|
||||||
|
The original map/trajectory is never mutated. The first knot fixes the gauge;
|
||||||
|
no constraint equates the first and last scanner positions.
|
||||||
|
|
||||||
|
Weights below are declared engineering regularizers, NOT calibrated covariance.
|
||||||
|
The small-rotation chart is appropriate for the measured first experiment; this
|
||||||
|
is not a qualified solution for arbitrary large drift or an arbitrary loop graph.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.interpolate import CubicSpline
|
||||||
|
from scipy.optimize import least_squares
|
||||||
|
from scipy.spatial.transform import Rotation
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CorrectionPolicy:
|
||||||
|
version: str = "smooth-map-correction-experiment/v2"
|
||||||
|
knot_spacing_m: float = 20.0
|
||||||
|
smoothness_length_m: float = 30.0
|
||||||
|
rotation_lever_m: float = 20.0
|
||||||
|
strain_weight: float = 1.0
|
||||||
|
maximum_evaluations: int = 100
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
values = [
|
||||||
|
self.knot_spacing_m,
|
||||||
|
self.smoothness_length_m,
|
||||||
|
self.rotation_lever_m,
|
||||||
|
self.strain_weight,
|
||||||
|
self.maximum_evaluations,
|
||||||
|
]
|
||||||
|
if not np.isfinite(values).all() or min(values) <= 0:
|
||||||
|
raise ValueError("Correction policy values must be finite and positive.")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SurfaceLink:
|
||||||
|
reference_distance_m: float
|
||||||
|
query_distance_m: float
|
||||||
|
T_reference_query: np.ndarray
|
||||||
|
query_center: np.ndarray
|
||||||
|
translation_weight_m: float
|
||||||
|
rotation_weight_deg: float
|
||||||
|
identity: str
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
t = np.asarray(self.T_reference_query, dtype=float)
|
||||||
|
c = np.asarray(self.query_center, dtype=float)
|
||||||
|
if (
|
||||||
|
t.shape != (4, 4)
|
||||||
|
or not np.isfinite(t).all()
|
||||||
|
or not np.allclose(t[3], [0, 0, 0, 1])
|
||||||
|
or not np.allclose(t[:3, :3].T @ t[:3, :3], np.eye(3), atol=1e-7)
|
||||||
|
or not np.isclose(np.linalg.det(t[:3, :3]), 1)
|
||||||
|
or c.shape != (3,)
|
||||||
|
or not np.isfinite(c).all()
|
||||||
|
):
|
||||||
|
raise ValueError("A surface link requires a rigid transform and finite center.")
|
||||||
|
values = [
|
||||||
|
self.reference_distance_m,
|
||||||
|
self.query_distance_m,
|
||||||
|
self.translation_weight_m,
|
||||||
|
self.rotation_weight_deg,
|
||||||
|
]
|
||||||
|
if (
|
||||||
|
not np.isfinite(values).all()
|
||||||
|
or min(values[:2]) < 0
|
||||||
|
or self.reference_distance_m == self.query_distance_m
|
||||||
|
or min(values[2:]) <= 0
|
||||||
|
or not self.identity
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid link distances, weights or identity.")
|
||||||
|
t, c = t.copy(), c.copy()
|
||||||
|
t.setflags(write=False)
|
||||||
|
c.setflags(write=False)
|
||||||
|
object.__setattr__(self, "T_reference_query", t)
|
||||||
|
object.__setattr__(self, "query_center", c)
|
||||||
|
|
||||||
|
|
||||||
|
class CorrectionField:
|
||||||
|
def __init__(self, knots, parameters, origin=None):
|
||||||
|
self.origin = np.asarray(np.zeros(3) if origin is None else origin, dtype=float).copy()
|
||||||
|
if self.origin.shape != (3,) or not np.isfinite(self.origin).all():
|
||||||
|
raise ValueError("Correction origin must be a finite 3D point.")
|
||||||
|
self.knots = np.asarray(knots, dtype=float).copy()
|
||||||
|
self.parameters = np.asarray(parameters, dtype=float).copy()
|
||||||
|
if (
|
||||||
|
self.knots.ndim != 1
|
||||||
|
or len(self.knots) < 2
|
||||||
|
or not np.isfinite(self.knots).all()
|
||||||
|
or (np.diff(self.knots) <= 0).any()
|
||||||
|
or self.parameters.shape != (len(self.knots), 6)
|
||||||
|
or not np.isfinite(self.parameters).all()
|
||||||
|
):
|
||||||
|
raise ValueError("Invalid correction knots or parameters.")
|
||||||
|
self.spline = CubicSpline(self.knots, self.parameters, bc_type="natural")
|
||||||
|
|
||||||
|
def matrices(self, distance):
|
||||||
|
s = np.atleast_1d(np.asarray(distance, dtype=float))
|
||||||
|
if (
|
||||||
|
s.ndim != 1
|
||||||
|
or not np.isfinite(s).all()
|
||||||
|
or (s < self.knots[0] - 1e-8).any()
|
||||||
|
or (s > self.knots[-1] + 1e-8).any()
|
||||||
|
):
|
||||||
|
raise ValueError("Correction cannot extrapolate beyond its source route.")
|
||||||
|
p = self.spline(np.clip(s, self.knots[0], self.knots[-1]))
|
||||||
|
# Avoid silently wrapping the rotation-vector chart through pi.
|
||||||
|
if (np.linalg.norm(p[:, 3:], axis=1) >= np.pi / 2).any():
|
||||||
|
raise ValueError("Correction exceeds the experimental small-rotation chart.")
|
||||||
|
result = np.repeat(np.eye(4)[None], len(s), axis=0)
|
||||||
|
result[:, :3, :3] = Rotation.from_rotvec(p[:, 3:]).as_matrix()
|
||||||
|
result[:, :3, 3] = (
|
||||||
|
p[:, :3] + self.origin - np.einsum("nij,j->ni", result[:, :3, :3], self.origin)
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def points(self, points, distance):
|
||||||
|
p = np.asarray(points, dtype=float)
|
||||||
|
if p.ndim != 2 or p.shape[1] != 3 or not np.isfinite(p).all():
|
||||||
|
raise ValueError("Expected finite Nx3 points.")
|
||||||
|
c = self.matrices(distance)
|
||||||
|
if len(c) not in (1, len(p)):
|
||||||
|
raise ValueError("One correction per frame or per point is required.")
|
||||||
|
return np.einsum("nij,nj->ni", c[:, :3, :3], p) + c[:, :3, 3]
|
||||||
|
|
||||||
|
def poses(self, positions, orientations_xyzw, distance):
|
||||||
|
c = self.matrices(distance)
|
||||||
|
q = np.asarray(orientations_xyzw, dtype=float)
|
||||||
|
if q.shape != (len(c), 4) or not np.isfinite(q).all():
|
||||||
|
raise ValueError("Pose orientations must match correction coordinates.")
|
||||||
|
return (
|
||||||
|
self.points(positions, distance),
|
||||||
|
(Rotation.from_matrix(c[:, :3, :3]) * Rotation.from_quat(q)).as_quat(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def fit_correction(length_m, links, policy=None):
|
||||||
|
"""Fit one separately reviewed candidate; absence of constraints is an error."""
|
||||||
|
policy = policy or CorrectionPolicy()
|
||||||
|
if not np.isfinite(length_m) or length_m <= 0 or not links:
|
||||||
|
raise ValueError("A positive route length and verified links are required.")
|
||||||
|
if any(max(e.reference_distance_m, e.query_distance_m) > length_m for e in links):
|
||||||
|
raise ValueError("Surface link lies outside the source route.")
|
||||||
|
knots = np.linspace(0, length_m, max(2, int(np.ceil(length_m / policy.knot_spacing_m)) + 1))
|
||||||
|
# Three-point quadrature exactly integrates the squared cubic derivatives.
|
||||||
|
h = np.diff(knots)
|
||||||
|
mid = (knots[:-1] + knots[1:]) / 2
|
||||||
|
abscissa, weight = np.polynomial.legendre.leggauss(3)
|
||||||
|
grid = (mid[:, None] + h[:, None] * abscissa / 2).ravel()
|
||||||
|
root_weight = np.sqrt((h[:, None] * weight / 2).ravel())[:, None]
|
||||||
|
unit_scale = np.array([1, 1, 1, *([policy.rotation_lever_m] * 3)])
|
||||||
|
edge_s = np.array([[e.reference_distance_m, e.query_distance_m] for e in links])
|
||||||
|
measured_r = Rotation.from_matrix(np.stack([e.T_reference_query[:3, :3] for e in links]))
|
||||||
|
centers = np.stack([e.query_center for e in links])
|
||||||
|
destinations = np.stack(
|
||||||
|
[e.T_reference_query[:3, :3] @ e.query_center + e.T_reference_query[:3, 3] for e in links]
|
||||||
|
)
|
||||||
|
# Geometry-bound pivot: a change of world origin must not change regularization.
|
||||||
|
origin = destinations[0].copy()
|
||||||
|
centers, destinations = centers - origin, destinations - origin
|
||||||
|
tw = np.array([e.translation_weight_m for e in links])[:, None]
|
||||||
|
rw = np.deg2rad([e.rotation_weight_deg for e in links])[:, None]
|
||||||
|
|
||||||
|
def field_of(x):
|
||||||
|
return CorrectionField(knots, np.vstack([np.zeros(6), x.reshape(-1, 6)]), origin)
|
||||||
|
|
||||||
|
def residual(x):
|
||||||
|
field = field_of(x)
|
||||||
|
values = field.spline(edge_s)
|
||||||
|
a = Rotation.from_rotvec(values[:, 0, 3:])
|
||||||
|
b = Rotation.from_rotvec(values[:, 1, 3:])
|
||||||
|
displacement = (
|
||||||
|
b.apply(centers) + values[:, 1, :3] - a.apply(destinations) - values[:, 0, :3]
|
||||||
|
) / tw
|
||||||
|
angular = ((a * measured_r).inv() * b).as_rotvec() / rw
|
||||||
|
first = field.spline(grid, 1) * unit_scale * root_weight * policy.strain_weight
|
||||||
|
second = (
|
||||||
|
field.spline(grid, 2)
|
||||||
|
* unit_scale
|
||||||
|
* root_weight
|
||||||
|
* policy.strain_weight
|
||||||
|
* policy.smoothness_length_m
|
||||||
|
)
|
||||||
|
return np.r_[displacement.ravel(), angular.ravel(), first.ravel(), second.ravel()]
|
||||||
|
|
||||||
|
result = least_squares(
|
||||||
|
residual,
|
||||||
|
np.zeros((len(knots) - 1) * 6),
|
||||||
|
max_nfev=policy.maximum_evaluations,
|
||||||
|
x_scale="jac",
|
||||||
|
ftol=1e-8,
|
||||||
|
xtol=1e-8,
|
||||||
|
gtol=1e-8,
|
||||||
|
)
|
||||||
|
field = field_of(result.x)
|
||||||
|
# Check interpolation, not just control points, for forbidden rotations.
|
||||||
|
field.matrices(np.linspace(0, length_m, max(100, len(knots) * 10)))
|
||||||
|
remaining = residual(result.x)[: len(links) * 6]
|
||||||
|
return field, {
|
||||||
|
"schema_version": "missioncore.smooth-map-correction/v2",
|
||||||
|
"origin_m": origin.tolist(),
|
||||||
|
"policy": asdict(policy),
|
||||||
|
"converged": bool(result.success),
|
||||||
|
"solver_message": str(result.message),
|
||||||
|
"evaluations": int(result.nfev),
|
||||||
|
"cost": float(result.cost),
|
||||||
|
"optimality": float(result.optimality),
|
||||||
|
"knots_m": knots.tolist(),
|
||||||
|
"parameters": field.parameters.tolist(),
|
||||||
|
"link_residuals": [
|
||||||
|
dict(
|
||||||
|
identity=e.identity,
|
||||||
|
translation_m=float(np.linalg.norm(remaining[i * 3 : i * 3 + 3]) * tw[i, 0]),
|
||||||
|
rotation_deg=float(
|
||||||
|
np.rad2deg(
|
||||||
|
np.linalg.norm(
|
||||||
|
remaining[len(links) * 3 + i * 3 : len(links) * 3 + i * 3 + 3]
|
||||||
|
)
|
||||||
|
* rw[i, 0]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for i, e in enumerate(links)
|
||||||
|
],
|
||||||
|
"weights_are_calibrated_covariances": False,
|
||||||
|
"status": "candidate" if result.success else "solver-failed",
|
||||||
|
"production_promotion": False,
|
||||||
|
"vehicle_control": False,
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Mutable per-recording presentation metadata, separate from sealed evidence."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCHEMA = "missioncore.session-display-profile/v1"
|
||||||
|
|
||||||
|
|
||||||
|
def load_display_profile(root: Path, session_id: str) -> dict | None:
|
||||||
|
path = root / "display-profile.json"
|
||||||
|
if not path.exists():
|
||||||
|
return None
|
||||||
|
if path.is_symlink() or not path.is_file() or path.stat().st_size > 64 * 1024:
|
||||||
|
raise ValueError("invalid display profile file")
|
||||||
|
document = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if (not isinstance(document, dict) or document.get("schema_version") != SCHEMA
|
||||||
|
or document.get("session_id") != session_id):
|
||||||
|
raise ValueError("invalid display profile identity")
|
||||||
|
return document
|
||||||
|
|
||||||
|
|
||||||
|
def save_display_profile(root: Path, session_id: str, settings: dict) -> dict:
|
||||||
|
destination = root / "display-profile.json"
|
||||||
|
if destination.is_symlink():
|
||||||
|
raise ValueError("invalid display profile file")
|
||||||
|
document = {"schema_version": SCHEMA, "session_id": session_id, "scene_settings": settings}
|
||||||
|
descriptor, temporary = tempfile.mkstemp(prefix=".display-profile-", dir=root)
|
||||||
|
try:
|
||||||
|
with os.fdopen(descriptor, "w", encoding="utf-8") as stream:
|
||||||
|
json.dump(document, stream, ensure_ascii=False, allow_nan=False)
|
||||||
|
stream.write("\n")
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
os.replace(temporary, destination)
|
||||||
|
finally:
|
||||||
|
Path(temporary).unlink(missing_ok=True)
|
||||||
|
return document
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Validate a pinned geometry projection without widening native source roots."""
|
||||||
|
|
||||||
|
from k1link.reconstruction.map_version import POINTS, TRAJECTORY, MapVersion
|
||||||
|
|
||||||
|
|
||||||
|
class MapReplaySessionStore:
|
||||||
|
"""Operator playback facade. Catalog, media and raw evidence stay in the store.
|
||||||
|
|
||||||
|
Scientific/native consumers receive the original store, not this facade.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, original, versions):
|
||||||
|
self.original, self.versions = original, versions
|
||||||
|
|
||||||
|
def __getattr__(self, name):
|
||||||
|
return getattr(self.original, name)
|
||||||
|
|
||||||
|
def prepare_replay(self, *args, **kwargs):
|
||||||
|
from .models import SessionIntegrityError
|
||||||
|
from .recording import RecordingMaterializationError, _validate_source
|
||||||
|
|
||||||
|
try:
|
||||||
|
command = self.versions.resolve_replay(self.original.prepare_replay(*args, **kwargs))
|
||||||
|
if command.map_version is not None:
|
||||||
|
_validate_source(command)
|
||||||
|
return command
|
||||||
|
except (OSError, ValueError, RecordingMaterializationError) as exc:
|
||||||
|
raise SessionIntegrityError(
|
||||||
|
"Исправленная версия записи недоступна или изменилась."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def map_recording_inputs(command, source):
|
||||||
|
from .recording import (
|
||||||
|
RecordingMaterializationError,
|
||||||
|
_regular_file_stat_nofollow,
|
||||||
|
_sha256_stable,
|
||||||
|
_validated_artifact_digests,
|
||||||
|
_ValidatedArtifact,
|
||||||
|
)
|
||||||
|
|
||||||
|
binding = command.map_version
|
||||||
|
if binding is None:
|
||||||
|
return ()
|
||||||
|
try:
|
||||||
|
if binding.directory.is_symlink() or binding.directory.name != binding.generation:
|
||||||
|
raise ValueError("Unsafe map directory.")
|
||||||
|
version = MapVersion(binding.directory, binding.generation)
|
||||||
|
parent = version.document["source"]
|
||||||
|
if parent["session_id"] != command.session_id or parent[
|
||||||
|
"source_digests"
|
||||||
|
] != _validated_artifact_digests(source):
|
||||||
|
raise ValueError("Map source identity does not match the recording.")
|
||||||
|
artifacts = []
|
||||||
|
names = {
|
||||||
|
"manifest.json": binding.generation,
|
||||||
|
**{
|
||||||
|
name: version.document["artifacts"][name]["sha256"] for name in (POINTS, TRAJECTORY)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, expected in names.items():
|
||||||
|
path = binding.directory / name
|
||||||
|
stat = _regular_file_stat_nofollow(path, "map recording input")
|
||||||
|
if _sha256_stable(path, stat) != expected:
|
||||||
|
raise ValueError("Map recording artifact changed.")
|
||||||
|
artifacts.append(
|
||||||
|
_ValidatedArtifact(
|
||||||
|
"map-version-" + name,
|
||||||
|
path,
|
||||||
|
"application/octet-stream",
|
||||||
|
stat,
|
||||||
|
stat.st_size,
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(artifacts)
|
||||||
|
except (OSError, ValueError, KeyError) as exc:
|
||||||
|
raise RecordingMaterializationError(
|
||||||
|
"Исправленная версия записи недоступна или изменилась."
|
||||||
|
) from exc
|
||||||
@@ -282,6 +282,14 @@ class ReplayArtifact:
|
|||||||
expected_sha256: str | None
|
expected_sha256: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ReplayMapVersion:
|
||||||
|
"""Pinned derived geometry; never expands the native capture confinement."""
|
||||||
|
|
||||||
|
directory: Path
|
||||||
|
generation: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class ReplayCommand:
|
class ReplayCommand:
|
||||||
"""Internal replay request containing no vendor format or channel names."""
|
"""Internal replay request containing no vendor format or channel names."""
|
||||||
@@ -296,6 +304,7 @@ class ReplayCommand:
|
|||||||
timeline_origin_monotonic_ns: int
|
timeline_origin_monotonic_ns: int
|
||||||
speed: float
|
speed: float
|
||||||
loop: bool
|
loop: bool
|
||||||
|
map_version: ReplayMapVersion | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def primary_artifact(self) -> ReplayArtifact:
|
def primary_artifact(self) -> ReplayArtifact:
|
||||||
|
|||||||
+111
-40
@@ -1,4 +1,5 @@
|
|||||||
"""On-demand, source-bound overview cache. No catalog-wide decoding."""
|
"""On-demand, source-bound overview cache. No catalog-wide decoding."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
@@ -7,26 +8,34 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
|
from collections.abc import Mapping
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Mapping
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
from .store import SessionStore
|
from .overview_comparison import load_comparison
|
||||||
from .plugin_contract import RecordingExporter
|
from .plugin_contract import RecordingExporter
|
||||||
from .recording import (_validate_source, _validate_source_state,
|
from .recording import (
|
||||||
_validated_artifact_digests, _stage_replay_prefix)
|
_stage_replay_prefix,
|
||||||
|
_validate_source,
|
||||||
|
_validate_source_state,
|
||||||
|
_validated_artifact_digests,
|
||||||
|
)
|
||||||
|
from .store import SessionStore
|
||||||
|
|
||||||
SCHEMA = 'missioncore.session-overview/v1'
|
SCHEMA = "missioncore.session-overview/v1"
|
||||||
|
|
||||||
|
|
||||||
class SessionOverviewService:
|
class SessionOverviewService:
|
||||||
def __init__(self, store: SessionStore, exporters: Mapping[str, RecordingExporter]):
|
def __init__(
|
||||||
|
self, store: SessionStore, exporters: Mapping[str, RecordingExporter], *, map_versions=None
|
||||||
|
):
|
||||||
self.store = store
|
self.store = store
|
||||||
|
self.map_versions = map_versions
|
||||||
self.exporters = exporters
|
self.exporters = exporters
|
||||||
self.root = store.data_dir / 'session-overviews'
|
self.root = store.data_dir / "session-overviews"
|
||||||
self.root.mkdir(parents=True, exist_ok=True)
|
self.root.mkdir(parents=True, exist_ok=True)
|
||||||
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix='session-overview')
|
self.executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="session-overview")
|
||||||
self.guard = threading.RLock()
|
self.guard = threading.RLock()
|
||||||
self.cancel = threading.Event()
|
self.cancel = threading.Event()
|
||||||
self.jobs: dict[str, dict] = {}
|
self.jobs: dict[str, dict] = {}
|
||||||
@@ -37,25 +46,35 @@ class SessionOverviewService:
|
|||||||
|
|
||||||
def get(self, session_id: str, *, start: bool = True) -> dict:
|
def get(self, session_id: str, *, start: bool = True) -> dict:
|
||||||
detail = self.store.get_session(session_id)
|
detail = self.store.get_session(session_id)
|
||||||
base = {'schema_version': SCHEMA, 'session': detail.as_dict()}
|
base = {"schema_version": SCHEMA, "session": detail.as_dict()}
|
||||||
exporter = self.exporters.get(detail.plugin_id)
|
exporter = self.exporters.get(detail.plugin_id)
|
||||||
if not detail.summary.replayable or detail.summary.lab is not None or exporter is None:
|
if not detail.summary.replayable or detail.summary.lab is not None or exporter is None:
|
||||||
return {**base, 'state': 'ready', 'metrics': None, 'scene_url': None}
|
return {**base, "state": "ready", "metrics": None, "scene_url": None}
|
||||||
command = self.store.prepare_replay(session_id)
|
command = self.store.prepare_replay(session_id)
|
||||||
source = _validate_source(command)
|
source = _validate_source(command)
|
||||||
identity = hashlib.sha256(json.dumps([SCHEMA, session_id, source.identity], default=str).encode()).hexdigest()
|
identity = hashlib.sha256(
|
||||||
|
json.dumps([SCHEMA, session_id, source.identity], default=str).encode()
|
||||||
|
).hexdigest()
|
||||||
directory = self.root / identity
|
directory = self.root / identity
|
||||||
cached = self._cached(directory)
|
cached = self._cached(directory)
|
||||||
if cached:
|
if cached:
|
||||||
return {**base, **cached, 'generation': identity, 'scene_url': f'/api/v1/observation-sessions/{session_id}/overview/scene.rrd?generation={identity}'}
|
return {
|
||||||
|
**base,
|
||||||
|
**cached,
|
||||||
|
"generation": identity,
|
||||||
|
"scene_url": (
|
||||||
|
f"/api/v1/observation-sessions/{session_id}/overview/scene.rrd"
|
||||||
|
f"?generation={identity}"
|
||||||
|
),
|
||||||
|
}
|
||||||
with self.guard:
|
with self.guard:
|
||||||
if identity in self.jobs:
|
if identity in self.jobs:
|
||||||
return {**base, **self.jobs[identity]}
|
return {**base, **self.jobs[identity]}
|
||||||
if not start:
|
if not start:
|
||||||
return {**base, 'state': 'missing'}
|
return {**base, "state": "missing"}
|
||||||
if sum(j['state'] in {'queued', 'preparing'} for j in self.jobs.values()) >= 8:
|
if sum(j["state"] in {"queued", "preparing"} for j in self.jobs.values()) >= 8:
|
||||||
return {**base, 'state': 'error', 'message': 'Подготовка занята. Повторите позже.'}
|
return {**base, "state": "error", "message": "Подготовка занята. Повторите позже."}
|
||||||
self.jobs[identity] = {'state': 'queued', 'messages_processed': 0}
|
self.jobs[identity] = {"state": "queued", "messages_processed": 0}
|
||||||
self.executor.submit(self._build, identity, source, exporter)
|
self.executor.submit(self._build, identity, source, exporter)
|
||||||
return {**base, **self.jobs[identity]}
|
return {**base, **self.jobs[identity]}
|
||||||
|
|
||||||
@@ -63,65 +82,117 @@ class SessionOverviewService:
|
|||||||
detail = self.store.get_session(session_id)
|
detail = self.store.get_session(session_id)
|
||||||
if detail.summary.replayable and detail.summary.lab is None:
|
if detail.summary.replayable and detail.summary.lab is None:
|
||||||
source = _validate_source(self.store.prepare_replay(session_id))
|
source = _validate_source(self.store.prepare_replay(session_id))
|
||||||
identity = hashlib.sha256(json.dumps([SCHEMA, session_id, source.identity], default=str).encode()).hexdigest()
|
identity = hashlib.sha256(
|
||||||
|
json.dumps([SCHEMA, session_id, source.identity], default=str).encode()
|
||||||
|
).hexdigest()
|
||||||
with self.guard:
|
with self.guard:
|
||||||
if self.jobs.get(identity, {}).get('state') == 'error':
|
if self.jobs.get(identity, {}).get("state") == "error":
|
||||||
self.jobs.pop(identity, None)
|
self.jobs.pop(identity, None)
|
||||||
return self.get(session_id)
|
return self.get(session_id)
|
||||||
|
|
||||||
def scene(self, session_id: str, generation: str) -> Path:
|
def scene(self, session_id: str, generation: str) -> Path:
|
||||||
current = self.get(session_id, start=False)
|
current = self.get(session_id, start=False)
|
||||||
if current.get('state') != 'ready' or current.get('generation') != generation:
|
if current.get("state") != "ready" or current.get("generation") != generation:
|
||||||
raise ValueError('overview generation is unavailable')
|
raise ValueError("overview generation is unavailable")
|
||||||
return self.root / generation / 'scene.rrd'
|
return self.root / generation / "scene.rrd"
|
||||||
|
|
||||||
|
def comparison(
|
||||||
|
self, session_id: str, generation: str, comparison_generation: str | None = None
|
||||||
|
):
|
||||||
|
# scene() rechecks current source identity, not merely the cached filename.
|
||||||
|
scene = self.scene(session_id, generation)
|
||||||
|
report = json.loads((scene.parent / "overview.json").read_text())
|
||||||
|
return load_comparison(
|
||||||
|
self.store.data_dir / "session-map-previews",
|
||||||
|
generation,
|
||||||
|
session_id,
|
||||||
|
report["source_digests"],
|
||||||
|
comparison_generation,
|
||||||
|
)
|
||||||
|
|
||||||
|
def default_representation(self, session_id, comparison, reference_generation=None):
|
||||||
|
if self.map_versions is None:
|
||||||
|
return "original"
|
||||||
|
version = (
|
||||||
|
self.map_versions.selected(session_id)
|
||||||
|
if reference_generation is None
|
||||||
|
else self.map_versions.version(session_id, reference_generation)
|
||||||
|
)
|
||||||
|
if version is None:
|
||||||
|
return "original"
|
||||||
|
version.verify()
|
||||||
|
if comparison is None or comparison.document["map_generation"] != version.generation:
|
||||||
|
raise ValueError("Corrected overview for the pinned map is unavailable.")
|
||||||
|
if version.document["source"]["source_digests"] != comparison.document["source_digests"]:
|
||||||
|
raise ValueError("Corrected overview source mismatch.")
|
||||||
|
return "corrected"
|
||||||
|
|
||||||
def _cached(self, directory: Path) -> dict | None:
|
def _cached(self, directory: Path) -> dict | None:
|
||||||
try:
|
try:
|
||||||
report = directory / 'overview.json'
|
report = directory / "overview.json"
|
||||||
if report.stat().st_size > 2 * 1024 * 1024:
|
if report.stat().st_size > 2 * 1024 * 1024:
|
||||||
return None
|
return None
|
||||||
doc = json.loads(report.read_text())
|
doc = json.loads(report.read_text())
|
||||||
stat = (directory / 'scene.rrd').stat()
|
stat = (directory / "scene.rrd").stat()
|
||||||
if doc['schema_version'] != SCHEMA or [stat.st_size, stat.st_mtime_ns] != doc['scene_stat']:
|
if (
|
||||||
|
doc["schema_version"] != SCHEMA
|
||||||
|
or [stat.st_size, stat.st_mtime_ns] != doc["scene_stat"]
|
||||||
|
):
|
||||||
return None
|
return None
|
||||||
return {'state': 'ready', 'metrics': doc['metrics'], 'scene_sha256': doc['scene_sha256']}
|
return {
|
||||||
|
"state": "ready",
|
||||||
|
"metrics": doc["metrics"],
|
||||||
|
"scene_sha256": doc["scene_sha256"],
|
||||||
|
}
|
||||||
except (OSError, ValueError, KeyError, TypeError):
|
except (OSError, ValueError, KeyError, TypeError):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _build(self, identity: str, source, exporter: RecordingExporter) -> None:
|
def _build(self, identity: str, source, exporter: RecordingExporter) -> None:
|
||||||
directory = self.root / identity
|
directory = self.root / identity
|
||||||
directory.mkdir(exist_ok=True)
|
directory.mkdir(exist_ok=True)
|
||||||
candidate = directory / ('.' + uuid4().hex + '.rrd')
|
candidate = directory / ("." + uuid4().hex + ".rrd")
|
||||||
staged = None
|
staged = None
|
||||||
try:
|
try:
|
||||||
with self.guard:
|
with self.guard:
|
||||||
self.jobs[identity] = {'state': 'preparing', 'messages_processed': 0}
|
self.jobs[identity] = {"state": "preparing", "messages_processed": 0}
|
||||||
digests = _validated_artifact_digests(source)
|
digests = _validated_artifact_digests(source)
|
||||||
staged, primary, _ = _stage_replay_prefix(directory, source, cancel_event=self.cancel)
|
staged, primary, _ = _stage_replay_prefix(directory, source, cancel_event=self.cancel)
|
||||||
|
|
||||||
def pulse():
|
def pulse():
|
||||||
with self.guard:
|
with self.guard:
|
||||||
self.jobs[identity]['messages_processed'] += 100
|
self.jobs[identity]["messages_processed"] += 100
|
||||||
metrics = dict(exporter(primary, candidate, cancel_event=self.cancel, activity_callback=pulse))
|
|
||||||
|
metrics = dict(
|
||||||
|
exporter(primary, candidate, cancel_event=self.cancel, activity_callback=pulse)
|
||||||
|
)
|
||||||
if self.cancel.is_set() or source.identity != _validate_source_state(source).identity:
|
if self.cancel.is_set() or source.identity != _validate_source_state(source).identity:
|
||||||
raise ValueError('overview source changed')
|
raise ValueError("overview source changed")
|
||||||
if digests != _validated_artifact_digests(source):
|
if digests != _validated_artifact_digests(source):
|
||||||
raise ValueError('overview source changed')
|
raise ValueError("overview source changed")
|
||||||
if candidate.stat().st_size > 32 * 1024 * 1024:
|
if candidate.stat().st_size > 32 * 1024 * 1024:
|
||||||
raise ValueError('overview exceeded display budget')
|
raise ValueError("overview exceeded display budget")
|
||||||
digest = hashlib.sha256(candidate.read_bytes()).hexdigest()
|
digest = hashlib.sha256(candidate.read_bytes()).hexdigest()
|
||||||
os.replace(candidate, directory / 'scene.rrd')
|
os.replace(candidate, directory / "scene.rrd")
|
||||||
stat = (directory / 'scene.rrd').stat()
|
stat = (directory / "scene.rrd").stat()
|
||||||
document = {'schema_version': SCHEMA, 'metrics': metrics, 'source_digests': digests,
|
document = {
|
||||||
'scene_sha256': digest, 'scene_stat': [stat.st_size, stat.st_mtime_ns]}
|
"schema_version": SCHEMA,
|
||||||
temporary = directory / '.overview.json'
|
"metrics": metrics,
|
||||||
|
"source_digests": digests,
|
||||||
|
"scene_sha256": digest,
|
||||||
|
"scene_stat": [stat.st_size, stat.st_mtime_ns],
|
||||||
|
}
|
||||||
|
temporary = directory / ".overview.json"
|
||||||
temporary.write_text(json.dumps(document, allow_nan=False))
|
temporary.write_text(json.dumps(document, allow_nan=False))
|
||||||
os.replace(temporary, directory / 'overview.json')
|
os.replace(temporary, directory / "overview.json")
|
||||||
with self.guard:
|
with self.guard:
|
||||||
self.jobs.pop(identity, None)
|
self.jobs.pop(identity, None)
|
||||||
except Exception:
|
except Exception:
|
||||||
logging.getLogger(__name__).exception('Session overview preparation failed')
|
logging.getLogger(__name__).exception("Session overview preparation failed")
|
||||||
with self.guard:
|
with self.guard:
|
||||||
self.jobs[identity] = {'state': 'error', 'message': 'Не удалось подготовить обзор записи.'}
|
self.jobs[identity] = {
|
||||||
|
"state": "error",
|
||||||
|
"message": "Не удалось подготовить обзор записи.",
|
||||||
|
}
|
||||||
finally:
|
finally:
|
||||||
candidate.unlink(missing_ok=True)
|
candidate.unlink(missing_ok=True)
|
||||||
if staged is not None:
|
if staged is not None:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user