feat: finalize corrected-route planning and Rerun recording review
This commit is contained in:
@@ -10,3 +10,4 @@
|
||||
*.las binary
|
||||
*.lcc binary
|
||||
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||
|
||||
Generated
+1
@@ -7,6 +7,7 @@
|
||||
"": {
|
||||
"name": "@nodedc/mission-core-control-station",
|
||||
"version": "0.1.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^2.2.0",
|
||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"postinstall": "node scripts/install-rerun-navigation.mjs",
|
||||
"prebuild": "node scripts/install-rerun-navigation.mjs",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test test/*.test.mjs",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = JSON.parse(readFileSync(resolve(vendorRoot, "navigation-build.json"), "utf8"));
|
||||
const installed = JSON.parse(readFileSync(resolve(packageRoot, "package.json"), "utf8"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
if (installed.version !== "0.36.3" || manifest.upstreamVersion !== installed.version) {
|
||||
throw new Error("Rerun navigation requires the audited 0.36.3 package; rebase before upgrading");
|
||||
}
|
||||
if (sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))) !== manifest.patchSha256) {
|
||||
throw new Error("Rerun navigation source patch identity mismatch");
|
||||
}
|
||||
|
||||
const eyeType = "{ position: [number, number, number]; lookTarget: [number, number, number]; eyeUp: [number, number, number] } | null";
|
||||
const additions = {
|
||||
"index.js": {
|
||||
marker: " get_active_recording_id() {",
|
||||
code: ` get_camera_eye() {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.ts": {
|
||||
marker: " get_active_recording_id(): string | null {",
|
||||
code: ` get_camera_eye(): ${eyeType} {\n if (!this.#handle) throw new Error("Rerun viewer is stopped");\n return JSON.parse(this.#handle.nodedc_camera_eye() ?? "null");\n }\n`,
|
||||
},
|
||||
"index.d.ts": {
|
||||
marker: " get_active_recording_id(): string | null;",
|
||||
code: ` get_camera_eye(): ${eyeType};\n`,
|
||||
},
|
||||
};
|
||||
|
||||
// Validate the complete set before changing any installed file. A package
|
||||
// upgrade, corrupt artifact or independently modified wrapper fails closed.
|
||||
const writes = [];
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
const current = readFileSync(resolve(packageRoot, name));
|
||||
let output;
|
||||
if (additions[name]) {
|
||||
const { marker, code } = additions[name];
|
||||
const source = current.toString("utf8");
|
||||
const original = source.includes(code) ? source.replace(code, "") : source;
|
||||
if (sha(original) !== identity.upstreamSha256 || original.split(marker).length !== 2) {
|
||||
throw new Error(`Unexpected upstream wrapper: ${name}`);
|
||||
}
|
||||
output = Buffer.from(original.replace(marker, code + marker));
|
||||
} else {
|
||||
output = readFileSync(resolve(vendorRoot, identity.artifact));
|
||||
if (sha(output) !== identity.sha256) throw new Error(`Corrupt navigation artifact: ${name}`);
|
||||
if (![identity.upstreamSha256, identity.sha256].includes(sha(current))) {
|
||||
throw new Error(`Unexpected installed runtime: ${name}`);
|
||||
}
|
||||
}
|
||||
writes.push([resolve(packageRoot, name), output]);
|
||||
}
|
||||
for (const [path, output] of writes) writeFileSync(path, output);
|
||||
console.log("Installed NODE.DC Rerun 0.36.3 navigation/v1 (native camera, one renderer).");
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Inspector,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
ToastStack,
|
||||
UserProfileMenu,
|
||||
Window,
|
||||
WindowFooterActions,
|
||||
@@ -49,6 +50,7 @@ import type {
|
||||
} from "./core/observation/sessionArchive";
|
||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
|
||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||
import {
|
||||
@@ -181,6 +183,7 @@ export default function App() {
|
||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const closeDisplayRef = useRef<() => void>(() => {});
|
||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||
@@ -228,6 +231,13 @@ export default function App() {
|
||||
);
|
||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||
replayActiveRef.current = replayPresented;
|
||||
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
|
||||
sceneSettingsRef.current = settings;
|
||||
displayDraftRef.current = settings;
|
||||
confirmedSceneSettingsRef.current = settings;
|
||||
setSceneSettings(settings);
|
||||
setDisplayDraft(settings);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||
@@ -310,6 +320,7 @@ export default function App() {
|
||||
const remote = runtime.state?.viewerSettings;
|
||||
if (
|
||||
!remote ||
|
||||
replayActiveRef.current ||
|
||||
workspaceLayoutProfile.profile ||
|
||||
sceneSettingsCommitterRef.current?.isBusy()
|
||||
) return;
|
||||
@@ -332,7 +343,7 @@ export default function App() {
|
||||
|
||||
useEffect(() => {
|
||||
const profile = workspaceLayoutProfile.profile;
|
||||
if (!profile) return;
|
||||
if (!profile || selectedRecordedSessionIdRef.current) return;
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
@@ -403,7 +414,10 @@ export default function App() {
|
||||
|
||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||
if (windowId === "sources") setSourceWindowOpen(false);
|
||||
if (windowId === "display") setDisplayWindowOpen(false);
|
||||
if (windowId === "display") {
|
||||
setDisplayWindowOpen(false);
|
||||
closeDisplayRef.current();
|
||||
}
|
||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||
}, []);
|
||||
@@ -464,14 +478,22 @@ export default function App() {
|
||||
const next = { ...displayDraftRef.current, ...patch };
|
||||
displayDraftRef.current = next;
|
||||
setDisplayDraft(next);
|
||||
sessionDisplayProfile.edited();
|
||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||
}
|
||||
// Preview and persistence are separate. An open inspector must not freeze
|
||||
// either its controls or the shared accumulation slider below the scene.
|
||||
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(next);
|
||||
return;
|
||||
}
|
||||
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
|
||||
viewerSettingsCommitTimerRef.current = null;
|
||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||
}, viewerSettingsQuietPeriodMs);
|
||||
}, []);
|
||||
}, [sessionDisplayProfile.edited]);
|
||||
|
||||
const flushDisplaySettings = useCallback(() => {
|
||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||
@@ -481,8 +503,15 @@ export default function App() {
|
||||
}, []);
|
||||
|
||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||
sessionDisplayProfile.edited();
|
||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||
}, [commitDisplaySettings]);
|
||||
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
|
||||
|
||||
closeDisplayRef.current = () => {
|
||||
const settings = {...displayDraftRef.current};
|
||||
commitDisplaySettings(settings);
|
||||
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
|
||||
};
|
||||
|
||||
const openDisplay = () => {
|
||||
if (!sceneWorkspaceActive) return;
|
||||
@@ -1036,8 +1065,11 @@ export default function App() {
|
||||
onClose={() => closeSceneWindow("display")}
|
||||
>
|
||||
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||
</Window>
|
||||
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
|
||||
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
|
||||
onDismiss={sessionDisplayProfile.dismissError} />
|
||||
|
||||
<Window
|
||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||
|
||||
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
|
||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||
const sessions = useObservationSessions({
|
||||
limit,
|
||||
scope: "source",
|
||||
scope: "standalone",
|
||||
replayEnabled: blockedReason === null,
|
||||
onReplayBegin,
|
||||
onReplayAccepted,
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "./rerun/recordedRerunCameraJournal";
|
||||
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
|
||||
import {
|
||||
advanceLiveReceiverOpenWatchdog,
|
||||
advanceLiveReceiverWatchdog,
|
||||
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
|
||||
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
||||
sceneSettings?: Pick<
|
||||
SceneSettings,
|
||||
| "pointDecimationPercent"
|
||||
| "accumulationSeconds"
|
||||
| "showGrid"
|
||||
| "showPoints"
|
||||
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
|
||||
cameraContract?: string | null;
|
||||
appliedFollowTrajectory?: boolean | null;
|
||||
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
||||
configureCameraJournal?: (
|
||||
eye: RecordedRerunCameraEye,
|
||||
spatialViewportStart: number,
|
||||
) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye;
|
||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
||||
getCameraEye?: () => RecordedRerunCameraEye | null;
|
||||
getCurrentTimeNs?: () => number | null;
|
||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
channel: {
|
||||
readonly ready: boolean;
|
||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
currentTimeNs,
|
||||
reactivateUpdates = false,
|
||||
onCameraMaxOrbitalRadius,
|
||||
displayPointBank,
|
||||
perceptionLayers = {
|
||||
enabled: false,
|
||||
detections2d: false,
|
||||
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
|
||||
currentTimeNs?: number | null;
|
||||
reactivateUpdates?: boolean;
|
||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||
displayPointBank?: string | null;
|
||||
perceptionLayers?: RecordedPerceptionLayers;
|
||||
fetcher?: typeof globalThis.fetch;
|
||||
},
|
||||
): Promise<Uint8Array> {
|
||||
const resolvedUnifiedPerception =
|
||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
||||
// Rerun exposes its interpolated TimeReal cursor as f64 nanoseconds, even
|
||||
// when paused after seeking. The API needs an integer pose-query timestamp.
|
||||
// Quantize at this boundary; never round the viewer's actual playback cursor.
|
||||
const requestTimeNs = currentTimeNs == null ? currentTimeNs : Math.round(currentTimeNs);
|
||||
const base = new URL(origin);
|
||||
const endpoint = new URL(endpointUrl, base.origin);
|
||||
if (
|
||||
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
...cameraEye.eyeUp,
|
||||
].some((value) => !Number.isFinite(value))) ||
|
||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
||||
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
|
||||
)) ||
|
||||
[
|
||||
perceptionLayers.enabled,
|
||||
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
application_id: identity.applicationId,
|
||||
recording_id: identity.recordingId,
|
||||
blueprint_session_id: blueprintSessionId,
|
||||
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
|
||||
accumulation_seconds: settings.accumulationSeconds,
|
||||
show_grid: settings.showGrid,
|
||||
show_points: settings.showPoints,
|
||||
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
|
||||
eye_up: cameraEye?.eyeUp ?? null,
|
||||
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||
current_time_ns: currentTimeNs,
|
||||
current_time_ns: requestTimeNs,
|
||||
}),
|
||||
show_detections_2d: perceptionLayers.detections2d,
|
||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
|
||||
followTrajectory: boolean;
|
||||
},
|
||||
): string {
|
||||
// Following is a tracking property of the current native eye. It must never
|
||||
// initialize the browser journal again: doing so replaces the operator's
|
||||
// current pose with the startup preset immediately before the blueprint is
|
||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
||||
// Following uses the native eye snapshot, never the startup preset.
|
||||
// Plan/3D and explicit reset are the only preset transitions.
|
||||
return [activeView, viewResetGeneration, planView].join(":");
|
||||
}
|
||||
|
||||
@@ -841,6 +842,7 @@ function RerunViewportInstance({
|
||||
const presentationGateRef = useRef(presentationGate);
|
||||
presentationGateRef.current = presentationGate;
|
||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
|
||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||
const recordedBlueprintUrl = sourceUrl
|
||||
? resolveRecordedBlueprintUrl(
|
||||
@@ -857,6 +859,17 @@ function RerunViewportInstance({
|
||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
||||
const pointDisplay = useRecordedPointDisplay({
|
||||
endpoint: recordedBlueprintUrl, settings: sceneSettings, revision: blueprintChannelRevision,
|
||||
sourceGeneration: recordedArtifact?.sha256,
|
||||
ready: status === 'ready' && pointDisplayVisibilityGate === `${recordedBlueprintUrl}:${blueprintChannelRevision}`,
|
||||
onLoad: onPointColorLoadChange,
|
||||
getOwner: () => {
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
return active?.channel.ready && identity ? {identity, send: bytes => active.channel.send_rrd(bytes)} : null;
|
||||
},
|
||||
});
|
||||
const presentationStatus = rerunPresentationStatus(
|
||||
status,
|
||||
presentationGate,
|
||||
@@ -1638,29 +1651,14 @@ function RerunViewportInstance({
|
||||
cameraContract: null,
|
||||
appliedFollowTrajectory: null,
|
||||
pendingFollowCameraEye: null,
|
||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
||||
if ("configure_camera_journal" in viewer) {
|
||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
||||
}
|
||||
},
|
||||
getCameraEye: () => "get_camera_eye" in viewer
|
||||
? viewer.get_camera_eye()
|
||||
: RECORDED_RERUN_ORBITAL_EYE,
|
||||
setCameraViewportStart: (spatialViewportStart) => {
|
||||
if ("set_camera_viewport_start" in viewer) {
|
||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
||||
}
|
||||
},
|
||||
: null,
|
||||
getCurrentTimeNs: () => {
|
||||
const currentIdentity = recordedIdentityRef.current;
|
||||
if (!currentIdentity) return null;
|
||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
||||
},
|
||||
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if ("set_camera_max_orbital_radius" in viewer) {
|
||||
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
channel,
|
||||
};
|
||||
blueprintChannelRef.current = blueprintChannel;
|
||||
@@ -2010,6 +2008,7 @@ function RerunViewportInstance({
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordedPointColorsUrl || !sceneSettings) return;
|
||||
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
|
||||
const active = blueprintChannelRef.current;
|
||||
const identity = recordedIdentityRef.current;
|
||||
if (
|
||||
@@ -2089,6 +2088,7 @@ function RerunViewportInstance({
|
||||
sceneSettings?.colorMode,
|
||||
sceneSettings?.customColor,
|
||||
sceneSettings?.palette,
|
||||
sceneSettings?.pointDecimationPercent,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -2113,12 +2113,6 @@ function RerunViewportInstance({
|
||||
},
|
||||
);
|
||||
const cameraContractChanged = active.cameraContract !== cameraContract;
|
||||
if (cameraContractChanged) {
|
||||
active.configureCameraJournal?.(
|
||||
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
}
|
||||
const abort = new AbortController();
|
||||
const previousFollow = active.appliedFollowTrajectory ?? false;
|
||||
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
||||
@@ -2143,7 +2137,9 @@ function RerunViewportInstance({
|
||||
if (eyeRelativeToTracking && currentTimeNs == null) {
|
||||
throw new Error("Recorded tracking cursor is unavailable");
|
||||
}
|
||||
return fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
|
||||
return fetchRecordedBlueprintRrd(recordedBlueprintUrl,
|
||||
{...sceneSettings, showPoints: sceneSettings.showPoints && !pointDisplay.hidePoints}, identity, {
|
||||
displayPointBank: pointDisplay.bank,
|
||||
origin: window.location.origin,
|
||||
blueprintSessionId: blueprintSessionIdRef.current,
|
||||
signal: abort.signal,
|
||||
@@ -2157,13 +2153,11 @@ function RerunViewportInstance({
|
||||
planView: recordedPlanView,
|
||||
cameraEye,
|
||||
eyeRelativeToTracking,
|
||||
currentTimeNs,
|
||||
// Only following-eye transitions need a pose at the cursor. Supplying
|
||||
// time for display-only updates makes the API scan the entire archive
|
||||
// for camera bounds, although neither the camera nor its pose changed.
|
||||
currentTimeNs: eyeRelativeToTracking ? currentTimeNs : undefined,
|
||||
reactivateUpdates,
|
||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
||||
if (blueprintChannelRef.current === active) {
|
||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
const canApply = () => (
|
||||
@@ -2175,24 +2169,29 @@ function RerunViewportInstance({
|
||||
const applyPayload = (payload: Uint8Array) => {
|
||||
if (!canApply()) return false;
|
||||
active.channel.send_rrd(payload);
|
||||
active.setCameraViewportStart?.(
|
||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
||||
);
|
||||
// Exclude inactive point generations before admitting their data into
|
||||
// cached recordings whose embedded blueprint predates display controls.
|
||||
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
|
||||
return true;
|
||||
};
|
||||
void (async () => {
|
||||
const firstEye = disablingFollow
|
||||
? transitionEye ?? undefined
|
||||
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
||||
? pendingFollowEye ?? active.getCameraEye?.()
|
||||
: undefined;
|
||||
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
|
||||
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
|
||||
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
||||
disablingFollow || recordedFollowTrajectory
|
||||
);
|
||||
const firstPayload = await requestBlueprint(
|
||||
firstEye,
|
||||
firstEyeIsTrackingRelative,
|
||||
enablingFollow || disablingFollow || Boolean(pendingFollowEye),
|
||||
// Upstream copies incoming blueprints into an active store. Every
|
||||
// settings update must activate it; merely appending rows is invisible.
|
||||
// Activation copies the incoming blueprint store, not the operator's
|
||||
// edited store. Preserve its actual native eye on display-only updates;
|
||||
// omitting the eye resurrects the embedded startup camera.
|
||||
true,
|
||||
);
|
||||
if (!applyPayload(firstPayload)) return;
|
||||
active.cameraContract = cameraContract;
|
||||
@@ -2213,9 +2212,10 @@ function RerunViewportInstance({
|
||||
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
||||
if (!applyPayload(stabilizedPayload)) return;
|
||||
active.pendingFollowCameraEye = null;
|
||||
})().catch(() => {
|
||||
})().catch((error: unknown) => {
|
||||
// The recording remains usable with its embedded default blueprint.
|
||||
// A later settings change retries through the same small channel.
|
||||
if (!abort.signal.aborted) console.warn("Recorded scene settings were not applied", error);
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [
|
||||
@@ -2243,6 +2243,9 @@ function RerunViewportInstance({
|
||||
sceneSettings?.showPoints,
|
||||
sceneSettings?.showTrajectory,
|
||||
sceneSettings?.showGrid,
|
||||
pointDisplay.ready,
|
||||
pointDisplay.bank,
|
||||
pointDisplay.hidePoints,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,11 +2,11 @@ import type {ReactNode} from "react";
|
||||
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||
import { useSessionOverview } from "../../core/observation/useSessionOverview";
|
||||
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
|
||||
export function MissionZonePreview({ sessionId, toolbar }: { sessionId: string; toolbar?:ReactNode }) {
|
||||
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
|
||||
const { data, error, retry } = useSessionOverview(sessionId);
|
||||
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
|
||||
const failure = error || (data?.state === "error" ? data.message : null);
|
||||
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
|
||||
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
|
||||
return <SessionOverviewScene sourceUrl={data.scene_url} 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 { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
|
||||
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
|
||||
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
|
||||
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
|
||||
|
||||
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
|
||||
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
|
||||
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
|
||||
export function PlanningProjectSettings({p,onStart,starting}:{
|
||||
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
|
||||
}) {
|
||||
const disabled=p.busy||starting;
|
||||
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">
|
||||
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
|
||||
</div>},
|
||||
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
|
||||
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
|
||||
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
|
||||
{p.source&&<small>{p.source.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
|
||||
</div>},
|
||||
{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>
|
||||
{p.source&&<>
|
||||
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
|
||||
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
|
||||
<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 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 { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||
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. */
|
||||
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 [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
const [retry, setRetry] = useState(0);
|
||||
@@ -14,6 +14,10 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
||||
const [mode, setMode] = useState<OverviewViewMode>("3d");
|
||||
const [viewError, setViewError] = useState<string | null>(null);
|
||||
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
|
||||
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const comparison = metadata?.comparison;
|
||||
const appliedMode = useRef<OverviewViewMode | null>(null);
|
||||
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
|
||||
useEffect(() => {
|
||||
@@ -23,7 +27,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
||||
appliedMode.current = null;
|
||||
setState("loading");
|
||||
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 timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||
@@ -44,29 +52,33 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
||||
useEffect(() => {
|
||||
if (state !== "ready" || !metadata || !controller.current) return;
|
||||
const abort = new AbortController();
|
||||
setUpdating(true);
|
||||
const timer = setTimeout(() => {
|
||||
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
|
||||
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal).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;
|
||||
controller.current.channel.send_rrd(result.bytes);
|
||||
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
|
||||
appliedMode.current = mode;
|
||||
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);
|
||||
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 high = metadata?.height_max_m;
|
||||
const low = comparison?.height_min_m ?? metadata?.height_min_m;
|
||||
const high = comparison?.height_max_m ?? metadata?.height_max_m;
|
||||
return <>
|
||||
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
|
||||
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
|
||||
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
|
||||
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
|
||||
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
|
||||
{toolbar}
|
||||
</div>
|
||||
<LoadingRegion loading={state === "loading"} label="Загрузка облака" className="session-overview__scene">
|
||||
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
|
||||
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
|
||||
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
|
||||
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
|
||||
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
|
||||
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
|
||||
@@ -74,7 +86,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
|
||||
</div>}
|
||||
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</LoadingRegion>
|
||||
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
|
||||
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек</span>}
|
||||
<span className="session-overview__note" role="status" aria-busy={updating}>
|
||||
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
|
||||
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
|
||||
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
|
||||
</span>
|
||||
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,244 +1,28 @@
|
||||
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
|
||||
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
|
||||
export type RecordedRerunCameraEye = {
|
||||
readonly position: readonly [number, number, number];
|
||||
readonly lookTarget: readonly [number, number, number];
|
||||
readonly eyeUp: readonly [number, number, number];
|
||||
};
|
||||
|
||||
type Vector3 = [number, number, number];
|
||||
type DragMode = "rotate" | "pan" | "roll";
|
||||
|
||||
const ROTATION_RADIANS_PER_POINT = 0.004;
|
||||
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
|
||||
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
|
||||
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
|
||||
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
|
||||
const DOM_WHEEL_DELTA_LINE = 1;
|
||||
const DOM_WHEEL_DELTA_PAGE = 2;
|
||||
|
||||
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||
position: [16, -16, 18],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 0, 1],
|
||||
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
|
||||
};
|
||||
|
||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||
position: [0, 0, 30],
|
||||
lookTarget: [0, 0, 0],
|
||||
eyeUp: [0, 1, 0],
|
||||
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
|
||||
};
|
||||
|
||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
||||
a[1] * b[2] - a[2] * b[1],
|
||||
a[2] * b[0] - a[0] * b[2],
|
||||
a[0] * b[1] - a[1] * b[0],
|
||||
];
|
||||
const length = (value: Vector3) => Math.hypot(...value);
|
||||
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
|
||||
const magnitude = length(value);
|
||||
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
|
||||
};
|
||||
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
|
||||
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
|
||||
const axis = normalize(rawAxis, [0, 0, 1]);
|
||||
const cosine = Math.cos(angle);
|
||||
const sine = Math.sin(angle);
|
||||
return add(
|
||||
add(scale(value, cosine), scale(cross(axis, value), sine)),
|
||||
scale(axis, dot(axis, value) * (1 - cosine)),
|
||||
);
|
||||
};
|
||||
|
||||
function cloneEye(value: RecordedRerunCameraEye): {
|
||||
position: Vector3;
|
||||
lookTarget: Vector3;
|
||||
eyeUp: Vector3;
|
||||
} {
|
||||
return {
|
||||
position: vector(value.position),
|
||||
lookTarget: vector(value.lookTarget),
|
||||
eyeUp: vector(value.eyeUp),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
||||
* here lets a later layer blueprint activate with the exact operator pose.
|
||||
*/
|
||||
export function createRecordedRerunCameraJournal(
|
||||
canvas: HTMLCanvasElement,
|
||||
scope: Window & typeof globalThis,
|
||||
) {
|
||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
||||
let spatialViewportStart = 0;
|
||||
let pointerId: number | null = null;
|
||||
let dragMode: DragMode | null = null;
|
||||
let previousPointer: readonly [number, number] | null = null;
|
||||
let latestPointer: readonly [number, number] | null = null;
|
||||
let maxOrbitalRadius = 1.0e17;
|
||||
|
||||
const isSpatialPoint = (event: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
||||
};
|
||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
||||
const usableUp = () => {
|
||||
const fwd = forward();
|
||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
||||
? [1, 0, 0]
|
||||
: cross(fwd, [0, 0, 1]);
|
||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
||||
const candidate = normalize(eye.eyeUp, fallback);
|
||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
||||
};
|
||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
||||
|
||||
const rotate = (deltaX: number, deltaY: number) => {
|
||||
const radius = orbitRadius();
|
||||
const up = usableUp();
|
||||
let fwd = forward();
|
||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
||||
fwd = normalize(
|
||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
||||
fwd,
|
||||
);
|
||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
||||
const maxPitch = 0.99 * Math.PI / 2;
|
||||
const nextPitch = clamp(
|
||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
||||
-maxPitch,
|
||||
maxPitch,
|
||||
);
|
||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
||||
};
|
||||
|
||||
const pan = (deltaX: number, deltaY: number) => {
|
||||
const fwd = forward();
|
||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
||||
const speed = 0.001 * orbitRadius();
|
||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
||||
eye.position = add(eye.position, translation);
|
||||
eye.lookTarget = add(eye.lookTarget, translation);
|
||||
};
|
||||
|
||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const left = rect.left + rect.width * spatialViewportStart;
|
||||
const centerX = left + (rect.right - left) / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
const relativeX = event.clientX - centerX;
|
||||
const relativeY = event.clientY - centerY;
|
||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
||||
if (divisor <= Number.EPSILON) return;
|
||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
||||
};
|
||||
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (!isSpatialPoint(event)) return;
|
||||
pointerId = event.pointerId;
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
latestPointer = [event.clientX, event.clientY];
|
||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
||||
const deltaX = event.clientX - previousPointer[0];
|
||||
const deltaY = event.clientY - previousPointer[1];
|
||||
previousPointer = [event.clientX, event.clientY];
|
||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
||||
else roll(event, deltaX, deltaY);
|
||||
};
|
||||
const pointerEnd = (event: PointerEvent) => {
|
||||
if (event.pointerId !== pointerId) return;
|
||||
pointerMove(event);
|
||||
pointerId = null;
|
||||
previousPointer = null;
|
||||
dragMode = null;
|
||||
};
|
||||
const wheel = (event: WheelEvent) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
||||
? event.clientX
|
||||
: latestPointer?.[0];
|
||||
if (wheelX === undefined || rect.width <= 0) return;
|
||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
||||
? rect.height
|
||||
: 1;
|
||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
||||
// eframe negates the DOM delta before Rerun applies
|
||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
||||
// exponent products collapse to the raw accumulated DOM delta below.
|
||||
const divisor = event.ctrlKey
|
||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
||||
const offset = subtract(eye.position, eye.lookTarget);
|
||||
const radius = length(offset);
|
||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
||||
const nextRadius = clamp(
|
||||
radius * radiusFactor,
|
||||
RERUN_MIN_ORBIT_DISTANCE,
|
||||
Math.max(radius, maxOrbitalRadius),
|
||||
);
|
||||
eye.position = add(
|
||||
eye.lookTarget,
|
||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
||||
);
|
||||
};
|
||||
|
||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
||||
scope.addEventListener("pointermove", pointerMove, true);
|
||||
scope.addEventListener("pointerup", pointerEnd, true);
|
||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
||||
|
||||
return {
|
||||
current(): RecordedRerunCameraEye {
|
||||
return {
|
||||
position: [...eye.position],
|
||||
lookTarget: [...eye.lookTarget],
|
||||
eyeUp: [...eye.eyeUp],
|
||||
};
|
||||
},
|
||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
||||
eye = cloneEye(nextEye);
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
pointerId = null;
|
||||
dragMode = null;
|
||||
previousPointer = null;
|
||||
},
|
||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
||||
},
|
||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
||||
if (
|
||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
||||
) {
|
||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
||||
scope.removeEventListener("pointermove", pointerMove, true);
|
||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
||||
scope.removeEventListener("wheel", wheel, true);
|
||||
},
|
||||
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
|
||||
if (value == null) return null; // No 3D frame rendered yet.
|
||||
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
|
||||
const eye = value as Record<string, unknown>;
|
||||
const vector = (key: string): [number, number, number] => {
|
||||
const item = eye[key];
|
||||
if (!Array.isArray(item) || item.length !== 3 ||
|
||||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
|
||||
throw new Error("Invalid native Rerun camera vector");
|
||||
}
|
||||
return [item[0], item[1], item[2]];
|
||||
};
|
||||
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
|
||||
}
|
||||
|
||||
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
|
||||
"set_current_time" | "set_playing"
|
||||
> & {
|
||||
open_channel: (name?: string) => Channel;
|
||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye;
|
||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
||||
get_camera_eye: () => RecordedRerunCameraEye | null;
|
||||
};
|
||||
|
||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
||||
@@ -90,16 +87,7 @@ export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
|
||||
set_active_timeline: (...args) => command("set_active_timeline", args),
|
||||
set_current_time: (...args) => command("set_current_time", args),
|
||||
set_playing: (...args) => command("set_playing", args),
|
||||
configure_camera_journal: (eye, spatialViewportStart) => command(
|
||||
"configure-camera-journal", [eye, spatialViewportStart],
|
||||
),
|
||||
get_camera_eye: () => command("get-camera-eye"),
|
||||
set_camera_viewport_start: (spatialViewportStart) => command(
|
||||
"set-camera-viewport-start", [spatialViewportStart],
|
||||
),
|
||||
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
|
||||
"set-camera-max-orbital-radius", [maxOrbitalRadius],
|
||||
),
|
||||
};
|
||||
return { facade, dispose, notify };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
||||
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||
|
||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
let notify: RerunNotify | null = null;
|
||||
const subscriptions = new Map<number, () => void>();
|
||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
||||
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
|
||||
const send = (message: object) => notify?.(JSON.stringify(message));
|
||||
const stop = () => {
|
||||
notify = null;
|
||||
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||
}
|
||||
channels.clear();
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = null;
|
||||
const current = native;
|
||||
native = null;
|
||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
const starting = required();
|
||||
try {
|
||||
await starting.start(args[0], mount, args[1]);
|
||||
if (native === starting && starting.canvas) {
|
||||
cameraJournal?.dispose();
|
||||
cameraJournal = createRecordedRerunCameraJournal(
|
||||
starting.canvas,
|
||||
window as Window & typeof globalThis,
|
||||
);
|
||||
}
|
||||
if (native === starting) send({ type: "started", id });
|
||||
} catch (error) {
|
||||
if (native === starting) send({ type: "start-failed", id, message: String(error) });
|
||||
@@ -87,10 +77,9 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
||||
channels.delete(id);
|
||||
}
|
||||
break;
|
||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
||||
case "get-camera-eye": result = readNativeRerunCameraEye(
|
||||
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
|
||||
); break;
|
||||
case "open": required().open(args[0]); break;
|
||||
case "close": required().close(args[0]); break;
|
||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import {useEffect, useRef, useState} from 'react';
|
||||
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
|
||||
import type {RecordedPointColorLoadState} from '../RerunViewport';
|
||||
|
||||
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
|
||||
|
||||
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
|
||||
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
|
||||
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
|
||||
sourceGeneration?: string;
|
||||
}) {
|
||||
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
|
||||
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
|
||||
const loadRef = useRef(onLoad); loadRef.current = onLoad;
|
||||
const failed = useRef(false);
|
||||
const [retry, setRetry] = useState(0);
|
||||
const key = settings ? pointDisplayKey(settings) : '';
|
||||
const percent = settings?.pointDecimationPercent ?? 0;
|
||||
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
|
||||
const matched = sameSource && applied?.key === key;
|
||||
useEffect(() => {
|
||||
// A new settings interaction retries a failed request, but accumulation or
|
||||
// size edits must not cancel an already-running preparation of the same key.
|
||||
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
|
||||
}, [settings]);
|
||||
useEffect(() => {
|
||||
if (!endpoint || !settings || !ready) return;
|
||||
if (percent === 0 || percent === 100) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
|
||||
return;
|
||||
}
|
||||
if (matched) {
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
|
||||
progress: 1, message: 'Прореженное облако готово.'});
|
||||
return;
|
||||
}
|
||||
const owner = ownerRef.current();
|
||||
if (!owner) return;
|
||||
const abort = new AbortController();
|
||||
failed.current = false;
|
||||
// Never append another generation at the same temporal entity path: an
|
||||
// accumulated range would otherwise draw older samples as well.
|
||||
const bank = crypto.randomUUID().replaceAll('-', '');
|
||||
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
|
||||
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
|
||||
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
|
||||
if (!abort.signal.aborted) owner.send(chunk);
|
||||
}, undefined, sourceGeneration).then(bytes => {
|
||||
if (abort.signal.aborted) return;
|
||||
setApplied({endpoint, revision, key, bank, sourceGeneration});
|
||||
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
|
||||
message: 'Прореженное облако готово.'});
|
||||
}).catch(() => {
|
||||
if (!abort.signal.aborted) {
|
||||
failed.current = true;
|
||||
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
|
||||
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
|
||||
}
|
||||
});
|
||||
return () => abort.abort();
|
||||
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
|
||||
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
|
||||
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
|
||||
hidePoints: percent === 100};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
|
||||
|
||||
export function useMissionPlanner() {
|
||||
const initializeRoute = useRef(true);
|
||||
const [sessions, setSessions] = useState<SessionOption[]>([]);
|
||||
const [cursor, setCursor] = useState<string | null>(null);
|
||||
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||
@@ -10,8 +9,6 @@ export function useMissionPlanner() {
|
||||
const [name, setName] = useState("");
|
||||
const [sessionId, setSessionId] = useState("");
|
||||
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||
const [start, setStart] = useState(0);
|
||||
const [end, setEnd] = useState(1);
|
||||
const [direction, setDirection] = useState<Direction>("forward");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||
@@ -19,12 +16,13 @@ export function useMissionPlanner() {
|
||||
const [catalogBusy, setCatalogBusy] = useState(true);
|
||||
const [loadVersion, setLoadVersion] = useState(0);
|
||||
const [sourceVersion, setSourceVersion] = useState(0);
|
||||
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
|
||||
const [check, setCheck] = useState<RouteCheck | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setCatalogBusy(true); setError(null);
|
||||
void Promise.all([
|
||||
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?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 }),
|
||||
]).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); })
|
||||
@@ -34,26 +32,29 @@ export function useMissionPlanner() {
|
||||
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setSourceError(null);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { 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; } } })
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
|
||||
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
|
||||
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
|
||||
return () => abort.abort();
|
||||
}, [sessionId, sourceVersion]);
|
||||
}, [sessionId, sourceVersion, pinnedGeneration]);
|
||||
|
||||
const currentSource = source?.session_id === sessionId ? source : null;
|
||||
// New executions always own the complete reference. No independent interval
|
||||
// state can survive a source change or collapse on same-source reselection.
|
||||
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
|
||||
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
|
||||
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
|
||||
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|
||||
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
|
||||
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
|
||||
const chooseSource = (id: string) => { 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 openDraft = async (id: string) => {
|
||||
setBusy(true); setError(null);
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
|
||||
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
|
||||
setStart(next.route.start_index); setEnd(next.route.end_index); setDirection(next.route.direction); setCheck(null);
|
||||
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
|
||||
setDirection(next.route.direction); setCheck(null);
|
||||
setSourceVersion(n => n + 1);
|
||||
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||
};
|
||||
@@ -63,7 +64,7 @@ export function useMissionPlanner() {
|
||||
try {
|
||||
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
|
||||
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
|
||||
generation: currentSource.generation, 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)]);
|
||||
return next;
|
||||
@@ -79,11 +80,11 @@ export function useMissionPlanner() {
|
||||
if (!cursor) return;
|
||||
setCatalogBusy(true);
|
||||
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);
|
||||
} 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,
|
||||
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,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 {
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
|
||||
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
|
||||
const [catalogBusy,setCatalogBusy]=useState(false);
|
||||
// Repeat recordings belong to the planner, including its own captured passes;
|
||||
// they are not restricted to the independent-reference catalog.
|
||||
useEffect(()=>{
|
||||
const abort=new AbortController();setCatalogBusy(true);
|
||||
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
|
||||
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
|
||||
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
|
||||
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
|
||||
return()=>abort.abort();
|
||||
},[]);
|
||||
const loadMore=async()=>{
|
||||
if(!cursor||catalogBusy)return;setCatalogBusy(true);
|
||||
try{
|
||||
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
|
||||
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
|
||||
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
|
||||
};
|
||||
useEffect(() => {
|
||||
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
|
||||
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
|
||||
@@ -35,5 +54,5 @@ export function useRegistrationTest(draft: Draft | null) {
|
||||
return data;
|
||||
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
|
||||
};
|
||||
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run };
|
||||
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type {SceneSettings} from '../../sceneSettings';
|
||||
|
||||
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
|
||||
export function pointDisplayKey(settings: PointDisplaySettings): string {
|
||||
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
|
||||
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
|
||||
}
|
||||
|
||||
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
|
||||
export async function streamRecordedPointDisplay(
|
||||
blueprintUrl: string, settings: PointDisplaySettings,
|
||||
identity: {applicationId: string; recordingId: string}, displayBank: string,
|
||||
signal: AbortSignal, send: (chunk: Uint8Array) => void,
|
||||
fetcher: typeof fetch = fetch,
|
||||
sourceGeneration?: string,
|
||||
): Promise<number> {
|
||||
const endpoint = new URL(blueprintUrl, window.location.origin);
|
||||
const percent = settings.pointDecimationPercent ?? 0;
|
||||
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|
||||
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|
||||
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|
||||
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
|
||||
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
|
||||
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
|
||||
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
|
||||
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
|
||||
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
|
||||
point_decimation_percent: percent, display_bank: displayBank,
|
||||
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
|
||||
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
|
||||
throw new Error('Не удалось подготовить прореженное облако');
|
||||
}
|
||||
const reader = response.body.getReader();
|
||||
let bytes = 0;
|
||||
let pending = new Uint8Array(0);
|
||||
let admitted = false;
|
||||
let complete = false;
|
||||
try {
|
||||
while (true) {
|
||||
const {done, value} = await reader.read();
|
||||
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
|
||||
if (done) break;
|
||||
bytes += value.byteLength;
|
||||
const combined = new Uint8Array(pending.length + value.length);
|
||||
combined.set(pending); combined.set(value, pending.length); pending = combined;
|
||||
if (!admitted) {
|
||||
if (pending.length < 4) continue;
|
||||
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
|
||||
pending = pending.slice(4); admitted = true;
|
||||
}
|
||||
while (pending.length >= 4 && !complete) {
|
||||
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
|
||||
if (length === 0) { pending = pending.slice(4); complete = true; break; }
|
||||
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
|
||||
if (pending.length < length + 4) break;
|
||||
const payload = pending.slice(4, length + 4);
|
||||
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
|
||||
send(payload); pending = pending.slice(length + 4);
|
||||
}
|
||||
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
|
||||
}
|
||||
if (!complete || pending.length) throw new Error('Поток облака не завершён');
|
||||
return bytes;
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export type ObservationSessionStatus =
|
||||
| "interrupted"
|
||||
| "failed";
|
||||
|
||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
||||
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
|
||||
|
||||
export interface ObservationLabInstance {
|
||||
labId: string;
|
||||
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
|
||||
seekable: true;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
mapGeneration?: string;
|
||||
playback: {
|
||||
speed: number;
|
||||
loop: boolean;
|
||||
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
|
||||
"seekable",
|
||||
"byte_length",
|
||||
"sha256",
|
||||
"map_generation",
|
||||
"playback",
|
||||
"media_sources",
|
||||
]);
|
||||
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
|
||||
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
||||
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
|
||||
}
|
||||
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
|
||||
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
|
||||
}
|
||||
const viewerSourceUrl = requireString(
|
||||
launch.viewer_source_url,
|
||||
"launch.viewer_source_url",
|
||||
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
|
||||
integer: true,
|
||||
}),
|
||||
sha256: launch.sha256,
|
||||
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
|
||||
playback: { speed, loop: launch.playback.loop },
|
||||
mediaSources,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
|
||||
|
||||
const schema = 'missioncore.session-display-profile/v1';
|
||||
const fields = {
|
||||
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
|
||||
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
|
||||
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
|
||||
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
|
||||
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
|
||||
} as const;
|
||||
|
||||
function endpoint(sessionId: string) {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
|
||||
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
|
||||
}
|
||||
|
||||
export function encodeSessionDisplay(settings: SceneSettings) {
|
||||
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
|
||||
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
|
||||
}
|
||||
|
||||
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
|
||||
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
|
||||
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
|
||||
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
|
||||
}
|
||||
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
|
||||
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
|
||||
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
|
||||
}
|
||||
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|
||||
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|
||||
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|
||||
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|
||||
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
|
||||
throw new Error('Некорректные настройки отображения');
|
||||
}
|
||||
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
|
||||
}
|
||||
|
||||
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
|
||||
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
|
||||
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
|
||||
const document = await response.json();
|
||||
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
|
||||
: decodeSessionDisplay(document, sessionId);
|
||||
}
|
||||
|
||||
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
|
||||
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
|
||||
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
|
||||
decodeSessionDisplay(await response.json(), sessionId);
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
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) {
|
||||
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("Параметры среза недоступны.");
|
||||
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 generation = url.searchParams.get("generation");
|
||||
url.search = "";
|
||||
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect }) });
|
||||
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
|
||||
comparison_generation: comparisonGeneration, representation }) });
|
||||
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
|
||||
|
||||
@@ -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 { usePlanningTest } from '../../core/missions/PlanningTestContext';
|
||||
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||
import { useRegistrationTest } from '../../core/missions/useRegistrationTest';
|
||||
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
|
||||
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
|
||||
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}) {
|
||||
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
|
||||
const t=useRegistrationTest(p.saved);
|
||||
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
|
||||
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
|
||||
const [view,setView]=useState<'cloud'|'route'>('cloud');
|
||||
const [starting,setStarting]=useState(false);
|
||||
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 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='';
|
||||
});
|
||||
const start=async()=>{
|
||||
@@ -47,13 +44,8 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
||||
try {
|
||||
const draft=p.saved&&!p.dirty?p.saved:await p.save();
|
||||
if(!draft)return;
|
||||
if(mode==='scanner') {
|
||||
const next=await live.begin(draft);
|
||||
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);}
|
||||
}
|
||||
const next=await live.begin(draft);
|
||||
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
|
||||
projects.refresh();
|
||||
} finally {setStarting(false);}
|
||||
};
|
||||
@@ -81,13 +73,13 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
||||
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
|
||||
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
|
||||
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
|
||||
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} 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>
|
||||
:project?<>
|
||||
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
|
||||
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
|
||||
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
|
||||
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись остаётся в разделе «Данные».</small></div>}
|
||||
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись прохода сохранена отдельно от результата.</small></div>}
|
||||
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
|
||||
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
|
||||
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
|
||||
@@ -96,7 +88,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
|
||||
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
|
||||
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
|
||||
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?.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>}
|
||||
|
||||
@@ -46,11 +46,11 @@ export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
|
||||
separatorLabel="Высота графика интервалов" className="session-overview__split"
|
||||
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
|
||||
primary={<GlassSurface className="session-overview__panel" radius="panel">
|
||||
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} /> : <><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>}
|
||||
</GlassSurface>}
|
||||
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>
|
||||
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
|
||||
</div>
|
||||
|
||||
@@ -554,7 +554,7 @@ export function SpatialWorkspace({
|
||||
) : null}
|
||||
|
||||
</>}
|
||||
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
|
||||
<ObservationTimeline
|
||||
active={presentedViewerStatus === "ready"}
|
||||
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
|
||||
@@ -572,6 +572,7 @@ export function SpatialWorkspace({
|
||||
onPlayingChange={playbackController?.setPlaying}
|
||||
onJumpToEnd={playbackController?.jumpToEnd}
|
||||
accumulationSeconds={accumulationSeconds}
|
||||
accumulationMaxSeconds={sceneSettings.accumulationMaxSeconds}
|
||||
onAccumulationChange={onAccumulationChange}
|
||||
onAccumulationCommit={onAccumulationCommit}
|
||||
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"';
|
||||
|
||||
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
|
||||
@@ -412,7 +421,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
|
||||
assert.doesNotMatch(laboratorySource, /fetchAdvancedLaboratoryResults/);
|
||||
});
|
||||
|
||||
test("recording preparation statuses share the viewer's left alignment", async () => {
|
||||
test("recording preparation statuses occupy the viewer's upper-right corner", async () => {
|
||||
const spatialStyles = await readFile(
|
||||
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
|
||||
"utf8",
|
||||
@@ -422,9 +431,10 @@ test("recording preparation statuses share the viewer's left alignment", async (
|
||||
spatialStyles.indexOf(".scene-operation-status {"),
|
||||
);
|
||||
|
||||
assert.match(statusStack, /left:\s*0\.85rem/);
|
||||
assert.match(statusStack, /justify-items:\s*start/);
|
||||
assert.doesNotMatch(statusStack, /right:/);
|
||||
assert.match(statusStack, /right:\s*0\.85rem/);
|
||||
assert.match(statusStack, /top:\s*0\.85rem/);
|
||||
assert.match(statusStack, /justify-items:\s*end/);
|
||||
assert.doesNotMatch(statusStack, /left:|bottom:/);
|
||||
});
|
||||
|
||||
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
|
||||
|
||||
@@ -150,14 +150,15 @@ test("follow toggles retain the current recorded camera journal", () => {
|
||||
assert.notEqual(afterReset, before);
|
||||
});
|
||||
|
||||
test("observation camera windows tile from the bottom-right above the live timeline", () => {
|
||||
test("observation camera windows tile from the bottom-left above the live timeline", () => {
|
||||
const bounds = { width: 1280, height: 720 };
|
||||
const left = initialObservationWindowRect(0, 2, bounds);
|
||||
const right = initialObservationWindowRect(1, 2, bounds);
|
||||
|
||||
assert.equal(left.y, right.y);
|
||||
assert.ok(left.x + left.width < right.x);
|
||||
assert.equal(right.x + right.width, bounds.width - 18);
|
||||
assert.equal(left.x, 18);
|
||||
assert.ok(right.x + right.width <= bounds.width - 18);
|
||||
assert.ok(right.y + right.height <= bounds.height - 64);
|
||||
});
|
||||
|
||||
@@ -323,10 +324,14 @@ test("recorded observation timeline rejects empty and non-finite ranges", () =>
|
||||
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
|
||||
assert.equal(normalizeAccumulationSeconds(-3), 0);
|
||||
assert.equal(normalizeAccumulationSeconds(12.6), 13);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 120);
|
||||
assert.equal(normalizeAccumulationSeconds(999), 999);
|
||||
assert.equal(normalizeAccumulationSeconds(1800), 1800);
|
||||
assert.equal(normalizeAccumulationSeconds(9999), 9999);
|
||||
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
|
||||
assert.equal(formatAccumulationDuration(0), "Кадр");
|
||||
assert.equal(formatAccumulationDuration(12), "12 с");
|
||||
assert.equal(formatAccumulationDuration(1800), "30 мин");
|
||||
assert.equal(formatAccumulationDuration(125), "2 мин 5 с");
|
||||
});
|
||||
|
||||
test("spatial timeline renders synchronized accumulation and playback controls", () => {
|
||||
@@ -606,6 +611,32 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
|
||||
);
|
||||
});
|
||||
|
||||
test("recorded tracking quantizes the native fractional-nanosecond cursor without accepting invalid times", async () => {
|
||||
const bodies = [];
|
||||
const request = currentTimeNs => fetchRecordedBlueprintRrd(
|
||||
"/api/v1/observation-sessions/session-1/blueprint.rrd",
|
||||
{accumulationSeconds: 10, showGrid: true, showPoints: true,
|
||||
showTrajectory: true, pointSize: 0.5, colorMode: "intensity",
|
||||
palette: "turbo", customColor: "#ffffff"},
|
||||
{applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001"},
|
||||
{origin: "http://127.0.0.1:8000", blueprintSessionId: "a".repeat(32),
|
||||
cameraEye: {position: [3, 4, 5], lookTarget: [1, 2, 0], eyeUp: [0, 0, 1]},
|
||||
eyeRelativeToTracking: true, currentTimeNs,
|
||||
fetcher: async (_input, init) => {
|
||||
bodies.push(JSON.parse(init.body));
|
||||
return new Response(new Uint8Array([0x52, 0x52, 0x46, 0x32]), {
|
||||
headers: {"Content-Type": "application/vnd.rerun.rrd"},
|
||||
});
|
||||
}},
|
||||
);
|
||||
await request(536_460_021_972.65625);
|
||||
assert.equal(bodies[0].current_time_ns, 536_460_021_973);
|
||||
for (const invalid of [-0.1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
|
||||
await assert.rejects(request(invalid), /Unsafe recorded blueprint request/);
|
||||
}
|
||||
assert.equal(bodies.length, 1);
|
||||
});
|
||||
|
||||
test("recorded point colors use one strict same-origin component overlay", async () => {
|
||||
const endpoint = resolveRecordedPointColorsUrl(
|
||||
"/api/v1/observation-sessions/session-1/recording.rrd",
|
||||
|
||||
@@ -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 { readFileSync } from "node:fs";
|
||||
import { after, before, test } from "node:test";
|
||||
import { createServer } from "vite";
|
||||
|
||||
let server, createRecordedRerunCameraJournal, initialEye;
|
||||
let server, readNativeRerunCameraEye;
|
||||
before(async () => {
|
||||
server = await createServer({ appType: "custom", logLevel: "silent",
|
||||
server: { middlewareMode: true } });
|
||||
const module = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts");
|
||||
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
|
||||
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
|
||||
({ readNativeRerunCameraEye } = await server.ssrLoadModule(
|
||||
"/src/components/rerun/recordedRerunCameraJournal.ts"));
|
||||
});
|
||||
after(async () => { await server?.close(); });
|
||||
|
||||
class FakeEvent {
|
||||
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
|
||||
}
|
||||
|
||||
class FakeTarget {
|
||||
listeners = new Map();
|
||||
emitted = [];
|
||||
addEventListener(type, listener) {
|
||||
const listeners = this.listeners.get(type) ?? [];
|
||||
listeners.push(listener); this.listeners.set(type, listeners);
|
||||
}
|
||||
removeEventListener(type, listener) {
|
||||
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
|
||||
}
|
||||
dispatchEvent(event) {
|
||||
this.emitted.push(event);
|
||||
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
|
||||
return true;
|
||||
test("camera snapshot copies exact native pose, without approximating input", () => {
|
||||
const native = { position: [287, -74, 8], lookTarget: [286, -72, 0], eyeUp: [0, 0, 1] };
|
||||
const eye = readNativeRerunCameraEye(native);
|
||||
assert.deepEqual(eye, native);
|
||||
native.position[0] = 999;
|
||||
assert.equal(eye.position[0], 287);
|
||||
assert.equal(readNativeRerunCameraEye(null), null);
|
||||
});
|
||||
|
||||
test("invalid native pose fails closed instead of substituting a guessed camera", () => {
|
||||
for (const value of [{}, "pose", { position: [NaN, 0, 1], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1] }]) {
|
||||
assert.throws(() => readNativeRerunCameraEye(value), /Invalid native Rerun camera/);
|
||||
}
|
||||
}
|
||||
|
||||
const radius = (eye) => Math.hypot(
|
||||
eye.position[0] - eye.lookTarget[0],
|
||||
eye.position[1] - eye.lookTarget[1],
|
||||
eye.position[2] - eye.lookTarget[2],
|
||||
);
|
||||
|
||||
test("recorded orbital eye tracks only 3D viewport navigation", () => {
|
||||
const scope = new FakeTarget();
|
||||
const timers = [];
|
||||
Object.assign(scope, {
|
||||
WheelEvent: FakeEvent,
|
||||
requestAnimationFrame(callback) { callback(); return 1; },
|
||||
setTimeout(callback) { timers.push(callback); return timers.length; },
|
||||
});
|
||||
const canvas = new FakeTarget();
|
||||
Object.assign(canvas, {
|
||||
clientHeight: 200,
|
||||
isConnected: true,
|
||||
getBoundingClientRect: () => ({
|
||||
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
|
||||
}),
|
||||
});
|
||||
const journal = createRecordedRerunCameraJournal(canvas, scope);
|
||||
journal.configure(initialEye, 0.46);
|
||||
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
|
||||
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
});
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
|
||||
assert.deepEqual(journal.current(), initialEye);
|
||||
|
||||
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
|
||||
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
|
||||
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
|
||||
const rotated = journal.current();
|
||||
assert.notDeepEqual(rotated.position, initialEye.position);
|
||||
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
|
||||
|
||||
journal.setMaxOrbitalRadius(40);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
|
||||
|
||||
journal.configure(rotated, 0.46);
|
||||
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
|
||||
}));
|
||||
const zoomed = journal.current();
|
||||
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
|
||||
|
||||
const snapshot = journal.current();
|
||||
journal.setSpatialViewportStart(0.8);
|
||||
scope.dispatchEvent(new FakeEvent("wheel", {
|
||||
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
|
||||
}));
|
||||
assert.deepEqual(journal.current(), snapshot);
|
||||
journal.dispose();
|
||||
});
|
||||
|
||||
test("iframe owner reads the native camera and installs no shadow input listeners", () => {
|
||||
const owner = readFileSync(new URL("../src/components/rerun/recordedRerunOwner.ts", import.meta.url), "utf8");
|
||||
const camera = readFileSync(new URL("../src/components/rerun/recordedRerunCameraJournal.ts", import.meta.url), "utf8");
|
||||
assert.match(owner, /get_camera_eye\?\.\(\)/);
|
||||
assert.doesNotMatch(owner + camera, /createRecordedRerunCameraJournal|addEventListener|Math\.exp/);
|
||||
});
|
||||
|
||||
test("display blueprint activation carries the current native eye, not the embedded preset", () => {
|
||||
const viewport = readFileSync(new URL("../src/components/RerunViewport.tsx", import.meta.url), "utf8");
|
||||
assert.match(viewport, /!enablingFollow \? active\.getCameraEye\?\.\(\) \?\? undefined : undefined/);
|
||||
assert.match(viewport, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,/);
|
||||
assert.match(viewport, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||
});
|
||||
|
||||
@@ -59,6 +59,17 @@ function bridge(native, mount = {}) {
|
||||
return parent;
|
||||
}
|
||||
|
||||
test("camera snapshot crosses the disposable realm as copied primitive data", () => {
|
||||
const eye = { position: [300, 7, 8], lookTarget: [298, 9, 0], eyeUp: [0, 0, 1] };
|
||||
const { facade, dispose } = bridge({ stop() {}, get_camera_eye: () => eye });
|
||||
const received = facade.get_camera_eye();
|
||||
assert.deepEqual(received, eye);
|
||||
assert.notEqual(received, eye);
|
||||
assert.notEqual(received.position, eye.position);
|
||||
dispose();
|
||||
assert.throws(() => facade.get_camera_eye(), /disposed/);
|
||||
});
|
||||
|
||||
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
|
||||
const f = fixture(t, { stopFails: true });
|
||||
const scope = createIsolatedRerunHost(f.host);
|
||||
|
||||
@@ -251,6 +251,10 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
|
||||
assert.match(source, /recordedPerceptionLayers\.costmap,/);
|
||||
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
|
||||
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
|
||||
// Ordinary archives need activation too, not only the LAB costmap path.
|
||||
assert.match(source, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,[\s\S]*?\n\s*true,\s*\);/);
|
||||
// Display changes must not trigger a full-file camera-bounds scan.
|
||||
assert.match(source, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
|
||||
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
|
||||
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
|
||||
assert.match(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -7,13 +8,41 @@ const root = resolve(import.meta.dirname, "..");
|
||||
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
|
||||
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
|
||||
|
||||
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
|
||||
test("Mission Core pins Rerun 0.36.3 with the bounded native navigation installer", () => {
|
||||
const application = readJson(resolve(root, "package.json"));
|
||||
const installed = readJson(resolve(packageRoot, "package.json"));
|
||||
|
||||
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
|
||||
assert.equal(installed.version, "0.36.3");
|
||||
assert.equal(application.scripts.postinstall, undefined);
|
||||
assert.equal(application.scripts.postinstall, "node scripts/install-rerun-navigation.mjs");
|
||||
assert.equal(application.scripts.prebuild, application.scripts.postinstall);
|
||||
});
|
||||
|
||||
test("native navigation artifacts and source patch match their provenance", () => {
|
||||
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
|
||||
const manifest = readJson(resolve(vendorRoot, "navigation-build.json"));
|
||||
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
|
||||
assert.equal(manifest.upstreamVersion, "0.36.3");
|
||||
assert.equal(manifest.upstreamCommit, "6ded109d33c549e98185f7c95fa8009d44e4adef");
|
||||
assert.equal(sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))), manifest.patchSha256);
|
||||
for (const [name, identity] of Object.entries(manifest.files)) {
|
||||
if (!identity.artifact) continue;
|
||||
assert.equal(sha(readFileSync(resolve(vendorRoot, identity.artifact))), identity.sha256, name);
|
||||
assert.equal(sha(readFileSync(resolve(packageRoot, name))), identity.sha256, name);
|
||||
}
|
||||
});
|
||||
|
||||
test("generated JS and native WASM share one ABI including the camera snapshot", () => {
|
||||
const glue = readFileSync(resolve(packageRoot, "re_viewer.js"), "utf8");
|
||||
const wasm = new WebAssembly.Module(readFileSync(resolve(packageRoot, "re_viewer_bg.wasm")));
|
||||
const names = new Set(WebAssembly.Module.exports(wasm).map(item => item.name));
|
||||
assert.ok(names.has("webhandle_nodedc_camera_eye"));
|
||||
for (const [, name] of glue.matchAll(/\bwasm\.([a-zA-Z_$][\w$]*)/g)) {
|
||||
assert.ok(names.has(name), `Missing WASM export: ${name}`);
|
||||
}
|
||||
assert.match(readFileSync(resolve(packageRoot, "index.js"), "utf8"), /this\.#handle\.nodedc_camera_eye\(\)/);
|
||||
assert.match(readFileSync(resolve(packageRoot, "index.d.ts"), "utf8"), /get_camera_eye\(\)/);
|
||||
assert.match(readFileSync(resolve(packageRoot, "re_viewer.d.ts"), "utf8"), /nodedc_camera_eye\(\)/);
|
||||
});
|
||||
|
||||
test("the active application never imports or installs the archived vendor fork", () => {
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before, after, test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
import {createElement} from 'react';
|
||||
import {renderToStaticMarkup} from 'react-dom/server';
|
||||
import {readFile} from 'node:fs/promises';
|
||||
import {runInNewContext} from 'node:vm';
|
||||
import ts from 'typescript';
|
||||
|
||||
let server, profile, points, defaults, Controls, Timeline;
|
||||
before(async () => {
|
||||
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
|
||||
profile = await server.ssrLoadModule('/src/core/observation/sessionDisplayProfile.ts');
|
||||
points = await server.ssrLoadModule('/src/core/observation/recordedPointDisplay.ts');
|
||||
defaults = (await server.ssrLoadModule('/src/sceneSettings.ts')).defaultSceneSettings;
|
||||
({SceneDisplayControls: Controls} = await server.ssrLoadModule('../../packages/spatial-ui/src/SceneDisplayControls.tsx'));
|
||||
({ObservationTimeline: Timeline} = await server.ssrLoadModule('/src/components/ObservationTimeline.tsx'));
|
||||
});
|
||||
after(async () => {await server?.close();});
|
||||
|
||||
const document = (settings, id='session-a') => ({schema_version: 'missioncore.session-display-profile/v1',
|
||||
session_id: id, scene_settings: profile.encodeSessionDisplay(settings)});
|
||||
|
||||
test('session profile round trips all display settings and fractional decimation', () => {
|
||||
const settings = {...defaults, accumulationMaxSeconds: 600, accumulationSeconds: 590, pointDecimationPercent: 49.5};
|
||||
assert.deepEqual(profile.decodeSessionDisplay(document(settings), 'session-a'), settings);
|
||||
assert.throws(() => profile.decodeSessionDisplay(document(settings), 'session-b'), /другой записи/);
|
||||
for (const percent of [-1, 100.1, NaN, Infinity]) {
|
||||
assert.throws(() => profile.decodeSessionDisplay(document({...settings, pointDecimationPercent: percent}), 'session-a'));
|
||||
}
|
||||
for (const percent of [0, 50, 100]) assert.equal(profile.decodeSessionDisplay(document({...settings,
|
||||
pointDecimationPercent: percent}), 'session-a').pointDecimationPercent, percent);
|
||||
assert.equal(defaults.accumulationMaxSeconds, 180);
|
||||
});
|
||||
|
||||
test('timeline uses per-session range instead of a fixed duration cap', () => {
|
||||
const html = renderToStaticMarkup(createElement(Timeline, {accumulationSeconds: 47,
|
||||
accumulationMaxSeconds: 600, onAccumulationChange: () => {}}));
|
||||
assert.match(html, /max="600"/);
|
||||
});
|
||||
|
||||
test('display exposes precise thinning only for saved playback', () => {
|
||||
const props = {displayDraft: defaults, stageDisplayPatch: () => {}, commitDisplayPatch: () => {}, flushDisplaySettings: () => {}};
|
||||
assert.match(renderToStaticMarkup(createElement(Controls, {...props, replayPresented: true})), /Прореживание облака/);
|
||||
assert.doesNotMatch(renderToStaticMarkup(createElement(Controls, props)), /Прореживание облака/);
|
||||
});
|
||||
|
||||
test('open Display previews 100%, 0% and accumulation; close alone persists the latest draft', async () => {
|
||||
// Execute the actual composition callbacks with deterministic timers. This
|
||||
// catches the former open-inspector early return, not just helper behavior.
|
||||
const source = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile('App.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const callbacks = new Map();
|
||||
const names = ['stageDisplayPatch', 'flushDisplaySettings', 'commitDisplaySettings'];
|
||||
let close;
|
||||
function visit(node) {
|
||||
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(ast))) {
|
||||
callbacks.set(node.name.getText(ast), node.initializer.getText(ast));
|
||||
}
|
||||
if (ts.isBinaryExpression(node) && node.left.getText(ast) === 'closeDisplayRef.current') close = node.getText(ast);
|
||||
ts.forEachChild(node, visit);
|
||||
}
|
||||
visit(ast);
|
||||
assert.equal(callbacks.size, names.length); assert.ok(close);
|
||||
const applied = [], saved = [], timers = new Map(); let timerId = 0;
|
||||
const context = {
|
||||
useCallback: fn => fn, displayWindowOpenRef: {current: true}, replayActiveRef: {current: true},
|
||||
displayDraftRef: {current: {...defaults, pointDecimationPercent: 86}}, setDisplayDraft() {},
|
||||
viewerSettingsCommitTimerRef: {current: null}, viewerSettingsQuietPeriodMs: 750,
|
||||
sceneSettingsCommitterRef: {current: {enqueue: value => applied.push(value)}}, closeDisplayRef: {current: null},
|
||||
sessionDisplayProfile: {edited() {}, save: value => saved.push(value)},
|
||||
window: {setTimeout: fn => {timers.set(++timerId, fn); return timerId;}, clearTimeout: id => timers.delete(id)},
|
||||
};
|
||||
const code = [...callbacks].map(([name, init]) => `const ${name} = ${init};`).join('\n') +
|
||||
`\n${close}; ({stageDisplayPatch, flushDisplaySettings, close: closeDisplayRef.current});`;
|
||||
const actions = runInNewContext(ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2022}}).outputText, context);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 100});
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 100);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 0});
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 0);
|
||||
actions.stageDisplayPatch({accumulationSeconds: 180});
|
||||
actions.flushDisplaySettings();
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 180);
|
||||
actions.stageDisplayPatch({accumulationSeconds: 10});
|
||||
actions.flushDisplaySettings();
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 10);
|
||||
actions.stageDisplayPatch({pointDecimationPercent: 49.5});
|
||||
assert.equal(timers.size, 1);
|
||||
const pending = [...timers.values()][0]; timers.clear(); pending();
|
||||
assert.equal(applied.at(-1).pointDecimationPercent, 49.5);
|
||||
assert.equal(applied.at(-1).accumulationSeconds, 10);
|
||||
assert.equal(saved.length, 0);
|
||||
actions.close();
|
||||
assert.equal(saved.length, 1);
|
||||
assert.equal(saved[0].pointDecimationPercent, 49.5);
|
||||
assert.equal(saved[0].accumulationSeconds, 10);
|
||||
});
|
||||
|
||||
test('display stream validates fragmented header and sends bounded chunks', async () => {
|
||||
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
|
||||
try {
|
||||
const wire = [78,80,68,49,8,0,0,0,82,82,70,50,1,2,3,4,0,0,0,0];
|
||||
const chunks = [new Uint8Array(wire.slice(0,1)), new Uint8Array(wire.slice(1,7)),
|
||||
new Uint8Array(wire.slice(7,10)), new Uint8Array(wire.slice(10))];
|
||||
const sent = []; let request;
|
||||
const fetcher = async (url, options) => {request = {url, ...options}; return new Response(new ReadableStream({
|
||||
start(controller) {chunks.forEach(chunk => controller.enqueue(chunk)); controller.close();},
|
||||
}), {headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}});};
|
||||
const total = await points.streamRecordedPointDisplay('/api/v1/observation-sessions/session-a/blueprint.rrd',
|
||||
{...defaults, pointDecimationPercent: 49.5}, {applicationId: 'app', recordingId: 'rec'}, 'a'.repeat(32),
|
||||
new AbortController().signal, chunk => sent.push(...chunk), fetcher, 'a'.repeat(64));
|
||||
assert.equal(total, 20); assert.deepEqual(sent, [82,82,70,50,1,2,3,4]);
|
||||
assert.equal(JSON.parse(request.body).point_decimation_percent, 49.5);
|
||||
assert.equal(JSON.parse(request.body).source_generation, 'a'.repeat(64));
|
||||
assert.match(request.url, /point-display\.rrd$/);
|
||||
} finally {globalThis.window = previous;}
|
||||
});
|
||||
|
||||
test('invalid, cross-origin and canceled streams never become active', async () => {
|
||||
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
|
||||
try {
|
||||
const call = (url, signal, fetcher) => points.streamRecordedPointDisplay(url, {...defaults, pointDecimationPercent: 50},
|
||||
{applicationId: 'app', recordingId: 'rec'}, 'b'.repeat(32), signal, () => assert.fail('must not publish'), fetcher);
|
||||
const url = '/api/v1/observation-sessions/session-a/blueprint.rrd';
|
||||
await assert.rejects(call('https://elsewhere.test'+url, new AbortController().signal, () => assert.fail('must not fetch')));
|
||||
await assert.rejects(call(url, new AbortController().signal, async () => new Response('bad!', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Некорректный поток/);
|
||||
await assert.rejects(call(url, new AbortController().signal, async () => new Response('NPD1', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /не завершён/);
|
||||
const abort = new AbortController(); abort.abort();
|
||||
await assert.rejects(call(url, abort.signal, async () => new Response('RRF2', {
|
||||
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Aborted/);
|
||||
} finally {globalThis.window = previous;}
|
||||
});
|
||||
@@ -0,0 +1,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(); });
|
||||
|
||||
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'),
|
||||
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
|
||||
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);
|
||||
});
|
||||
|
||||
test('normal and expanded scenes retain the renderer without mouse-navigation copy', async () => {
|
||||
for (const focused of [false, true]) {
|
||||
const markup = render(focused);
|
||||
assert.match(markup, /data-testid="renderer">RENDERER/);
|
||||
assert.doesNotMatch(markup, /scene-navigation-hint|Навигация по 3D-сцене|ЛКМ|ПКМ|Колесо/);
|
||||
}
|
||||
const source = await readFile(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url), 'utf8');
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/observation.css', import.meta.url), 'utf8');
|
||||
assert.doesNotMatch(source, /navigationReady|scene-navigation-hint/);
|
||||
assert.doesNotMatch(css, /scene-navigation-hint/);
|
||||
});
|
||||
|
||||
test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => {
|
||||
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
|
||||
const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
|
||||
@@ -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.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:
|
||||
|
||||
- **Data → Sessions and records** contains only original physical captures.
|
||||
- **Data → Sessions and records** contains independent original physical captures.
|
||||
Examples: `RAVNOVES00`, `TEST007`, `TEST009`. A source record is immutable
|
||||
evidence received from a device or recording adapter.
|
||||
evidence received from a device or recording adapter. Per the 2026-09-21
|
||||
owner decision, physical passes acquired by the planner are shown with their
|
||||
studies in **LAB → Planner**, not in Data or the reference selector.
|
||||
- **Test contour → Laboratory contours** contains derived experimental runs.
|
||||
Examples: LAB E24, E25, E26, E28 and E29. A LAB run references an original
|
||||
record and never becomes another original capture.
|
||||
@@ -16,7 +18,10 @@ Mission Core has two different catalogs over related evidence:
|
||||
The backend may keep both entities in one durable SQLite catalog, but every
|
||||
consumer must request an explicit catalog scope:
|
||||
|
||||
- `scope=source` for original records;
|
||||
- `scope=standalone` for Data and planner reference choices: original records
|
||||
excluding explicit planner-acquisition bindings;
|
||||
- `scope=source` for all original records, including planner passes needed for
|
||||
recorded comparisons and scientific inspection;
|
||||
- `scope=laboratory` for LAB projections;
|
||||
- `scope=all` only for internal joins that must resolve both a derivative and
|
||||
its source.
|
||||
@@ -24,6 +29,12 @@ consumer must request an explicit catalog scope:
|
||||
Deleting, renaming or moving source payloads to make the UI look clean is
|
||||
forbidden. Product separation is expressed by typed projections.
|
||||
|
||||
A planner pass remains a physical source, not a synthetic LAB projection.
|
||||
Classification uses exact run/session acquisition bindings, never names or
|
||||
registration success. Hiding it from Data does not remove its raw evidence or
|
||||
its historical planning report. See
|
||||
[the full-reference/catalog audit](audits/2026-09-21-whole-reference-and-capture-catalogs.md).
|
||||
|
||||
## Current reference-source policy
|
||||
|
||||
RAVNOVES00 is the sole active physical reference source for the current
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
Date: 2026-08-30
|
||||
Status: accepted; RAVNOVES004TREE is the first migrated full-route LAB
|
||||
|
||||
Navigation amendment, 2026-09-21: [ADR 0052](0052-native-rerun-grid-navigation.md)
|
||||
records the owner-authorized, bounded native-camera patch. The historical
|
||||
decision below remains intact; single renderer/clock/data ownership still applies.
|
||||
|
||||
Implementation audit, 2026-09-05: see the
|
||||
[complete customization inventory](../audits/2026-09-05-rerun-customization-inventory.md)
|
||||
and [upstream provenance evidence](../audits/2026-09-05-rerun-upstream-evidence.json).
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# ADR 0052: Native Rerun grid navigation
|
||||
|
||||
Date: 2026-09-21
|
||||
Status: implemented and installed; checks and manual acceptance bounds are in the navigation audit
|
||||
|
||||
## Decision and authority
|
||||
|
||||
The owner accepted the restored recorded cloud quality, then explicitly asked
|
||||
to finish navigation: pan the pivot on the grid, orbit around that fixed pivot,
|
||||
and zoom independently of the route extent. OPS is updated only after completion.
|
||||
|
||||
This is a bounded amendment to ADR 0045's **unmodified web viewer** requirement.
|
||||
Rerun SDK, recording format, data, timeline, native renderer and single-viewer
|
||||
ownership remain unchanged. The web viewer stays on upstream **0.36.3**, with
|
||||
one reproducible, hash-checked source patch. The archived 0.34.1 fork is not
|
||||
reactivated. No second camera/renderer, pointer injection or HTTP request per
|
||||
gesture is introduced.
|
||||
|
||||
## Camera contract
|
||||
|
||||
- The native orbital pivot lies on the native `LineGrid3D` plane (XY in the
|
||||
product's world frame). Pan translates the whole camera rig along that plane;
|
||||
orbital rotation leaves the pivot fixed. Scene changes cannot choose a new
|
||||
pivot after operator navigation. The guide grid is not a measured terrain model.
|
||||
- Initial fallback height is projected before rendering, not at the start of
|
||||
the first rotation. Active interpolation stops on manual navigation.
|
||||
- Zoom changes distance to the pivot. The scene-diagonal zoom-out cap is removed.
|
||||
Upstream's 0.02 m collision-with-pivot guard and a finite 1e17 arithmetic guard
|
||||
remain; these are not route-distance limits. Passing through the pivot would
|
||||
reverse orbital direction, so it is deliberately not implemented.
|
||||
- Explicit tracking remains distinct from fixed-pivot inspection. While following,
|
||||
Rerun may move the target with the tracked entity. First-person movement keeps
|
||||
upstream behavior. Reset and plan/3D switches remain explicit preset actions.
|
||||
- A read-only native snapshot supplies the last rendered eye to the existing
|
||||
iframe facade. The approximate DOM-input camera journal is removed. Snapshots
|
||||
cross the disposable iframe as copied primitive JSON only; no native handle or
|
||||
listener escapes its lifetime. With no rendered 3D frame, the snapshot is null.
|
||||
- Display-only blueprint activation carries that actual native eye. Omitting
|
||||
eye fields resurrects the incoming store's startup camera, as reproduced in
|
||||
browser QA; stable view identity alone is insufficient. This is one snapshot
|
||||
per settings update, not a second input controller or HTTP per gesture.
|
||||
- Mission Core admits one active spatial pane per iframe. The snapshot selects
|
||||
the last rendered spatial state of the active recording, ignoring retained
|
||||
states from earlier views. Multiple simultaneous 3D panes would require an
|
||||
explicit view-id argument before that product composition is admitted.
|
||||
|
||||
## Build, upgrade and rollback
|
||||
|
||||
`vendor/rerun-web-viewer-0.36.3/NODEDC_NAVIGATION.patch` is applied to the pinned
|
||||
upstream commit. `scripts/build-rerun-navigation.sh` runs in a bounded temporary
|
||||
Worker006 container, not on the 18 GB operator Mac. It tests native camera math,
|
||||
uses Rerun's own release/WebAssembly builder and matching JS transformation,
|
||||
and emits the paired runtime and declarations. No scanner data enters the build.
|
||||
|
||||
`navigation-build.json` binds upstream, source patch, compiler image and artifact
|
||||
hashes. The installer validates every input before modifying any package file,
|
||||
and rejects an unfamiliar SDK version or wrapper. Fresh installs and production
|
||||
builds use that same installer once the candidate is accepted.
|
||||
|
||||
An upgrade requires rebasing this patch, native tests, JS/WASM ABI checks,
|
||||
frontend contracts and real-browser navigation/regression QA. Do not copy the
|
||||
old WASM into a newer SDK. To roll back, disable the installer hooks and restore
|
||||
the exact official 0.36.3 package; the facade treats a missing native snapshot as
|
||||
unavailable, never fabricates an operator eye. Raw/corrected recordings do not
|
||||
need to be regenerated.
|
||||
|
||||
## Acceptance
|
||||
|
||||
Required: plane/pan/orbit/zoom invariants, different grid plane, top-down and
|
||||
first-person regression; exact native snapshot and realm cleanup; normal and
|
||||
expanded product views, Escape, explicit reset, layers/point-size updates and
|
||||
follow transitions. A successful compile alone is not product acceptance.
|
||||
|
||||
The accepted full-fidelity cloud and 30-minute accumulation are outside this
|
||||
navigation patch and must not be reduced to make testing cheaper.
|
||||
@@ -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 column = index % 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.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
||||
const rowStart = Math.min(WINDOW_INSET, Math.max(0, bounds.width - width));
|
||||
|
||||
return {
|
||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
||||
import { MAX_ACCUMULATION_SECONDS } from "./sceneSettings";
|
||||
|
||||
type ObservationTimelineMode = "live-only" | "buffered" | "recorded";
|
||||
|
||||
@@ -18,6 +19,7 @@ export function ObservationTimeline({
|
||||
playbackRate,
|
||||
onPlaybackRateChange,
|
||||
accumulationSeconds,
|
||||
accumulationMaxSeconds = MAX_ACCUMULATION_SECONDS,
|
||||
onAccumulationChange,
|
||||
onAccumulationCommit,
|
||||
className = "",
|
||||
@@ -37,6 +39,7 @@ export function ObservationTimeline({
|
||||
playbackRate?: number;
|
||||
onPlaybackRateChange?: (rate: number) => void;
|
||||
accumulationSeconds?: number;
|
||||
accumulationMaxSeconds?: number;
|
||||
onAccumulationChange?: (value: number) => void;
|
||||
onAccumulationCommit?: () => void;
|
||||
className?: string;
|
||||
@@ -75,7 +78,7 @@ export function ObservationTimeline({
|
||||
className="observation-timeline__track"
|
||||
type="range"
|
||||
min={0}
|
||||
max={120}
|
||||
max={Math.max(1, accumulationMaxSeconds, accumulationValue)}
|
||||
step={1}
|
||||
value={accumulationValue}
|
||||
aria-label="Окно накопления облака точек"
|
||||
@@ -165,12 +168,15 @@ export function ObservationTimeline({
|
||||
|
||||
export function normalizeAccumulationSeconds(value: number): number {
|
||||
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 {
|
||||
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(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {Checker,ColorField,ControlRow,Inspector,RangeControl,Select} from '@nodedc/ui-react';
|
||||
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 }> = [
|
||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
||||
@@ -28,7 +30,7 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
{
|
||||
id: "points",
|
||||
label: "Облако точек",
|
||||
description: "Размер и способ окрашивания",
|
||||
description: "Размер, плотность и окрашивание",
|
||||
content: (
|
||||
<div className="inspector-control-stack">
|
||||
<div
|
||||
@@ -47,6 +49,17 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
||||
/>
|
||||
</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}>
|
||||
<ControlRow label="Атрибут цвета">
|
||||
<Select
|
||||
@@ -85,12 +98,6 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
</ControlRow>
|
||||
</div>
|
||||
) : null}
|
||||
{replayPresented ? (
|
||||
<p className="scene-window-note">
|
||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -100,6 +107,18 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
description: "История облака и траектория",
|
||||
content: (
|
||||
<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
|
||||
className="scene-settings-commit-field"
|
||||
onPointerUp={flushDisplaySettings}
|
||||
@@ -110,10 +129,12 @@ export function SceneDisplayControls({displayDraft,stageDisplayPatch,commitDispl
|
||||
label="Окно накопления"
|
||||
value={displayDraft.accumulationSeconds}
|
||||
min={0}
|
||||
max={120}
|
||||
max={displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS}
|
||||
exactValueBounds={{min: 0}}
|
||||
step={1}
|
||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
||||
formatValue={formatAccumulationDuration}
|
||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds,
|
||||
accumulationMaxSeconds: Math.max(accumulationSeconds, displayDraft.accumulationMaxSeconds ?? MAX_ACCUMULATION_SECONDS) })}
|
||||
/>
|
||||
</div>
|
||||
<div className="nodedc-field">
|
||||
|
||||
@@ -3,13 +3,11 @@ import {StatusBadge} from '@nodedc/ui-react';
|
||||
|
||||
/** The existing scene composition. Hosts supply transport/renderers and authority. */
|
||||
export function SpatialScene({viewportRef, focused, primaryFocused, mediaMaximized, toolbar,
|
||||
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer,
|
||||
navigationReady=false}: {
|
||||
renderer, deviceControls, sourceControls, status, metrics, timeline, media, overlays, footer}: {
|
||||
viewportRef: RefObject<HTMLDivElement|null>; focused?:boolean; primaryFocused?:boolean;
|
||||
mediaMaximized?:boolean; toolbar:ReactNode; renderer:ReactNode; deviceControls?:ReactNode;
|
||||
sourceControls?:ReactNode; status:{label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||
metrics:ReactNode; timeline?:ReactNode; media?:ReactNode; overlays?:ReactNode; footer?:ReactNode;
|
||||
navigationReady?:boolean;
|
||||
}) {
|
||||
const detailsHidden = primaryFocused || mediaMaximized;
|
||||
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>
|
||||
{overlays}
|
||||
{navigationReady&&!mediaMaximized&&<div className="scene-navigation-hint" aria-label="Навигация по 3D-сцене"><span>Колесо · зум к курсору</span><span>WASD · свободный проход</span></div>}
|
||||
{timeline}{media}
|
||||
</div>
|
||||
{footer}
|
||||
|
||||
@@ -32,25 +32,6 @@
|
||||
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 {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -2,9 +2,14 @@ export type SceneProjection = "3d" | "2d" | "map";
|
||||
export type PointColorMode = "intensity" | "height" | "distance" | "rgb" | "class";
|
||||
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 {
|
||||
projection: SceneProjection;
|
||||
pointSize: number;
|
||||
pointDecimationPercent?: number;
|
||||
accumulationMaxSeconds?: number;
|
||||
colorMode: PointColorMode;
|
||||
palette: PointPalette;
|
||||
customColor: string;
|
||||
@@ -19,6 +24,8 @@ export interface SceneSettings {
|
||||
export const defaultSceneSettings: SceneSettings = {
|
||||
projection: "3d",
|
||||
pointSize: 2.5,
|
||||
pointDecimationPercent: 0,
|
||||
accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS,
|
||||
colorMode: "intensity",
|
||||
palette: "turbo",
|
||||
customColor: "#35d7c1",
|
||||
|
||||
@@ -367,16 +367,19 @@
|
||||
.scene-operation-status-stack {
|
||||
position: absolute;
|
||||
z-index: 11;
|
||||
left: 0.85rem;
|
||||
bottom: 7.15rem;
|
||||
right: 0.85rem;
|
||||
top: 0.85rem;
|
||||
min-height: 2.75rem;
|
||||
max-width: calc(100% - 8rem);
|
||||
display: grid;
|
||||
justify-items: start;
|
||||
align-content: center;
|
||||
justify-items: end;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.scene-operation-status {
|
||||
display: inline-flex;
|
||||
max-width: min(24rem, calc(100% - 1.7rem));
|
||||
max-width: 100%;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid rgb(255 255 255 / 0.07);
|
||||
@@ -388,7 +391,7 @@
|
||||
font-size: 0.52rem;
|
||||
font-weight: 680;
|
||||
line-height: 1;
|
||||
white-space: nowrap;
|
||||
white-space: normal;
|
||||
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&&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"/>}
|
||||
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>}
|
||||
|
||||
@@ -36,6 +36,10 @@ node-device-media = ["aiortc==1.14.0"]
|
||||
localization = [
|
||||
"small-gicp==1.0.1",
|
||||
]
|
||||
map-correction = [
|
||||
"scipy==1.16.2",
|
||||
"small-gicp==1.0.1",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
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 (
|
||||
RecordedPointColorOverlayStore,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.recorded_point_display import render_point_display
|
||||
from k1link.device_plugins.xgrids_k1.rrd_export import (
|
||||
RrdExportCancelled,
|
||||
RrdExportError,
|
||||
@@ -74,6 +75,7 @@ def build_xgrids_k1_observation(repository_root: Path, live_planning_source=None
|
||||
),
|
||||
recording_exporter=_export_recording,
|
||||
point_color_renderer=point_colors.render,
|
||||
point_display_renderer=render_point_display,
|
||||
overview_exporter=export_session_overview,
|
||||
planning_exporter=export_planning_source,
|
||||
submap_extractor=extract_submap,
|
||||
@@ -267,6 +269,8 @@ def _export_recording(
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: object | None = None,
|
||||
) -> dict[str, object]:
|
||||
from k1link.reconstruction.recorded_geometry import RecordedMapGeometry
|
||||
|
||||
try:
|
||||
return dict(
|
||||
export_k1mqtt_to_rrd(
|
||||
@@ -280,9 +284,14 @@ def _export_recording(
|
||||
),
|
||||
cancel_event=cancel_event,
|
||||
activity_callback=activity_callback if callable(activity_callback) else None,
|
||||
map_geometry=RecordedMapGeometry.optional(artifacts),
|
||||
)
|
||||
)
|
||||
except RrdExportCancelled as exc:
|
||||
raise PluginRecordingExportCancelled("K1 recording export was cancelled") from exc
|
||||
except RrdExportError as 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 os
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from collections import OrderedDict
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
@@ -121,6 +122,16 @@ class RecordedPointColorOverlayStore:
|
||||
capture_clock,
|
||||
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(
|
||||
color_mode=color_mode,
|
||||
palette=palette,
|
||||
@@ -129,11 +140,7 @@ class RecordedPointColorOverlayStore:
|
||||
settings_key = (
|
||||
color_mode,
|
||||
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)
|
||||
|
||||
@@ -156,6 +163,7 @@ class RecordedPointColorOverlayStore:
|
||||
metadata,
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
geometry,
|
||||
)
|
||||
payload = _render_color_overlay(
|
||||
index.frames,
|
||||
@@ -173,6 +181,7 @@ class RecordedPointColorOverlayStore:
|
||||
metadata: Path | None,
|
||||
capture_clock: Path | None,
|
||||
capture_clock_origin: Path | None,
|
||||
geometry=None,
|
||||
) -> _PointColorIndex:
|
||||
with self._lock:
|
||||
cached = self._indexes.get(source_identity)
|
||||
@@ -187,6 +196,7 @@ class RecordedPointColorOverlayStore:
|
||||
capture_clock,
|
||||
capture_clock_origin,
|
||||
source_identity=source_identity,
|
||||
geometry=geometry,
|
||||
)
|
||||
if index.byte_length > self._index_cache_bytes:
|
||||
return index
|
||||
@@ -222,7 +232,17 @@ def _build_index(
|
||||
capture_clock_origin: Path | None,
|
||||
*,
|
||||
source_identity: tuple[object, ...],
|
||||
geometry=None,
|
||||
) -> _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:
|
||||
envelope = None if capture_clock is None else read_capture_clock_envelope(capture_clock)
|
||||
origin = (
|
||||
@@ -232,9 +252,13 @@ def _build_index(
|
||||
)
|
||||
except CaptureFormatError as exc:
|
||||
raise RecordedPointColorError("native point-color clock is invalid") from exc
|
||||
if envelope is not None 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
|
||||
if (
|
||||
envelope is not None
|
||||
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")
|
||||
session_origin_ns = (
|
||||
@@ -245,11 +269,11 @@ def _build_index(
|
||||
else None
|
||||
)
|
||||
|
||||
frames: list[_PointColorFrame] = []
|
||||
total_bytes = 0
|
||||
frame_count = 0
|
||||
point_frame_number = 0
|
||||
previous_sequence = 0
|
||||
previous_monotonic_ns: int | None = None
|
||||
first_raw_receipt_s = None
|
||||
try:
|
||||
source_size = source.stat().st_size
|
||||
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")
|
||||
previous_sequence = sequence
|
||||
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:
|
||||
session_origin_ns = monotonic_ns
|
||||
topic = record.get("topic")
|
||||
@@ -333,6 +359,12 @@ def _build_index(
|
||||
if decoded.colors_rgb is None
|
||||
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,
|
||||
@@ -346,17 +378,12 @@ def _build_index(
|
||||
intensities=intensities.copy(),
|
||||
rgb=None if rgb is None else rgb.copy(),
|
||||
)
|
||||
frames.append(frame)
|
||||
total_bytes += frame.byte_length
|
||||
frame_count += 1
|
||||
yield frame
|
||||
except (OSError, json.JSONDecodeError, UnicodeError) as 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")
|
||||
return _PointColorIndex(
|
||||
source_identity=source_identity,
|
||||
frames=tuple(frames),
|
||||
byte_length=total_bytes,
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict
|
||||
from uuid import UUID, uuid4
|
||||
@@ -52,9 +52,6 @@ APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
CAPTURE_TIMELINE = "capture_time"
|
||||
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"
|
||||
|
||||
# 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,
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
map_geometry=None,
|
||||
) -> 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
|
||||
recording-local duration timeline whose zero is the durable capture-clock
|
||||
origin for v2 recordings (or the first raw message for legacy captures).
|
||||
It never traverses the bounded live-preview queue. Point-cloud frames and
|
||||
very dense point batches are deterministically sampled for interactive
|
||||
rendering while counters, poses, capture boundaries and the native capture
|
||||
remain complete. AI jobs always read the complete native capture.
|
||||
It never traverses the bounded live-preview queue or samples away points
|
||||
and frames. Counters, poses and capture boundaries remain complete, and
|
||||
an admitted corrected map supplies its exact per-frame positions. AI jobs
|
||||
still read the complete native capture, independently of this projection.
|
||||
|
||||
The destination is replaced only after the temporary RRD has been closed,
|
||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||
@@ -236,6 +234,8 @@ def export_k1mqtt_to_rrd(
|
||||
capture_clock,
|
||||
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")
|
||||
settings = RerunSceneSettings()
|
||||
blueprint = _recorded_blueprint(settings)
|
||||
@@ -264,6 +264,7 @@ def export_k1mqtt_to_rrd(
|
||||
previous_monotonic_ns: int | None = None
|
||||
last_source_time_ns: int | None = None
|
||||
last_source_capture_ns: int | None = None
|
||||
first_raw_receipt_s: float | None = None
|
||||
|
||||
try:
|
||||
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"
|
||||
)
|
||||
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:
|
||||
raise RrdExportError(
|
||||
"session duration exceeds the exact JavaScript nanosecond range"
|
||||
@@ -345,9 +349,19 @@ def export_k1mqtt_to_rrd(
|
||||
if isinstance(decoded, DecodedPointCloudView):
|
||||
counters.point_frames += 1
|
||||
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):
|
||||
_log_points(recording, decoded, settings)
|
||||
_log_points(recording, decoded, settings, positions=positions)
|
||||
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 = (
|
||||
float(decoded.position_xyz[0]),
|
||||
float(decoded.position_xyz[1]),
|
||||
@@ -359,6 +373,8 @@ def export_k1mqtt_to_rrd(
|
||||
else:
|
||||
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:
|
||||
raise RrdExportError("native capture contains no messages")
|
||||
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.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_origin.started_at_epoch_ns)
|
||||
if capture_clock_origin is not None
|
||||
else "-",
|
||||
str(capture_clock_origin.started_monotonic_ns)
|
||||
if capture_clock_origin is not None
|
||||
else "-",
|
||||
str(capture_clock_origin.started_at_epoch_ns) if capture_clock_origin is not None else "-",
|
||||
str(capture_clock_origin.started_monotonic_ns) if capture_clock_origin is not None else "-",
|
||||
):
|
||||
identity.update(value.encode("ascii"))
|
||||
identity.update(b"\0")
|
||||
@@ -814,8 +826,11 @@ def _log_points(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPointCloudView,
|
||||
settings: RerunSceneSettings,
|
||||
*,
|
||||
positions: np.ndarray | 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:
|
||||
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
|
||||
else:
|
||||
@@ -841,28 +856,18 @@ def _recorded_view_points(
|
||||
intensities: np.ndarray,
|
||||
rgb: np.ndarray | None,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
||||
# The native K1 capture remains the complete source of record and all AI
|
||||
# jobs read that source directly. Rerun is the interactive operator
|
||||
# projection: bound the temporal frame rate, but preserve complete normal
|
||||
# K1 scans. The AI composition intentionally uses one latest point frame
|
||||
# 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],
|
||||
)
|
||||
# An archive is a faithful projection, not a lossy preview. Keep attributes
|
||||
# aligned and use the visible time window to control displayed history.
|
||||
# Resource admission must fail explicitly rather than silently drop points.
|
||||
return positions, intensities, rgb
|
||||
|
||||
|
||||
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:
|
||||
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(
|
||||
|
||||
@@ -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):
|
||||
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())
|
||||
body = {'schema_version': 'missioncore.mission-draft/v1', 'name': request.name.strip(),
|
||||
'vehicle_id': None, 'status': 'draft', 'zone': {key: source[key] for key in
|
||||
|
||||
@@ -195,6 +195,7 @@ def acquire_entry(
|
||||
fitter=None,
|
||||
clock=time.monotonic,
|
||||
policy=ENTRY_POLICY,
|
||||
progress=None,
|
||||
):
|
||||
reference, query, initial = cloud(reference), cloud(query), rigid(initial)
|
||||
started = clock()
|
||||
@@ -207,7 +208,9 @@ def acquire_entry(
|
||||
|
||||
attempts = []
|
||||
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
|
||||
matrix = seed.pop("matrix")
|
||||
result = fitter(reference, query, matrix)
|
||||
@@ -217,7 +220,7 @@ def acquire_entry(
|
||||
attempts,
|
||||
initial,
|
||||
query_entry,
|
||||
complete=elapsed <= policy["deadline_s"],
|
||||
complete=policy["deadline_s"] is None or elapsed <= policy["deadline_s"],
|
||||
policy=policy,
|
||||
)
|
||||
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
|
||||
|
||||
LIVE_ROUTE_POLICY = dict(
|
||||
version="selected-live-route/v1",
|
||||
version="selected-live-route/v2",
|
||||
minimum_m=3.0,
|
||||
maximum_m=None,
|
||||
maximum_seconds=None,
|
||||
@@ -16,6 +16,8 @@ def live_route_limits(length_m):
|
||||
raise ValueError("Для привязки выберите участок длиной не менее 3 м.")
|
||||
return dict(
|
||||
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,
|
||||
)
|
||||
|
||||
@@ -37,10 +37,15 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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.root = drafts.database.parent / "live-tests"
|
||||
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.run = None
|
||||
self.sample = None
|
||||
@@ -177,6 +182,9 @@ class PlanningLiveTests:
|
||||
|
||||
def update(self, **values):
|
||||
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.revision += 1
|
||||
self.persist()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""Staged stationary localisation before the normal fresh-data tracking gate.
|
||||
|
||||
A known start is the reliable laboratory path, so it first receives a dense
|
||||
multi-start fit. Only its honest rejection permits retrieval over the entire
|
||||
selected route. That preserves a repeatable start while retaining an auditable
|
||||
recovery path for a restarted rover that must look for *where it is*.
|
||||
A known start first receives a dense multi-start fit, but it cannot shortcut
|
||||
comparison with the entire selected route. A finite queue, not elapsed wall
|
||||
time, defines completeness. The process owner handles cancellation and stalls.
|
||||
|
||||
Neither stage grants tracking or vehicle authority: both only produce a
|
||||
provisional hypothesis for the separate, disjoint fresh-data gate.
|
||||
@@ -13,7 +12,6 @@ from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from itertools import product
|
||||
|
||||
@@ -21,15 +19,17 @@ import numpy as np
|
||||
|
||||
from .entry_acquisition import acquire_entry
|
||||
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 PreparedReference, angle_deg, cloud, rigid, transform
|
||||
from .stationary_entry import STATIONARY_POLICY
|
||||
|
||||
ROUTE_RELOCALIZATION_POLICY = dict(
|
||||
version="route-relocalization/v6",
|
||||
version="route-relocalization/v7",
|
||||
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.
|
||||
query_radius_m=TRACKING_INPUT["radius_m"],
|
||||
anchor_spacing_m=5.0,
|
||||
@@ -54,13 +54,9 @@ ROUTE_RELOCALIZATION_POLICY = dict(
|
||||
cluster_rotation_deg=8.0,
|
||||
ambiguity_overlap_margin=0.05,
|
||||
ambiguity_rmse_margin_m=0.03,
|
||||
# Keep the stationary prefix younger than the bootstrap's 40-s source-age
|
||||
# fence. A late exhaustive calculation is an explicit incomplete search,
|
||||
# never a stale provisional position.
|
||||
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.
|
||||
# No overall search timer: every admitted place must be compared. A child
|
||||
# that makes NO progress is separately stopped, never called a map mismatch.
|
||||
worker_stall_s=60.0,
|
||||
registration_policy={
|
||||
**TRACKING_POLICY,
|
||||
"version": "route-relocalization-gicp/v1",
|
||||
@@ -257,7 +253,8 @@ class RouteCandidate:
|
||||
|
||||
|
||||
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."""
|
||||
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)
|
||||
ranked = []
|
||||
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(
|
||||
grid,
|
||||
position,
|
||||
@@ -454,6 +454,8 @@ def relocalize_route(
|
||||
*,
|
||||
clock=time.monotonic,
|
||||
policy=ROUTE_RELOCALIZATION_POLICY,
|
||||
on_progress=None,
|
||||
additional_hypotheses=(),
|
||||
):
|
||||
"""Run complete candidate retrieval and qualification against a selected route."""
|
||||
started = clock()
|
||||
@@ -461,7 +463,7 @@ def relocalize_route(
|
||||
query_entry = np.asarray(query_entry, dtype=float).reshape(3)
|
||||
grid = ReferenceGrid(reference, cell_m=policy["spatial_cell_m"])
|
||||
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 = [], [], []
|
||||
query_center = np.median(query, axis=0)
|
||||
@@ -470,11 +472,10 @@ def relocalize_route(
|
||||
float(np.linalg.norm(query - query_center, axis=1).max())
|
||||
+ 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"]
|
||||
for candidate in ranked:
|
||||
if clock() - started > policy["deadline_s"]:
|
||||
break
|
||||
if len(evaluated) % batch_size == 0:
|
||||
batches.append([])
|
||||
target = local_submap(
|
||||
@@ -487,12 +488,18 @@ def relocalize_route(
|
||||
target_center = np.median(target, axis=0)
|
||||
count_before = len(attempts)
|
||||
prepared = None
|
||||
for yaw_deg in _yaw_candidates(
|
||||
query, target, query_center, target_center, policy=policy
|
||||
for yaw_deg, seed_mode in product(
|
||||
_yaw_candidates(query, target, query_center, target_center, policy=policy),
|
||||
policy["seed_modes"],
|
||||
):
|
||||
if clock() - started > policy["deadline_s"]:
|
||||
break
|
||||
initial = _seed(query_center, target_center, yaw_deg)
|
||||
if on_progress is not None:
|
||||
on_progress(dict(stage="route-search", completed_fits=len(attempts),
|
||||
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:
|
||||
# Target preprocessing is independent of yaw. Keep one tree
|
||||
# per place; all seeds and all eligibility checks stay intact.
|
||||
@@ -512,15 +519,21 @@ def relocalize_route(
|
||||
descriptor_distance=candidate.descriptor_distance,
|
||||
),
|
||||
yaw_deg=yaw_deg,
|
||||
seed_mode=seed_mode,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
if len(attempts) - count_before != policy["yaw_candidates_per_place"]:
|
||||
if len(attempts) - count_before != fits_per_place:
|
||||
break
|
||||
evaluated.append(candidate.index)
|
||||
batches[-1].append(candidate.index)
|
||||
complete = len(evaluated) == len(ranked) and clock() - started <= policy["deadline_s"]
|
||||
result = choose_route_location(attempts, query_entry, complete=complete, policy=policy)
|
||||
complete = len(evaluated) == len(ranked)
|
||||
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(
|
||||
coverage,
|
||||
elapsed_s=clock() - started,
|
||||
@@ -644,23 +657,26 @@ def relocalize_start_then_route(
|
||||
policy=ROUTE_RELOCALIZATION_POLICY,
|
||||
reference_position=None,
|
||||
route_only=False,
|
||||
on_progress=None,
|
||||
):
|
||||
"""Use the proven start-area fit first, then a bounded route fallback.
|
||||
|
||||
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.
|
||||
"""
|
||||
"""Compare the proven dense start with every route place before deciding."""
|
||||
started = clock()
|
||||
if route_only:
|
||||
# A dense-start prior failed fresh confirmation. Recollect first, then
|
||||
# search the route without repeatedly retrying that unconfirmed start.
|
||||
return relocalize_route(reference, reference_path, query, query_entry,
|
||||
clock=clock, policy=policy)
|
||||
target, query, initial, entry, forward, window = _route_start_context(
|
||||
reference, reference_path, query, query_entry, reference_position
|
||||
)
|
||||
clock=clock, policy=policy, on_progress=on_progress)
|
||||
try:
|
||||
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(
|
||||
target,
|
||||
query,
|
||||
@@ -668,7 +684,8 @@ def relocalize_start_then_route(
|
||||
entry,
|
||||
forward,
|
||||
clock=clock,
|
||||
policy=STATIONARY_POLICY,
|
||||
policy={**STATIONARY_POLICY, "deadline_s": None},
|
||||
progress=on_progress,
|
||||
)
|
||||
start_result["initialization"].update(
|
||||
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"):
|
||||
# Compute exhaustion is not evidence that this place did not match.
|
||||
start_result["initialization"] = _hybrid_initialization(policy, start_result)
|
||||
return start_result
|
||||
|
||||
# A failed standard start may still be a valid mid-route or recovery
|
||||
# position. Give retrieval only the fresh-prefix time remaining: it must
|
||||
# never turn a late calculation into an apparently usable prior.
|
||||
remaining = policy["maximum_search_wall_s"] - (clock() - started)
|
||||
if remaining <= 0:
|
||||
route_result = choose_route_location([], entry, complete=False, policy=policy)
|
||||
route_result["initialization"].update(
|
||||
elapsed_s=0.0, worker_timeout_reason="start-stage-timeout"
|
||||
)
|
||||
else:
|
||||
recovery_policy = deepcopy(policy)
|
||||
recovery_policy["deadline_s"] = min(policy["deadline_s"], remaining)
|
||||
route_result = relocalize_route(
|
||||
reference,
|
||||
reference_path,
|
||||
query,
|
||||
entry,
|
||||
clock=clock,
|
||||
policy=recovery_policy,
|
||||
)
|
||||
additional = []
|
||||
if start_result["status"] == "candidate":
|
||||
additional.append(dict(
|
||||
candidate=dict(index=-1, position=start_result["initialization"]["reference_position"],
|
||||
progress_m=start_result["initialization"]["route_progress_m"],
|
||||
descriptor_distance=0.0),
|
||||
yaw_deg=0.0,
|
||||
result={key: value for key, value in start_result.items() if key != "initialization"},
|
||||
))
|
||||
route_result = relocalize_route(
|
||||
reference, reference_path, query, entry, clock=clock, policy=policy,
|
||||
on_progress=on_progress, additional_hypotheses=additional,
|
||||
)
|
||||
route_result["initialization"] = _hybrid_initialization(policy, start_result, route_result)
|
||||
route_result["initialization"]["elapsed_s"] = clock() - started
|
||||
route_result["registration_seconds"] = start_result.get(
|
||||
"registration_seconds", 0.0
|
||||
) + route_result.get("registration_seconds", 0.0)
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
@@ -38,9 +40,13 @@ def incomplete_result(reason):
|
||||
def run_route_relocalization(
|
||||
directory, reference, reference_path, query, query_entry, *, reference_position=None,
|
||||
route_only=False,
|
||||
cancel_event=None,
|
||||
):
|
||||
source = directory / "route-relocalization-input.npz"
|
||||
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(
|
||||
source,
|
||||
reference=reference,
|
||||
@@ -57,8 +63,7 @@ def run_route_relocalization(
|
||||
"VECLIB_MAXIMUM_THREADS": "1",
|
||||
}
|
||||
with (directory / "calculation.log").open("wb") as log:
|
||||
try:
|
||||
subprocess.run(
|
||||
with subprocess.Popen(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
@@ -69,20 +74,70 @@ def run_route_relocalization(
|
||||
env=environment,
|
||||
stdout=log,
|
||||
stderr=log,
|
||||
timeout=ROUTE_RELOCALIZATION_POLICY["maximum_search_wall_s"] + 5,
|
||||
check=True,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
# 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))
|
||||
) as process:
|
||||
reason = supervise_search(process, progress_path, cancel_event=cancel_event)
|
||||
if reason is not None:
|
||||
destination.write_text(json.dumps(incomplete_result(reason), allow_nan=False))
|
||||
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():
|
||||
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:
|
||||
result = relocalize_start_then_route(
|
||||
data["reference"],
|
||||
@@ -91,8 +146,10 @@ def main():
|
||||
data["query_entry"],
|
||||
reference_position=data.get("reference_position"),
|
||||
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__":
|
||||
|
||||
@@ -6,13 +6,13 @@ not prove SLAM frame continuity; this remains a laboratory-only protocol.
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .causal_tracking import CausalTracking
|
||||
from .causal_tracking import TRACKING_POLICY, CausalTracking
|
||||
from .live_buffer import LiveCloudBuffer
|
||||
from .registration import rigid
|
||||
from .stationary_entry import STATIONARY_POLICY, StationaryPrefix
|
||||
|
||||
BOOTSTRAP_POLICY = dict(
|
||||
version="stationary-fresh-bootstrap/v3",
|
||||
version="stationary-fresh-bootstrap/v4",
|
||||
prefix_seconds=10.0,
|
||||
maximum_motion_m=0.10,
|
||||
maximum_search_wall_s=30.0,
|
||||
@@ -58,6 +58,15 @@ class StationaryBootstrap:
|
||||
self.candidate_index = None
|
||||
self.dense_start_prior = 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):
|
||||
self.prior = None
|
||||
@@ -66,6 +75,7 @@ class StationaryBootstrap:
|
||||
self.candidate_queue = []
|
||||
self.retry_route_search = False
|
||||
self.gate.clear(reason)
|
||||
self.search_continuity_proven = False
|
||||
self.phase = "lost"
|
||||
self.reason = reason
|
||||
|
||||
@@ -82,6 +92,16 @@ class StationaryBootstrap:
|
||||
self.stop("source-order-changed")
|
||||
raise ValueError("Source sequence or receipt clock regressed.")
|
||||
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:
|
||||
# Worker completion is not a data receipt. A gap straddling that
|
||||
# instant may finish before the first fresh cloud. No validation has
|
||||
@@ -99,7 +119,9 @@ class StationaryBootstrap:
|
||||
if awaiting_first_cloud:
|
||||
self.segment = segment
|
||||
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.segment = segment
|
||||
if self.phase == "collecting":
|
||||
@@ -109,6 +131,16 @@ class StationaryBootstrap:
|
||||
|
||||
def tick(self, 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":
|
||||
self.stop("stale")
|
||||
if self.phase == "refreshing" and (
|
||||
@@ -129,8 +161,10 @@ class StationaryBootstrap:
|
||||
return sample, initial, forward, meta
|
||||
|
||||
def offer_prior(self, result, now_ns, segment):
|
||||
self.tick(now_ns, segment)
|
||||
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
|
||||
age = (now_ns - sample["monotonic_ns"]) / 1e9
|
||||
initialization = result.get("initialization", {})
|
||||
@@ -158,11 +192,14 @@ class StationaryBootstrap:
|
||||
or initialization.get("expected_attempts") != len(initialization.get("attempts", []))
|
||||
):
|
||||
reason = "initialization-incomplete"
|
||||
elif not 0 <= (now_ns - self.search_started_ns) / 1e9 <= self.initialization_policy.get(
|
||||
"maximum_search_wall_s", BOOTSTRAP_POLICY["maximum_search_wall_s"]
|
||||
elif not self.continuous_search and not (
|
||||
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"
|
||||
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"
|
||||
elif not 0 <= segment - sample["segment"] <= BOOTSTRAP_POLICY["maximum_pre_ready_gaps"]:
|
||||
reason = "too-many-receipt-gaps"
|
||||
@@ -178,6 +215,7 @@ class StationaryBootstrap:
|
||||
self.stop("initialization-incomplete")
|
||||
return dict(accepted=False, reason=self.reason, provisional=False, age_s=age)
|
||||
self.candidate_queue = [dict(item) for item in queue[1:]]
|
||||
self.search_continuity_proven = self.continuous_search
|
||||
self.candidate_trial = 1
|
||||
self.candidate_index = initialization.get("selected_candidate_index")
|
||||
stages = initialization.get("stages", [])
|
||||
@@ -196,6 +234,7 @@ class StationaryBootstrap:
|
||||
provisional=True,
|
||||
source_segment=sample["segment"],
|
||||
validation_segment=segment,
|
||||
stationary_search_continuity=self.search_continuity_proven,
|
||||
)
|
||||
|
||||
def _reset_fresh(self, floor_ns):
|
||||
@@ -208,10 +247,13 @@ class StationaryBootstrap:
|
||||
|
||||
This is available only before tracking. Once tracking is established,
|
||||
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
|
||||
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")
|
||||
return
|
||||
candidate = self.candidate_queue.pop(0)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import threading
|
||||
|
||||
import numpy as np
|
||||
|
||||
@@ -17,8 +18,7 @@ PHASE_MESSAGE = {
|
||||
"waiting-cloud": "Ожидание облака точек после подготовки сканера.",
|
||||
"collecting": "Накопление данных. Сканер должен оставаться неподвижным.",
|
||||
"searching": (
|
||||
"Точная привязка у стартовой зоны; при честном отказе — поиск по выбранному "
|
||||
"маршруту. Ожидание на месте."
|
||||
"Поиск по всему выбранному маршруту. Оставайтесь на месте до подтверждения привязки."
|
||||
),
|
||||
"refreshing": "Подтверждение привязки по новым кадрам. Ожидание на месте.",
|
||||
"validating": "Подтверждение привязки по новым кадрам. Ожидание на месте.",
|
||||
@@ -42,9 +42,15 @@ def phase_message(boot):
|
||||
)
|
||||
if boot.reason in {"initialization-incomplete", "initialization-expired"}:
|
||||
return (
|
||||
"Синхронизация маршрута не завершилась. Остановите устройство и запись, "
|
||||
"затем начните новое исследование и дождитесь неподвижной калибровки."
|
||||
"Поиск не завершён. Оставьте сканер неподвижно и нажмите "
|
||||
"«Переинициализировать». Запись продолжается."
|
||||
)
|
||||
if boot.reason == "search-motion":
|
||||
return ("Сканер перемещён во время поиска. "
|
||||
"Остановитесь и нажмите «Переинициализировать».")
|
||||
if boot.reason == "search-source-stale":
|
||||
return ("Данные сканера перестали поступать. "
|
||||
"Проверьте поток и нажмите «Переинициализировать».")
|
||||
return (
|
||||
"Синхронизация маршрута не выполнена. Убедитесь, что сканер находится "
|
||||
"у исследованного участка; можно выбрать другую различимую точку, остановиться "
|
||||
@@ -74,6 +80,8 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
||||
recovery_attempt = 0
|
||||
recovery_position = None
|
||||
route_search_only = False
|
||||
search_cancel = threading.Event()
|
||||
requested_retry = None
|
||||
|
||||
def begin_recovery(reason):
|
||||
# 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"
|
||||
)
|
||||
break
|
||||
retry_attempt = service.consume_reinitialization(run_id)
|
||||
if retry_attempt is not None:
|
||||
requested_retry = service.consume_reinitialization(run_id) or requested_retry
|
||||
if requested_retry is not None:
|
||||
if future is not None:
|
||||
# The public operation admits only a terminal initial
|
||||
# failure. Keep this fence in case a caller races an
|
||||
# internal state update.
|
||||
raise RuntimeError("Нельзя переинициализировать во время расчёта привязки.")
|
||||
reset_initialization(retry_attempt)
|
||||
continue
|
||||
search_cancel.set()
|
||||
finish(active=False)
|
||||
if future is None:
|
||||
reset_initialization(requested_retry)
|
||||
requested_retry = None
|
||||
continue
|
||||
now = clock.monotonic()
|
||||
if boot is not None:
|
||||
boot.tick(clock.monotonic_ns(), buffer.segment)
|
||||
if boot.phase == "lost":
|
||||
search_cancel.set()
|
||||
finish()
|
||||
current = source.snapshot()
|
||||
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"],
|
||||
)
|
||||
last_snapshot = now
|
||||
if buffer.distance >= service.run["maximum_distance_m"]:
|
||||
# A pose can reach the limit before the next cloud snapshot.
|
||||
service.update_sample(buffer.snapshot(), clock.monotonic_ns())
|
||||
service.update(distance_m=buffer.distance)
|
||||
end_reason = "distance-limit"
|
||||
break
|
||||
# Travel is telemetry, not completion. Detours and additional
|
||||
# laps must keep consuming the same capture and confirming the
|
||||
# same reference, even if an older run carried a distance cap.
|
||||
if boot is None or future is not None:
|
||||
continue
|
||||
# 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,
|
||||
},
|
||||
)
|
||||
search_cancel = threading.Event()
|
||||
future = executor.submit(
|
||||
initialize,
|
||||
target,
|
||||
@@ -557,6 +565,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
||||
sample["path"][0],
|
||||
**({"reference_position": recovery_position} if recovering else {}),
|
||||
**({"route_only": True} if route_search_only else {}),
|
||||
cancel_event=search_cancel,
|
||||
)
|
||||
publish_phase()
|
||||
continue
|
||||
@@ -588,6 +597,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
||||
sample, "fresh-validation", {"reference_window": window}
|
||||
)
|
||||
future = executor.submit(calculate, target, reference, sample["points"], hint)
|
||||
search_cancel.set()
|
||||
if boot is not None:
|
||||
boot.stop(end_reason)
|
||||
with service.lock:
|
||||
@@ -600,12 +610,7 @@ def run_stationary_live(service, source, run_id, executor, clock, initialize, ca
|
||||
tracking_state="lost",
|
||||
tracking_reason=end_reason,
|
||||
termination_reason=end_reason,
|
||||
message=(
|
||||
"Достигнут предел проверочного прохода. "
|
||||
"Запись управляется штатными кнопками сканера."
|
||||
if end_reason == "distance-limit"
|
||||
else "Исследование завершено. Запись управляется штатными кнопками сканера."
|
||||
),
|
||||
message="Исследование завершено. Запись управляется штатными кнопками сканера.",
|
||||
finished_at_utc=utc_now_iso(),
|
||||
)
|
||||
# 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
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
class ReplayCommand:
|
||||
"""Internal replay request containing no vendor format or channel names."""
|
||||
@@ -296,6 +304,7 @@ class ReplayCommand:
|
||||
timeline_origin_monotonic_ns: int
|
||||
speed: float
|
||||
loop: bool
|
||||
map_version: ReplayMapVersion | None = None
|
||||
|
||||
@property
|
||||
def primary_artifact(self) -> ReplayArtifact:
|
||||
|
||||
+111
-40
@@ -1,4 +1,5 @@
|
||||
"""On-demand, source-bound overview cache. No catalog-wide decoding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
@@ -7,26 +8,34 @@ import logging
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
from collections.abc import Mapping
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
from uuid import uuid4
|
||||
|
||||
from .store import SessionStore
|
||||
from .overview_comparison import load_comparison
|
||||
from .plugin_contract import RecordingExporter
|
||||
from .recording import (_validate_source, _validate_source_state,
|
||||
_validated_artifact_digests, _stage_replay_prefix)
|
||||
from .recording import (
|
||||
_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:
|
||||
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.map_versions = map_versions
|
||||
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.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.cancel = threading.Event()
|
||||
self.jobs: dict[str, dict] = {}
|
||||
@@ -37,25 +46,35 @@ class SessionOverviewService:
|
||||
|
||||
def get(self, session_id: str, *, start: bool = True) -> dict:
|
||||
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)
|
||||
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)
|
||||
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
|
||||
cached = self._cached(directory)
|
||||
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:
|
||||
if identity in self.jobs:
|
||||
return {**base, **self.jobs[identity]}
|
||||
if not start:
|
||||
return {**base, 'state': 'missing'}
|
||||
if sum(j['state'] in {'queued', 'preparing'} for j in self.jobs.values()) >= 8:
|
||||
return {**base, 'state': 'error', 'message': 'Подготовка занята. Повторите позже.'}
|
||||
self.jobs[identity] = {'state': 'queued', 'messages_processed': 0}
|
||||
return {**base, "state": "missing"}
|
||||
if sum(j["state"] in {"queued", "preparing"} for j in self.jobs.values()) >= 8:
|
||||
return {**base, "state": "error", "message": "Подготовка занята. Повторите позже."}
|
||||
self.jobs[identity] = {"state": "queued", "messages_processed": 0}
|
||||
self.executor.submit(self._build, identity, source, exporter)
|
||||
return {**base, **self.jobs[identity]}
|
||||
|
||||
@@ -63,65 +82,117 @@ class SessionOverviewService:
|
||||
detail = self.store.get_session(session_id)
|
||||
if detail.summary.replayable and detail.summary.lab is None:
|
||||
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:
|
||||
if self.jobs.get(identity, {}).get('state') == 'error':
|
||||
if self.jobs.get(identity, {}).get("state") == "error":
|
||||
self.jobs.pop(identity, None)
|
||||
return self.get(session_id)
|
||||
|
||||
def scene(self, session_id: str, generation: str) -> Path:
|
||||
current = self.get(session_id, start=False)
|
||||
if current.get('state') != 'ready' or current.get('generation') != generation:
|
||||
raise ValueError('overview generation is unavailable')
|
||||
return self.root / generation / 'scene.rrd'
|
||||
if current.get("state") != "ready" or current.get("generation") != generation:
|
||||
raise ValueError("overview generation is unavailable")
|
||||
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:
|
||||
try:
|
||||
report = directory / 'overview.json'
|
||||
report = directory / "overview.json"
|
||||
if report.stat().st_size > 2 * 1024 * 1024:
|
||||
return None
|
||||
doc = json.loads(report.read_text())
|
||||
stat = (directory / 'scene.rrd').stat()
|
||||
if doc['schema_version'] != SCHEMA or [stat.st_size, stat.st_mtime_ns] != doc['scene_stat']:
|
||||
stat = (directory / "scene.rrd").stat()
|
||||
if (
|
||||
doc["schema_version"] != SCHEMA
|
||||
or [stat.st_size, stat.st_mtime_ns] != doc["scene_stat"]
|
||||
):
|
||||
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):
|
||||
return None
|
||||
|
||||
def _build(self, identity: str, source, exporter: RecordingExporter) -> None:
|
||||
directory = self.root / identity
|
||||
directory.mkdir(exist_ok=True)
|
||||
candidate = directory / ('.' + uuid4().hex + '.rrd')
|
||||
candidate = directory / ("." + uuid4().hex + ".rrd")
|
||||
staged = None
|
||||
try:
|
||||
with self.guard:
|
||||
self.jobs[identity] = {'state': 'preparing', 'messages_processed': 0}
|
||||
self.jobs[identity] = {"state": "preparing", "messages_processed": 0}
|
||||
digests = _validated_artifact_digests(source)
|
||||
staged, primary, _ = _stage_replay_prefix(directory, source, cancel_event=self.cancel)
|
||||
|
||||
def pulse():
|
||||
with self.guard:
|
||||
self.jobs[identity]['messages_processed'] += 100
|
||||
metrics = dict(exporter(primary, candidate, cancel_event=self.cancel, activity_callback=pulse))
|
||||
self.jobs[identity]["messages_processed"] += 100
|
||||
|
||||
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:
|
||||
raise ValueError('overview source changed')
|
||||
raise ValueError("overview source changed")
|
||||
if digests != _validated_artifact_digests(source):
|
||||
raise ValueError('overview source changed')
|
||||
raise ValueError("overview source changed")
|
||||
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()
|
||||
os.replace(candidate, directory / 'scene.rrd')
|
||||
stat = (directory / 'scene.rrd').stat()
|
||||
document = {'schema_version': SCHEMA, 'metrics': metrics, 'source_digests': digests,
|
||||
'scene_sha256': digest, 'scene_stat': [stat.st_size, stat.st_mtime_ns]}
|
||||
temporary = directory / '.overview.json'
|
||||
os.replace(candidate, directory / "scene.rrd")
|
||||
stat = (directory / "scene.rrd").stat()
|
||||
document = {
|
||||
"schema_version": SCHEMA,
|
||||
"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))
|
||||
os.replace(temporary, directory / 'overview.json')
|
||||
os.replace(temporary, directory / "overview.json")
|
||||
with self.guard:
|
||||
self.jobs.pop(identity, None)
|
||||
except Exception:
|
||||
logging.getLogger(__name__).exception('Session overview preparation failed')
|
||||
logging.getLogger(__name__).exception("Session overview preparation failed")
|
||||
with self.guard:
|
||||
self.jobs[identity] = {'state': 'error', 'message': 'Не удалось подготовить обзор записи.'}
|
||||
self.jobs[identity] = {
|
||||
"state": "error",
|
||||
"message": "Не удалось подготовить обзор записи.",
|
||||
}
|
||||
finally:
|
||||
candidate.unlink(missing_ok=True)
|
||||
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