2 Commits
141 changed files with 14362 additions and 926 deletions
+1
View File
@@ -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
+1
View File
@@ -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",
+2
View File
@@ -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).");
+56 -8
View File
@@ -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 {
@@ -69,6 +71,9 @@ import {
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css";
@@ -151,6 +156,7 @@ export default function App() {
polygonDatasetRoute.active ? "data" : null,
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
@@ -177,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);
@@ -224,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;
@@ -306,6 +320,7 @@ export default function App() {
const remote = runtime.state?.viewerSettings;
if (
!remote ||
replayActiveRef.current ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
@@ -328,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;
@@ -399,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));
}, []);
@@ -431,9 +449,10 @@ export default function App() {
}
};
const openView = (viewId: string) => {
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
const definition = workspaceById(viewId);
if (!definition) return;
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
setActiveRoot(definition.root);
workspace.openView(viewId);
};
@@ -459,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;
@@ -476,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;
@@ -806,15 +840,25 @@ export default function App() {
utilityActions={contentActions}
onClose={workspace.closeView}
>
<PlanningCaptureGuard
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
onResume={() => openView('local-device', 'planning')}
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
launchProfile === 'planning' ? <PlanningConnectionWindow>
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
</PlanningConnectionWindow> : <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : (
<WorkspaceRenderer
launchProfile={launchProfile}
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
@@ -858,6 +902,7 @@ export default function App() {
}}
/>
)}
</PlanningCaptureGuard>
</ApplicationPanel>
) : null}
/>
@@ -1020,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 />;
}
@@ -0,0 +1,23 @@
import type {ReactNode} from 'react';
import {Button, StatusBadge} from '@nodedc/ui-react';
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Composition-level handoff; never sends a device or acquisition command. */
export function PlanningCaptureGuard({enabled, onResume, children}: {
enabled: boolean;
onResume: () => void;
children: ReactNode;
}) {
const planning = usePlanningTest();
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
return <div className="mission-planner__check">
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
<p>{planning.test!.draft.name}</p>
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
{planning.error && <p role="alert">{planning.error}</p>}
<div className="mission-planner__actions">
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
</div>
</div>;
}
@@ -8,11 +8,11 @@ export function PlanningConnectionWindow({children}:{children:ReactNode}){
const p=usePlanningTest(); const [open,setOpen]=useState(true);
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
useEffect(()=>{
if(!p.selected||!p.test||selection||selectionTransitionPending)return;
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
if(models.length===1)void selectModel(models[0].model.id);
},[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
if(!p.selected||!p.test)return <>{children}</>;
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
if(!p.test)return <>{children}</>;
const t=p.test, terminal=planningTestTerminal(t);
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
@@ -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};
}
@@ -18,14 +18,13 @@ export interface PlanningRunSummary {id:string;name:string;state:string;query_se
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
}|null>(null);
/** Server owns the run; navigation and reload retain its frozen reference. */
/** Server owns the run and its frozen reference, never the workspace launch profile. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [dismissed,setDismissed]=useState<string|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
@@ -33,8 +32,8 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
@@ -48,7 +47,7 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;}
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
@@ -64,8 +63,12 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
/** A prepared consumer owns the next capture until explicitly finished or used. */
export function planningAwaitsCapture(test:PlanningLiveTest|null){
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
}
@@ -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)};
}
@@ -0,0 +1,10 @@
/** Entry intent belongs to application navigation, not a retained server job. */
export type WorkspaceLaunchProfile = 'direct' | 'planning';
export function workspaceLaunchProfile(
current: WorkspaceLaunchProfile,
kind: string,
requested: WorkspaceLaunchProfile = 'direct',
): WorkspaceLaunchProfile {
return kind === 'device' || kind === 'spatial' ? requested : current;
}
@@ -1,4 +1,3 @@
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
@@ -41,7 +40,7 @@ function ModelCard({
);
}
function DeviceConnectionBody({
export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
@@ -129,7 +128,3 @@ function DeviceConnectionBody({
</div>
);
}
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
}
@@ -1,5 +1,4 @@
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
import { usePlanningTest } from "../core/missions/PlanningTestContext";
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
@@ -416,10 +415,9 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
const planning = usePlanningTest();
switch (props.definition.kind) {
case "spatial":
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
@@ -14,9 +14,10 @@ import type {
} from "../core/runtime/contracts";
import type { WorkspaceDefinition } from "../productModel";
import type { SceneSettings } from "../sceneSettings";
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
@@ -35,6 +36,7 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
launchProfile?: WorkspaceLaunchProfile;
fleetCreateRequest?: number;
headerToolsHost?: HTMLElement | null;
definition: WorkspaceDefinition;
@@ -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';
@@ -15,11 +14,9 @@ import { PlanningProjectSelect } from '../../components/missions/PlanningProject
import '../../styles/session-overview.css';
import '../../styles/mission-planner.css';
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) {
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const 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');}
} 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,9 +88,9 @@ 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()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</Button>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
{live.error&&editing&&<p role="alert">{live.error}</p>}
</WorkspaceWindow>}
</div>
@@ -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>
@@ -39,6 +39,7 @@ export interface SpatialWorkspaceProfile {
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
}
export function SpatialWorkspace({
launchProfile = 'direct',
state,
sourceUrl,
requestedPlaybackSeconds,
@@ -441,7 +442,7 @@ export function SpatialWorkspace({
model={spatialControls.model}
spatialActivity={visualProfile?.activity}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
@@ -553,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)}
@@ -571,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,106 @@
import assert from 'node:assert/strict';
import React, {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
import {before, after, test} from 'node:test';
import {createServer} from 'vite';
let server, resolve, awaitsCapture, Guard, Provider, Device, Host;
before(async () => {
server = await createServer({appType:'custom', logLevel:'silent', server:{middlewareMode:true}});
({workspaceLaunchProfile:resolve} = await server.ssrLoadModule('/src/core/observation/workspaceLaunch.ts'));
({planningAwaitsCapture:awaitsCapture, PlanningTestProvider:Provider} = await server.ssrLoadModule('/src/core/missions/PlanningTestContext.tsx'));
({PlanningCaptureGuard:Guard} = await server.ssrLoadModule('/src/components/missions/PlanningCaptureGuard.tsx'));
({DeviceWorkspace:Device} = await server.ssrLoadModule('/src/workspaces/DeviceWorkspace.tsx'));
({DevicePluginHostProvider:Host} = await server.ssrLoadModule('/src/core/device-plugins/DevicePluginHost.tsx'));
});
after(async () => { await server?.close(); });
test('direct entry overrides an earlier planning profile for both shared surfaces', () => {
for (const kind of ['device','spatial']) {
assert.equal(resolve('planning',kind),'direct');
assert.equal(resolve('direct',kind),'direct');
assert.equal(resolve('direct',kind,'planning'),'planning');
assert.equal(resolve('planning',kind,'planning'),'planning');
}
assert.equal(resolve('planning','missions'),'planning');
assert.equal(resolve('direct','missions'),'direct');
});
test('a prepared consumer cannot silently claim a direct capture; old/bound runs do not block it', () => {
assert.equal(awaitsCapture(null),false);
for (const state of ['completed','cancelled','interrupted','error']) {
for (const query_session_id of [null,'recording']) assert.equal(awaitsCapture({state,query_session_id}),false);
}
for (const state of ['preparing','waiting','running']) {
assert.equal(awaitsCapture({state,query_session_id:null}),true);
assert.equal(awaitsCapture({state,query_session_id:'recording'}),false);
}
});
function withHooks(hooks, fn) {
// Same bounded pre-effect harness used by observatoryHooks.test.mjs.
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
const previous = internals.H;
internals.H = hooks;
try { return fn(); } finally { internals.H = previous; }
}
test('retained completed run renders the ordinary connection, without issuing any commands', () => {
let calls = 0;
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>({test:{state:'completed',query_session_id:'old'},finish:()=>{calls++;}})},
() => Guard({enabled:true,onResume:()=>{calls++;},children:child}));
assert.match(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(calls,0);
});
test('pending run handoff is explicit and only finishes the research, not the scanner', () => {
let stopped = 0, resumed = 0;
const context = {test:{state:'waiting',query_session_id:null,draft:{name:'PENDING'}},busy:false,error:null,finish:()=>{stopped++;}};
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>context}, () => Guard({enabled:true,onResume:()=>{resumed++;},children:child}));
assert.doesNotMatch(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(stopped,0);
const actions = tree.props.children.at(-1).props.children;
actions[0].props.onClick(); assert.equal(stopped,1); assert.equal(resumed,0);
actions[1].props.onClick(); assert.equal(resumed,1);
const planningTree = withHooks({useContext:()=>context}, () => Guard({enabled:false,onResume:()=>{},children:child}));
assert.match(renderToStaticMarkup(planningTree),/ORDINARY DEVICE/);
});
test('generic device catalog renders without any PlanningTestProvider', () => {
const html = renderToStaticMarkup(createElement(Host,{plugins:[]},
createElement(Device,{onOpenSpatialScene:()=>{},onActivateAutomaticSpatialSource:()=>{}})));
assert.match(html,/Выберите модель устройства/);
assert.doesNotMatch(html,/Профиль · Планирование|Подключение сканера · Планирование/);
});
test('failed planner selection does not grant a successful launch', async () => {
const writes = [];
const context = withHooks({
useState:initial=>[typeof initial==='function'?initial():initial,value=>writes.push(value)],
useRef:value=>({current:value}),useCallback:fn=>fn,useEffect:()=>{},
}, () => Provider({children:null}).props.value);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () => new Response(JSON.stringify({detail:'Выбранный проход недоступен'}),{status:409});
assert.equal(await context.select('missing'),false);
assert.ok(writes.includes('Выбранный проход недоступен'));
assert.equal('selected' in context,false);
assert.equal('resume' in context,false);
} finally { globalThis.fetch = originalFetch; }
});
test('composition owns explicit profile propagation; server polling cannot restore it', () => {
const source = path => readFileSync(new URL('../src/'+path,import.meta.url),'utf8');
assert.doesNotMatch(source('workspaces/DeviceWorkspace.tsx'),/Planning|missions\//);
assert.doesNotMatch(source('core/missions/PlanningTestContext.tsx'),/dismissed|sessionStorage|launchProfile/);
assert.match(source('App.tsx'),/useState<WorkspaceLaunchProfile>\('direct'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'planning'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'direct'\)/);
assert.match(source('workspaces/spatial/SpatialWorkspace.tsx'),/openView\("spatial-scene", launchProfile\)/);
assert.match(source('workspaces/Workspaces.tsx'),/props\.launchProfile === 'planning'/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/openView\('local-device','planning'\)/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/if\(await live.select\(project.id\)\)openView/);
});
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,25 @@
Copyright (c) 2022 Rerun Technologies AB <opensource@rerun.io>
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,361 @@
--- a/crates/viewer/re_view_spatial/src/eye.rs
+++ b/crates/viewer/re_view_spatial/src/eye.rs
@@ -2,7 +2,7 @@
use glam::{Mat4, Quat, Vec3, vec3};
use macaw::IsoTransform;
use re_log_types::EntityPath;
-use re_sdk_types::blueprint::archetypes::EyeControls3D;
+use re_sdk_types::blueprint::archetypes::{EyeControls3D, LineGrid3D};
use re_sdk_types::blueprint::components::{AngularSpeed, Eye3DKind};
use re_sdk_types::components::{LinearSpeed, Position3D, Vector3D};
use re_view::controls::{
@@ -214,6 +214,8 @@
pub last_look_target: Option<Vec3>,
pub last_orbit_radius: Option<f32>,
pub last_eye_up: Option<Vec3>,
+ /// NODE.DC: time of the last rendered eye, for the read-only WebViewer snapshot.
+ pub last_render_time: f64,
}
#[derive(Clone, Debug, PartialEq, re_byte_size::SizeBytes)]
@@ -241,6 +243,7 @@
fov_y: Option<f32>,
did_interact: bool,
+ grid_plane: macaw::Plane3,
}
impl EyeController {
@@ -250,16 +253,8 @@
/// Avoids breaking the view by zooming in too far.
pub const MIN_ORBIT_DISTANCE: f32 = Eye::PERSPECTIVE_NEAR_PLANE * 2.0;
- /// Cap on the orbital camera radius, as a multiple of the scene bounding box diagonal.
- ///
- /// Only applied when scroll-to-zoom wants to grow the radius further. Zoom-in, rotate,
- /// pan, WASD, and every other form of motion are left alone — this is intentionally a
- /// local restriction on orbital zoom-out only, not a general movement envelope. The 2D
- /// view has its own, separate zoom-out cap (see `ui_2d::MAX_ZOOM_OUT_FACTOR`).
- ///
- /// If the radius already exceeds this (e.g. right after loading) the current radius is
- /// used as the cap instead, so the camera isn't pulled back in.
- const MAX_ORBITAL_ZOOM_OUT_FACTOR: f32 = 5.0;
+ // NODE.DC: finite arithmetic guard only, independent of route/scene bounds.
+ const MAX_ORBITAL_RADIUS: f32 = 1.0e17;
fn get_eye(&self) -> Eye {
Eye {
@@ -317,6 +312,10 @@
.0,
);
+ let grid = ViewProperty::from_archetype::<LineGrid3D>(ctx);
+ let grid_plane = grid.component_or_fallback::<re_sdk_types::components::Plane3D>(
+ ctx, LineGrid3D::descriptor_plane().component,
+ )?;
Ok(Self {
pos,
look_target,
@@ -325,6 +324,7 @@
eye_up,
did_interact: false,
fov_y,
+ grid_plane: grid_plane.into(),
})
}
@@ -482,12 +482,45 @@
let up = rot * Vec3::Y;
let right = rot * -Vec3::X; // TODO(emilk): why do we need a negation here? O.o
+ // Mission Core's map grid is XY. Screen-plane pan otherwise lifts the
+ // orbit target into the air, leaving scroll-to-target stranded there.
+ // First-person navigation intentionally retains upstream 3D movement.
+ let (right, up) = if self.kind == Eye3DKind::Orbital {
+ let normal = self.grid_plane.normal;
+ let project = |v: Vec3| v - normal * normal.dot(v);
+ let right = project(right).normalize_or(project(Vec3::X).normalize_or(Vec3::Y));
+ let up = project(up).normalize_or(normal.cross(-right));
+ (right, up)
+ } else {
+ (right, up)
+ };
let translate = delta_in_view.x * right + delta_in_view.y * up;
self.pos += translate;
self.look_target += translate;
}
+ /// Translate the whole rig, not its orientation/radius, onto the grid.
+ /// Called only by deliberate navigation while not following an entity.
+ fn anchor_orbit_to_grid(&mut self) {
+ if self.kind == Eye3DKind::Orbital {
+ let correction = self.grid_plane.normal
+ * (self.grid_plane.d - self.grid_plane.normal.dot(self.look_target));
+ self.pos += correction;
+ self.look_target += correction;
+ }
+ }
+
+ fn zoom_orbit(&mut self, zoom_factor: f32) {
+ if !zoom_factor.is_finite() || zoom_factor <= 0.0 {
+ return;
+ }
+ let new_radius = (self.radius() / zoom_factor)
+ .clamp(Self::MIN_ORBIT_DISTANCE, Self::MAX_ORBITAL_RADIUS);
+ self.pos = self.look_target - self.fwd() * new_radius;
+ self.did_interact = true;
+ }
+
fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) {
if response.drag_delta().length() > drag_threshold {
let roll = response.dragged_by(ROLL_MOUSE)
@@ -513,7 +546,7 @@
}
/// Handle zoom/scroll input.
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
+ fn handle_zoom(&mut self, egui_ctx: &egui::Context, _scene_bounding_box: &macaw::BoundingBox) {
let zoom_factor = egui_ctx.input(|input| {
// egui's default horizontal_scroll_modifier is shift, which is also our speed-up modifier.
// This means that a user who wants to speed up scroll-to-zoom will generate a horizontal scroll delta.
@@ -528,22 +561,7 @@
match self.kind {
Eye3DKind::Orbital => {
- let radius = self.pos.distance(self.look_target);
-
- // Cap zoom-out against the scene bounding box. If we're already past the cap
- // (e.g. right after loading) use the current radius instead — no snap-back.
- let max_radius = max_orbital_radius(scene_bounding_box).max(radius);
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
-
- // The user may be scrolling to move the camera closer, but are not realizing
- // the radius is now tiny.
- // TODO(emilk): inform the users somehow that scrolling won't help, and that they should use WSAD instead.
- // It might be tempting to start moving the camera here on scroll, but that would is bad for other reasons.
-
- if f32::MIN_POSITIVE < new_radius {
- self.pos = self.look_target - self.fwd() * new_radius;
- self.did_interact = true;
- }
+ self.zoom_orbit(zoom_factor);
}
Eye3DKind::FirstPerson => {
// Move along the forward axis when zooming in first person mode.
@@ -707,25 +725,6 @@
}
}
-/// Cap on the orbital zoom-out radius, derived from the scene bounding box diagonal.
-///
-/// Returns `1.0e17` (a large fallback that avoids infinities downstream) when no usable scene
-/// bounding box is available.
-fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 {
- // `1.0e17` fallback is chosen with generous margin of an observed crash due to infinity.
- let fallback = 1.0e17;
-
- if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
- return fallback;
- }
- let scene_diagonal = scene_bounding_box.size().length();
- if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
- return fallback;
- }
- (scene_diagonal * EyeController::MAX_ORBITAL_ZOOM_OUT_FACTOR)
- .max(EyeController::MIN_ORBIT_DISTANCE)
-}
-
pub fn find_camera(cameras: &[PinholeWrapper], needle: &EntityPath) -> Option<Eye> {
let mut found_camera = None;
@@ -744,6 +743,103 @@
fn ease_out(t: f32) -> f32 {
1. - (1. - t) * (1. - t)
+}
+
+#[cfg(test)]
+mod nodedc_navigation_tests {
+ use super::*;
+
+ fn controller() -> EyeController {
+ EyeController {
+ pos: vec3(305.0, -80.0, 22.0),
+ look_target: vec3(290.0, -60.0, 4.0),
+ kind: Eye3DKind::Orbital,
+ speed: 30.0,
+ eye_up: Vec3::Z,
+ fov_y: Some(Eye::DEFAULT_FOV_Y),
+ did_interact: false,
+ grid_plane: macaw::Plane3 { normal: Vec3::Z, d: 0.0 },
+ }
+ }
+
+ #[test]
+ fn pan_is_on_grid_and_moves_the_whole_rig() {
+ let mut c = controller();
+ let offset = c.pos - c.look_target;
+ c.anchor_orbit_to_grid();
+ for _ in 0..100 {
+ c.translate(egui::vec2(0.5, 0.7));
+ }
+ assert_eq!(c.look_target.z, 0.0);
+ assert!((c.pos - c.look_target - offset).length() < 0.001);
+ }
+
+ #[test]
+ fn rotation_keeps_selected_pivot_and_radius() {
+ let mut c = controller();
+ c.anchor_orbit_to_grid();
+ let target = c.look_target;
+ let radius = c.radius();
+ for _ in 0..200 {
+ c.rotate(egui::vec2(2.0, 0.1));
+ }
+ assert_eq!(c.look_target, target);
+ assert!((c.radius() - radius).abs() < 0.01);
+ }
+
+ #[test]
+ fn zoom_passes_map_scale_without_moving_pivot() {
+ let mut c = controller();
+ c.anchor_orbit_to_grid();
+ let target = c.look_target;
+ c.zoom_orbit(0.001);
+ assert!(c.radius() > 20_000.0);
+ for _ in 0..12 {
+ c.zoom_orbit(4.0);
+ }
+ assert!(c.radius() < 0.03);
+ assert_eq!(c.look_target, target);
+ assert!(c.get_eye().world_from_rub_view.translation().is_finite());
+ }
+
+ #[test]
+ fn vertical_view_pan_stays_finite_on_grid() {
+ let mut c = controller();
+ c.pos = vec3(0.0, 0.0, 30.0);
+ c.look_target = Vec3::ZERO;
+ c.eye_up = Vec3::Y;
+ c.translate(egui::vec2(1.0, 1.0));
+ assert_eq!(c.look_target.z, 0.0);
+ assert!(c.pos.is_finite());
+ assert!(c.look_target.length() > 1.0);
+ }
+
+ #[test]
+ fn navigation_respects_the_actual_grid_plane() {
+ let mut c = controller();
+ c.grid_plane = macaw::Plane3 { normal: Vec3::Y, d: 3.0 };
+ c.anchor_orbit_to_grid();
+ let target = c.look_target;
+ c.translate(egui::vec2(2.0, 4.0));
+ c.rotate(egui::vec2(10.0, 3.0));
+ assert_eq!(c.look_target.y, 3.0);
+ assert_ne!(c.look_target, target);
+ }
+
+ #[test]
+ fn invalid_zoom_is_ignored_and_first_person_is_unchanged() {
+ let mut c = controller();
+ let before = c.pos;
+ for factor in [0.0, -1.0, f32::NAN, f32::INFINITY] {
+ c.zoom_orbit(factor);
+ assert_eq!(c.pos, before);
+ }
+ c.kind = Eye3DKind::FirstPerson;
+ c.anchor_orbit_to_grid();
+ assert_eq!(c.look_target.z, 4.0);
+ c.translate(egui::vec2(0.0, 1.0));
+ assert_ne!(c.look_target.z, 4.0);
+ }
}
impl EyeState {
@@ -804,9 +900,32 @@
}
}
+ // Anchor before the first paint, not on the first orbit drag. This
+ // avoids a one-off camera jump when the scene-bounds fallback is high.
+ if tracking_entity.is_none() && eye_controller.kind == Eye3DKind::Orbital
+ && (eye_controller.grid_plane.normal.dot(eye_controller.look_target)
+ - eye_controller.grid_plane.d).abs() > 1.0e-5
+ {
+ eye_controller.anchor_orbit_to_grid();
+ eye_controller.did_interact = true;
+ }
+
// Handle spinning before inputs because some inputs depend on view direction.
self.handle_spinning(ctx, eye_property, &mut eye_controller)?;
+ // NODE.DC: pin manual map navigation to the XY grid. Tracking and
+ // explicit camera presets retain their own target until manual input.
+ let manual_input = response.drag_delta().length_sq() > 0.0
+ || (response.hovered() && response.ctx.input(|i| {
+ i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() != 1.0
+ }));
+ if manual_input {
+ self.stop_interpolation();
+ if tracking_entity.is_none() {
+ eye_controller.anchor_orbit_to_grid();
+ }
+ }
+
// We do input before tracking entity, because the input can cause the eye
// to stop tracking.
let gamepad_navigation_status = eye_controller.handle_input(
@@ -1241,6 +1360,7 @@
};
self.last_eye = Some(eye);
+ self.last_render_time = ctx.egui_ctx().time();
Ok(eye)
}
--- a/crates/viewer/re_viewer_context/src/view/view_states.rs
+++ b/crates/viewer/re_viewer_context/src/view/view_states.rs
@@ -173,6 +173,13 @@
}
impl ViewStates {
+ /// NODE.DC: read-only per-recording state access for native camera snapshots.
+ pub fn states_for_store<'a>(&'a self, store_id: &'a StoreId) -> impl Iterator<Item = &'a dyn ViewState> {
+ self.states.iter().filter_map(move |((id, _), state)| {
+ (id == store_id).then_some(state.as_ref())
+ })
+ }
+
pub fn get(&self, store_id: &StoreId, view_id: ViewId) -> Option<&dyn ViewState> {
self.states
.get(&(store_id.clone(), view_id))
--- a/crates/viewer/re_viewer/src/web.rs
+++ b/crates/viewer/re_viewer/src/web.rs
@@ -384,6 +384,26 @@
let recording = hub.entity_db(recording_id)?;
Some(recording.store_id().recording_id().to_string())
+ }
+
+ /// NODE.DC navigation/v1. Last rendered native eye, never a replay of DOM deltas.
+ /// Each Mission Core iframe admits one active 3D pane. Timestamp selection
+ /// excludes states retained for a previously selected/reset pane.
+ #[wasm_bindgen]
+ pub fn nodedc_camera_eye(&self) -> Option<String> {
+ let app = self.runner.app_mut::<crate::App>()?;
+ let store_id = app.active_recording_id()?;
+ let eye_state = app.state.view_states.states_for_store(store_id)
+ .filter_map(|state| state.as_any().downcast_ref::<re_view_spatial::SpatialViewState>())
+ .map(|state| &state.state_3d.eye_state)
+ .filter(|state| state.last_eye.is_some() && state.last_look_target.is_some())
+ .max_by(|a, b| a.last_render_time.total_cmp(&b.last_render_time))?;
+ let eye = eye_state.last_eye?;
+ Some(serde_json::json!({
+ "position": eye.pos_in_world().to_array(),
+ "lookTarget": eye_state.last_look_target?.to_array(),
+ "eyeUp": eye_state.last_eye_up?.to_array(),
+ }).to_string())
}
//TODO(#10737): we should refer to logical recordings using store id (recording id is ambiguous)
@@ -0,0 +1,44 @@
# Rerun 0.36.3 · NODE.DC navigation/v1
This is the bounded native camera amendment in ADR 0052, not the archived
0.34.1 renderer fork. `navigation-build.json` binds the exact upstream commit,
portable source patch and paired generated runtime. Keep the upstream licenses.
## Rebuild
1. Download the source archive for the manifest's `upstreamCommit` from
`https://codeload.github.com/rerun-io/rerun/tar.gz/<commit>` and verify its hash.
2. Extract into a new temporary directory and run `git apply --check` followed
by `git apply` with `NODEDC_NAVIGATION.patch` at the extracted root.
3. Pack that root as `patched-source.tar.gz`. On macOS use
`COPYFILE_DISABLE=1 tar --no-mac-metadata --exclude='._*'`; AppleDouble files
are not shaders and must not enter the build.
4. On Worker006, create a temporary container from the pinned Rust image in
the manifest, with 4 CPUs, 16 GB memory, no GPU, no ports and no private data.
Use the `ndc-` name and NODE.DC ownership labels required by AGENTS.md.
Copy the archive to `/work/patched-source.tar.gz` and the repository's
`scripts/build-rerun-navigation.sh` to `/work/build-rerun-navigation.sh`.
Entrypoint: `bash /work/build-rerun-navigation.sh`.
5. Require the native navigation tests to pass. The upstream
`rerun_js/web-viewer/build-wasm.mjs --mode release` produces the matching
`re_viewer.js`, `re_viewer.d.ts` and `re_viewer_bg.wasm`. Copy all three,
never only WASM, to the artifact names in this directory's manifest.
6. Recheck source patch, build script and generated file hashes. Update the
manifest, run the installer twice (idempotency), vendor ABI checks, frontend
contracts/build, then real product-browser QA. Remove the temporary build
container after copying artifacts and evidence.
The source archive and build cache are not runtime dependencies. No compiler,
Docker or hardware access is needed on a clean operator install: the existing
exact npm package plus `postinstall` installs the audited paired artifacts.
An unfamiliar package or artifact is rejected before any file is changed.
## Upgrade / rollback
Rebase against the next exact upstream source, not against minified JS. Retest
grid-plane pan, fixed-pivot orbit, close/far zoom, explicit reset/top/follow,
display updates, normal/expanded views and disposable-iframe cleanup.
To roll back, disable `postinstall`/`prebuild`, restore the official exact npm
package and rebuild. Missing snapshots are represented as null, not simulated
camera state. Recordings, corrected maps and scanner sessions are unchanged.
@@ -0,0 +1,35 @@
{
"schema": "nodedc.rerun-navigation/v1",
"upstreamVersion": "0.36.3",
"upstreamCommit": "6ded109d33c549e98185f7c95fa8009d44e4adef",
"upstreamArchiveSha256": "3f92aef3f33f63c79b275b5c66a65d73192e75967cbcd3d1f16c75010996f896",
"patchSha256": "a893a4b4e7523cca88284316c88b930c3b91ba973ed3f954c9ca632f71252e64",
"cargoLockSha256": "74cdf9c3c8c5dbe5a368f1a9a58b011e2a45720843b30e552bc7b1d8ac21d791",
"buildScriptSha256": "e3cbd5da889403eda3ffba7c020b6075bdfb025b823d1bf503b639f59688135a",
"buildDate": "2026-09-21",
"rustImage": "rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1",
"binaryenVersion": "117",
"binaryenArchiveSha256": "3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212",
"wasmBindgenVersion": "0.2.126",
"nativeTestsPassed": 6,
"files": {
"index.js": { "upstreamSha256": "009aa5f66c8674ea268a91926c00e2f82cc7a7b7e5535056d93bd4656d6f6812" },
"index.ts": { "upstreamSha256": "087b25170f720a28aa69c411903133286238900050ac775d2cc511b919846696" },
"index.d.ts": { "upstreamSha256": "649637dad4e50ef4f2310fc1920b580d16832226a0dda5238355059bf167efba" },
"re_viewer.js": {
"upstreamSha256": "d2a94836dd0e6de40538c4d657cfb423b1803e354e6ee0cd550735eb5aaab09b",
"artifact": "re_viewer.nodedc.js",
"sha256": "610d89abffcc5c329797e77794ae2ab5b426c80a5b23fd24064c7d47c6583d3a"
},
"re_viewer.d.ts": {
"upstreamSha256": "9a77a4e1e1aa8311185a3f275bf326a8fff2844e468e561c59d5f0ee7aa6debe",
"artifact": "re_viewer.nodedc.d.ts",
"sha256": "cfbf010d66560af919aa68d255a6ddc7120829f8ee8f27a41f56d943cd54e598"
},
"re_viewer_bg.wasm": {
"upstreamSha256": "02d6d9c3a569d3faceb01cf89104342d970fc85e7c31a08dedd357b6c7104d2f",
"artifact": "re_viewer_bg.nodedc.wasm",
"sha256": "2b661de545ac90bb950eda1a28fc5a7c3ce4ca7f5bb24894789acf7589495185"
}
}
}
@@ -0,0 +1,200 @@
declare namespace wasm_bindgen {
/* tslint:disable */
/* eslint-disable */
/**
* The `ReadableStreamType` enum.
*
* *This API requires the following crate features to be activated: `ReadableStreamType`*
*/
export type ReadableStreamType = "bytes";
export class IntoUnderlyingByteSource {
private constructor();
free(): void;
[Symbol.dispose](): void;
cancel(): void;
pull(controller: ReadableByteStreamController): Promise<any>;
start(controller: ReadableByteStreamController): void;
readonly autoAllocateChunkSize: number;
readonly type: ReadableStreamType;
}
export class IntoUnderlyingSink {
private constructor();
free(): void;
[Symbol.dispose](): void;
abort(reason: any): Promise<any>;
close(): Promise<any>;
write(chunk: any): Promise<any>;
}
export class IntoUnderlyingSource {
private constructor();
free(): void;
[Symbol.dispose](): void;
cancel(): void;
pull(controller: ReadableStreamDefaultController): Promise<any>;
}
export class WebHandle {
free(): void;
[Symbol.dispose](): void;
/**
* Add a new receiver streaming data from the given url.
*
* Websocket streams are always opened in `Following` mode.
*
* It is an error to open a channel twice with the same id.
*/
add_receiver(url: string): void;
/**
* Close an existing channel for streaming data.
*
* No-op if the channel is already closed.
*/
close_channel(id: string): void;
destroy(): void;
get_active_recording_id(): string | undefined;
get_active_timeline(recording_id: string): string | undefined;
get_playing(recording_id: string): boolean | undefined;
get_time_for_timeline(recording_id: string, timeline_name: string): number | undefined;
get_timeline_time_range(recording_id: string, timeline_name: string): any;
has_panicked(): boolean;
constructor(app_options: any);
/**
* NODE.DC navigation/v1. Last rendered native eye, never a replay of DOM deltas.
* Each Mission Core iframe admits one active 3D pane. Timestamp selection
* excludes states retained for a previously selected/reset pane.
*/
nodedc_camera_eye(): string | undefined;
/**
* Open a new channel for streaming data.
*
* It is an error to open a channel twice with the same id.
*/
open_channel(id: string, channel_name: string): void;
override_panel_state(panel: string, state?: string | null): void;
panic_callstack(): string | undefined;
panic_message(): string | undefined;
remove_receiver(url: string): void;
/**
* Add an rrd to the viewer directly from a byte array.
*/
send_rrd_to_channel(id: string, data: Uint8Array): void;
send_table_to_channel(id: string, data: Uint8Array): void;
set_active_recording_id(recording_id: string): void;
/**
* Set the active timeline.
*
* This does nothing if the timeline can't be found.
*/
set_active_timeline(recording_id: string, timeline_name: string): void;
set_credentials(access_token: string, email: string): void;
set_playing(recording_id: string, value: boolean): void;
set_time_for_timeline(recording_id: string, timeline_name: string, time: number): void;
start(canvas: any): Promise<void>;
toggle_panel_overrides(value?: boolean | null): void;
}
}
declare type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
declare interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly __wbg_webhandle_free: (a: number, b: number) => void;
readonly webhandle_add_receiver: (a: number, b: number, c: number) => void;
readonly webhandle_close_channel: (a: number, b: number, c: number) => void;
readonly webhandle_destroy: (a: number) => void;
readonly webhandle_get_active_recording_id: (a: number) => [number, number];
readonly webhandle_get_active_timeline: (a: number, b: number, c: number) => [number, number];
readonly webhandle_get_playing: (a: number, b: number, c: number) => number;
readonly webhandle_get_time_for_timeline: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly webhandle_get_timeline_time_range: (a: number, b: number, c: number, d: number, e: number) => any;
readonly webhandle_has_panicked: (a: number) => number;
readonly webhandle_new: (a: any) => [number, number, number];
readonly webhandle_nodedc_camera_eye: (a: number) => [number, number];
readonly webhandle_open_channel: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_override_panel_state: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly webhandle_panic_callstack: (a: number) => [number, number];
readonly webhandle_panic_message: (a: number) => [number, number];
readonly webhandle_remove_receiver: (a: number, b: number, c: number) => void;
readonly webhandle_send_rrd_to_channel: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_send_table_to_channel: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_set_active_recording_id: (a: number, b: number, c: number) => void;
readonly webhandle_set_active_timeline: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_set_credentials: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_set_playing: (a: number, b: number, c: number, d: number) => void;
readonly webhandle_set_time_for_timeline: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly webhandle_start: (a: number, b: any) => any;
readonly webhandle_toggle_panel_overrides: (a: number, b: number) => void;
readonly rust_lz4_wasm_shim_calloc: (a: number, b: number) => number;
readonly rust_lz4_wasm_shim_free: (a: number) => void;
readonly rust_lz4_wasm_shim_malloc: (a: number) => number;
readonly rust_lz4_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
readonly rust_lz4_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
readonly rust_lz4_wasm_shim_memmove: (a: number, b: number, c: number) => number;
readonly rust_lz4_wasm_shim_memset: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_calloc: (a: number, b: number) => number;
readonly rust_zstd_wasm_shim_free: (a: number) => void;
readonly rust_zstd_wasm_shim_malloc: (a: number) => number;
readonly rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_memmove: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_memset: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
readonly intounderlyingbytesource_cancel: (a: number) => void;
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
readonly intounderlyingbytesource_type: (a: number) => number;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_write: (a: number, b: any) => any;
readonly intounderlyingsource_cancel: (a: number) => void;
readonly intounderlyingsource_pull: (a: number, b: any) => any;
readonly wasm_bindgen__convert__closures_____invoke__ha28703b0fc0ac5f5: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__hd1708d5debff0eb7: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4_10: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4_11: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h68d110bc138a9729: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h4a090e5af75dc439: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h72e0675ea71ceaf9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h76e373640bdbaace: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h912ec1a6c04fd19b: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h76e373640bdbaace_4: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__ha366fcce789d0db1: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h3d976baa4adbebda: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h912ec1a6c04fd19b_9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hbf8a291ae3f8f46d: (a: number, b: number) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0e2ab714131e0149: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h2a38a854c15eed3d: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h46084f6dced1097a: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_destroy_closure: (a: number, b: number) => void;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_start: () => void;
}
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
declare function wasm_bindgen (module_or_path: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
export type WebHandle = wasm_bindgen.WebHandle;
export default function(): wasm_bindgen;
File diff suppressed because it is too large Load Diff
+14 -3
View File
@@ -6,9 +6,11 @@ Status: accepted, 2026-07-26
Mission Core has two different catalogs over related evidence:
- **Data → Sessions and records** contains only original physical captures.
- **Data → Sessions and records** contains independent original physical captures.
Examples: `RAVNOVES00`, `TEST007`, `TEST009`. A source record is immutable
evidence received from a device or recording adapter.
evidence received from a device or recording adapter. Per the 2026-09-21
owner decision, physical passes acquired by the planner are shown with their
studies in **LAB → Planner**, not in Data or the reference selector.
- **Test contour → Laboratory contours** contains derived experimental runs.
Examples: LAB E24, E25, E26, E28 and E29. A LAB run references an original
record and never becomes another original capture.
@@ -16,7 +18,10 @@ Mission Core has two different catalogs over related evidence:
The backend may keep both entities in one durable SQLite catalog, but every
consumer must request an explicit catalog scope:
- `scope=source` for original records;
- `scope=standalone` for Data and planner reference choices: original records
excluding explicit planner-acquisition bindings;
- `scope=source` for all original records, including planner passes needed for
recorded comparisons and scientific inspection;
- `scope=laboratory` for LAB projections;
- `scope=all` only for internal joins that must resolve both a derivative and
its source.
@@ -24,6 +29,12 @@ consumer must request an explicit catalog scope:
Deleting, renaming or moving source payloads to make the UI look clean is
forbidden. Product separation is expressed by typed projections.
A planner pass remains a physical source, not a synthetic LAB projection.
Classification uses exact run/session acquisition bindings, never names or
registration success. Hiding it from Data does not remove its raw evidence or
its historical planning report. See
[the full-reference/catalog audit](audits/2026-09-21-whole-reference-and-capture-catalogs.md).
## Current reference-source policy
RAVNOVES00 is the sole active physical reference source for the current
@@ -113,6 +113,45 @@ Owns shell-level orchestration: selected root/workspace, global panels, runtime
providers, and passing typed controllers to a workspace. It must not absorb
domain API calls, per-LAB renderers, or new visual primitives.
## Shared LAB launch profiles — 2026-09-21
Test devices and Spatial scene are shared infrastructure, not children of the
planner. `DeviceWorkspace` imports only the generic device host and its UI
contracts; it must render without a `PlanningTestProvider`.
- Direct navigation to either surface selects the `direct` launch profile.
A page reload also starts with `direct`, even if the server retains a completed
or interrupted planning run. Model selection remains the device host's state.
- A successful planner start or explicit reopen passes `planning` through
`WorkspaceNavigation.openView`. The shell composes `PlanningConnectionWindow`
only for that entry. Device connection and spatial-control callbacks preserve
the originating profile when opening the scene.
- `PlanningTestProvider` owns run data and polling, not navigation selection.
Reading `/live-tests/active` must never change the launch profile. An unsuccessful
explicit run selection must not open a stale run's connection or scene.
- A nonterminal planning consumer that has not bound a query session still waits
for the next capture. The composition-level `PlanningCaptureGuard` requires an
explicit return to that study or completion of the study before direct capture;
it does not send scanner, network, or recording commands. It waits for a terminal
server state, not merely acknowledgment of the stop request. Completed runs and
runs already bound to a recording do not claim a later direct launch.
Changing presentation does not delete evidence, end acquisition, or restart an
experiment. The planner continues to consume an explicitly started recording;
ordinary acquisition requires neither a reference route nor a planner draft.
Regression coverage: `test/workspaceLaunch.test.mjs`, including independent model
catalog rendering, explicit handoff, terminal-run isolation, failed selection,
and both shared-scene entry paths.
Acceptance on the canonical operator service `127.0.0.1:8000`: architecture
checks, TypeScript, all 882 frontend tests and production build passed. In-app
browser QA confirmed direct entry with retained completed evidence, catalog →
XGRIDS connection, the unchanged connection-method selector and Escape,
normal/expanded layouts, ordinary scene entry, and planner → direct-device
navigation. No BLE discovery, provisioning, acquisition or new field run was
performed during this UI acceptance.
## CSS ownership
CSS follows the same feature boundary:
@@ -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 23 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
2030 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 35 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 46 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 35 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.54699.823%, inlier RMSE 0.11810.1501 m; measured local calculation
time is 0.0730.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.2599.64%
and inlier RMSE about 0.139 m, but correcting the obsolete local prior requires
3.153.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 4049%; 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.27499.928%**, median
**99.644%**. Inlier surface RMSE: **0.13300.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:3540 | 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.12260.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.5799.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 46 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 5375% 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.
+33
View File
@@ -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())
+310
View File
@@ -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()
+119
View File
@@ -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())
+452
View File
@@ -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()
+144
View File
@@ -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">
+1 -4
View File
@@ -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}
-19
View File
@@ -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;
}
+7
View File
@@ -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",
+8 -5
View File
@@ -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>}
+4
View File
@@ -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"
+27
View File
@@ -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(
+25
View File
@@ -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)
+51
View File
@@ -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
)
+7 -1
View File
@@ -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
+5 -2
View File
@@ -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
+5 -3
View File
@@ -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,
)
+9 -1
View File
@@ -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()
+71 -63
View File
@@ -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__":
+51 -9
View File
@@ -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)
+31 -24
View File
@@ -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()
+163
View File
@@ -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),
}
+1
View File
@@ -0,0 +1 @@
"""Offline, source-preserving map derivatives; no capture or vehicle authority."""

Some files were not shown because too many files have changed in this diff Show More