feat: finalize corrected-route planning and Rerun recording review

This commit is contained in:
DCCONSTRUCTIONS
2026-09-22 10:10:03 +03:00
parent c804d89b18
commit 2e5d52521f
132 changed files with 14141 additions and 898 deletions
+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).");
+37 -5
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 {
@@ -181,6 +183,7 @@ export default function App() {
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const closeDisplayRef = useRef<() => void>(() => {});
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
@@ -228,6 +231,13 @@ export default function App() {
);
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayPresented;
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
sceneSettingsRef.current = settings;
displayDraftRef.current = settings;
confirmedSceneSettingsRef.current = settings;
setSceneSettings(settings);
setDisplayDraft(settings);
});
useEffect(() => {
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
@@ -310,6 +320,7 @@ export default function App() {
const remote = runtime.state?.viewerSettings;
if (
!remote ||
replayActiveRef.current ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
@@ -332,7 +343,7 @@ export default function App() {
useEffect(() => {
const profile = workspaceLayoutProfile.profile;
if (!profile) return;
if (!profile || selectedRecordedSessionIdRef.current) return;
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
@@ -403,7 +414,10 @@ export default function App() {
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "display") {
setDisplayWindowOpen(false);
closeDisplayRef.current();
}
if (windowId === "layers") setLayerInspectorOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []);
@@ -464,14 +478,22 @@ export default function App() {
const next = { ...displayDraftRef.current, ...patch };
displayDraftRef.current = next;
setDisplayDraft(next);
sessionDisplayProfile.edited();
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
}
// Preview and persistence are separate. An open inspector must not freeze
// either its controls or the shared accumulation slider below the scene.
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(next);
return;
}
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, viewerSettingsQuietPeriodMs);
}, []);
}, [sessionDisplayProfile.edited]);
const flushDisplaySettings = useCallback(() => {
if (viewerSettingsCommitTimerRef.current === null) return;
@@ -481,8 +503,15 @@ export default function App() {
}, []);
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
sessionDisplayProfile.edited();
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]);
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
closeDisplayRef.current = () => {
const settings = {...displayDraftRef.current};
commitDisplaySettings(settings);
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
};
const openDisplay = () => {
if (!sceneWorkspaceActive) return;
@@ -1036,8 +1065,11 @@ export default function App() {
onClose={() => closeSceneWindow("display")}
>
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
</Window>
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
onDismiss={sessionDisplayProfile.dismissError} />
<Window
open={sceneWorkspaceActive && layerInspectorOpen}
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: "source",
scope: "standalone",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
@@ -8,6 +8,7 @@ import {
} from "./rerun/recordedRerunCameraJournal";
import type { SceneSettings } from "../sceneSettings";
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
import {
advanceLiveReceiverOpenWatchdog,
advanceLiveReceiverWatchdog,
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
sceneSettings?: Pick<
SceneSettings,
| "pointDecimationPercent"
| "accumulationSeconds"
| "showGrid"
| "showPoints"
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
cameraContract?: string | null;
appliedFollowTrajectory?: boolean | null;
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
configureCameraJournal?: (
eye: RecordedRerunCameraEye,
spatialViewportStart: number,
) => void;
getCameraEye?: () => RecordedRerunCameraEye;
setCameraViewportStart?: (spatialViewportStart: number) => void;
getCameraEye?: () => RecordedRerunCameraEye | null;
getCurrentTimeNs?: () => number | null;
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
channel: {
readonly ready: boolean;
send_rrd: (rrdBytes: Uint8Array) => void;
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
currentTimeNs,
reactivateUpdates = false,
onCameraMaxOrbitalRadius,
displayPointBank,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
currentTimeNs?: number | null;
reactivateUpdates?: boolean;
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
displayPointBank?: string | null;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
): Promise<Uint8Array> {
const resolvedUnifiedPerception =
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
// Rerun exposes its interpolated TimeReal cursor as f64 nanoseconds, even
// when paused after seeking. The API needs an integer pose-query timestamp.
// Quantize at this boundary; never round the viewer's actual playback cursor.
const requestTimeNs = currentTimeNs == null ? currentTimeNs : Math.round(currentTimeNs);
const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin);
if (
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
...cameraEye.eyeUp,
].some((value) => !Number.isFinite(value))) ||
(currentTimeNs !== undefined && currentTimeNs !== null && (
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
)) ||
[
perceptionLayers.enabled,
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
application_id: identity.applicationId,
recording_id: identity.recordingId,
blueprint_session_id: blueprintSessionId,
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
accumulation_seconds: settings.accumulationSeconds,
show_grid: settings.showGrid,
show_points: settings.showPoints,
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
eye_up: cameraEye?.eyeUp ?? null,
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
current_time_ns: currentTimeNs,
current_time_ns: requestTimeNs,
}),
show_detections_2d: perceptionLayers.detections2d,
show_camera_image: perceptionLayers.cameraImage ?? true,
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
followTrajectory: boolean;
},
): string {
// Following is a tracking property of the current native eye. It must never
// initialize the browser journal again: doing so replaces the operator's
// current pose with the startup preset immediately before the blueprint is
// sent. Plan/3D and explicit reset are the only preset transitions.
// Following uses the native eye snapshot, never the startup preset.
// Plan/3D and explicit reset are the only preset transitions.
return [activeView, viewResetGeneration, planView].join(":");
}
@@ -841,6 +842,7 @@ function RerunViewportInstance({
const presentationGateRef = useRef(presentationGate);
presentationGateRef.current = presentationGate;
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
const recordedBlueprintUrl = sourceUrl
? resolveRecordedBlueprintUrl(
@@ -857,6 +859,17 @@ function RerunViewportInstance({
const recordedPointColorsUrl = recordedBlueprintUrl
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
const pointDisplay = useRecordedPointDisplay({
endpoint: recordedBlueprintUrl, settings: sceneSettings, revision: blueprintChannelRevision,
sourceGeneration: recordedArtifact?.sha256,
ready: status === 'ready' && pointDisplayVisibilityGate === `${recordedBlueprintUrl}:${blueprintChannelRevision}`,
onLoad: onPointColorLoadChange,
getOwner: () => {
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
return active?.channel.ready && identity ? {identity, send: bytes => active.channel.send_rrd(bytes)} : null;
},
});
const presentationStatus = rerunPresentationStatus(
status,
presentationGate,
@@ -1638,29 +1651,14 @@ function RerunViewportInstance({
cameraContract: null,
appliedFollowTrajectory: null,
pendingFollowCameraEye: null,
configureCameraJournal: (eye, spatialViewportStart) => {
if ("configure_camera_journal" in viewer) {
viewer.configure_camera_journal(eye, spatialViewportStart);
}
},
getCameraEye: () => "get_camera_eye" in viewer
? viewer.get_camera_eye()
: RECORDED_RERUN_ORBITAL_EYE,
setCameraViewportStart: (spatialViewportStart) => {
if ("set_camera_viewport_start" in viewer) {
viewer.set_camera_viewport_start(spatialViewportStart);
}
},
: null,
getCurrentTimeNs: () => {
const currentIdentity = recordedIdentityRef.current;
if (!currentIdentity) return null;
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
},
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if ("set_camera_max_orbital_radius" in viewer) {
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
}
},
channel,
};
blueprintChannelRef.current = blueprintChannel;
@@ -2010,6 +2008,7 @@ function RerunViewportInstance({
useEffect(() => {
if (!recordedPointColorsUrl || !sceneSettings) return;
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
if (
@@ -2089,6 +2088,7 @@ function RerunViewportInstance({
sceneSettings?.colorMode,
sceneSettings?.customColor,
sceneSettings?.palette,
sceneSettings?.pointDecimationPercent,
]);
useEffect(() => {
@@ -2113,12 +2113,6 @@ function RerunViewportInstance({
},
);
const cameraContractChanged = active.cameraContract !== cameraContract;
if (cameraContractChanged) {
active.configureCameraJournal?.(
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
}
const abort = new AbortController();
const previousFollow = active.appliedFollowTrajectory ?? false;
const enablingFollow = recordedFollowTrajectory && !previousFollow;
@@ -2143,7 +2137,9 @@ function RerunViewportInstance({
if (eyeRelativeToTracking && currentTimeNs == null) {
throw new Error("Recorded tracking cursor is unavailable");
}
return fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
return fetchRecordedBlueprintRrd(recordedBlueprintUrl,
{...sceneSettings, showPoints: sceneSettings.showPoints && !pointDisplay.hidePoints}, identity, {
displayPointBank: pointDisplay.bank,
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
signal: abort.signal,
@@ -2157,13 +2153,11 @@ function RerunViewportInstance({
planView: recordedPlanView,
cameraEye,
eyeRelativeToTracking,
currentTimeNs,
// Only following-eye transitions need a pose at the cursor. Supplying
// time for display-only updates makes the API scan the entire archive
// for camera bounds, although neither the camera nor its pose changed.
currentTimeNs: eyeRelativeToTracking ? currentTimeNs : undefined,
reactivateUpdates,
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if (blueprintChannelRef.current === active) {
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
},
});
};
const canApply = () => (
@@ -2175,24 +2169,29 @@ function RerunViewportInstance({
const applyPayload = (payload: Uint8Array) => {
if (!canApply()) return false;
active.channel.send_rrd(payload);
active.setCameraViewportStart?.(
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
// Exclude inactive point generations before admitting their data into
// cached recordings whose embedded blueprint predates display controls.
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
return true;
};
void (async () => {
const firstEye = disablingFollow
? transitionEye ?? undefined
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
? pendingFollowEye ?? active.getCameraEye?.()
: undefined;
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
disablingFollow || recordedFollowTrajectory
);
const firstPayload = await requestBlueprint(
firstEye,
firstEyeIsTrackingRelative,
enablingFollow || disablingFollow || Boolean(pendingFollowEye),
// Upstream copies incoming blueprints into an active store. Every
// settings update must activate it; merely appending rows is invisible.
// Activation copies the incoming blueprint store, not the operator's
// edited store. Preserve its actual native eye on display-only updates;
// omitting the eye resurrects the embedded startup camera.
true,
);
if (!applyPayload(firstPayload)) return;
active.cameraContract = cameraContract;
@@ -2213,9 +2212,10 @@ function RerunViewportInstance({
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
if (!applyPayload(stabilizedPayload)) return;
active.pendingFollowCameraEye = null;
})().catch(() => {
})().catch((error: unknown) => {
// The recording remains usable with its embedded default blueprint.
// A later settings change retries through the same small channel.
if (!abort.signal.aborted) console.warn("Recorded scene settings were not applied", error);
});
return () => abort.abort();
}, [
@@ -2243,6 +2243,9 @@ function RerunViewportInstance({
sceneSettings?.showPoints,
sceneSettings?.showTrajectory,
sceneSettings?.showGrid,
pointDisplay.ready,
pointDisplay.bank,
pointDisplay.hidePoints,
]);
return (
@@ -2,11 +2,11 @@ import type {ReactNode} from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
export function MissionZonePreview({ sessionId, toolbar }: { sessionId: string; toolbar?:ReactNode }) {
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
const { data, error, retry } = useSessionOverview(sessionId);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
return <SessionOverviewScene sourceUrl={data.scene_url} toolbar={toolbar} hideTitle />;
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
}
@@ -1,53 +1,30 @@
import { Button, Icon, Inspector, InspectorSelectField, LoadingRegion, RangeControl, SegmentedControl, TextField } from '@nodedc/ui-react';
import { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
export function PlanningProjectSettings({p,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
return <Inspector variant="panel" defaultOpen={['project','zone','route','query']} sections={[
const routeReady=canStartPlanningRoute(length,'scanner');
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
</div>},
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
{p.source&&<small>{p.source.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
</div>},
{id:'route',label:'Участок эталона',icon:<Icon name="plan"/>,content:<div className="inspector-control-stack">
{p.source&&!p.sourceChanged?<>
<RangeControl label="Начало участка" value={p.source.poses[p.start]?.distance_m??0} min={0} max={p.source.poses[p.source.poses.length-2].distance_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>{const n=indexAtDistance(p.source!,distance,0,p.source!.poses.length-2);p.setStart(n);if(n>=p.end)p.setEnd(n+1);}}/>
<RangeControl label="Конец участка" value={p.source.poses[p.end]?.distance_m??0} min={p.source.poses[p.start+1]?.distance_m??0} max={p.source.path_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>p.setEnd(indexAtDistance(p.source!,distance,p.start+1,p.source!.poses.length-1,true))}/>
<Button disabled={disabled} onClick={()=>p.setEnd(endAtDistance(p.source!,p.start,30))}>30 м от начала участка</Button>
<Button disabled={disabled} onClick={()=>{p.setStart(0);p.setEnd(p.source!.poses.length-1);}}>Вся траектория</Button>
{p.source&&<>
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
<small>{length.toFixed(2)} м · {p.poses.length.toLocaleString('ru-RU')} положений</small>
</>:<p>Выберите сохранённую запись эталона.</p>}
</div>},
{id:'query',label:'Повторный проход',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<SegmentedControl label="Источник повторного прохода" value={mode} onChange={setMode} items={[{value:'scanner',label:'Новый проход',disabled},{value:'recording',label:'Из записи',disabled}]}/>
{mode==='scanner'?<p>После запуска откроется подключение сканера. Новый проход записывается отдельным проектом.</p>:<>
<InspectorSelectField label="Повторная запись" value={t.sessionId} disabled={disabled} searchable onChange={t.setSessionId}
options={[{value:'',label:'Выберите повторный проход'},...p.sessions.filter(canSelectSession).map(s=>({value:s.id,label:s.label,description:s.id===p.sessionId?'Та же запись · внутренняя проверка':'Сохранённые облако и траектория'}))]}/>
<LoadingRegion loading={t.loading} label="Подготовка повторного прохода">
{t.source&&<div className="inspector-control-stack">
<RangeControl label="Начало повторного участка" min={0} max={t.source.poses[t.source.poses.length-2].distance_m} step="any" value={t.source.poses[t.start].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`}
onChange={n=>{const start=indexAtDistance(t.source!,n,0,t.source!.poses.length-2);t.setStart(start);t.setEnd(endAtDistance(t.source!,start,20));}}/>
<RangeControl label="Конец повторного участка" min={t.source.poses[t.start+1].distance_m} max={t.source.path_m} step="any" value={t.source.poses[t.end].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`} onChange={n=>t.setEnd(indexAtDistance(t.source!,n,t.start+1))}/>
</div>}
</LoadingRegion>
</>}
<small>{mode==='scanner'
?'Длина выбранного участка задаёт предел прохода. Ограничения по времени нет; запись сканера не останавливается автоматически. Неподвижная калибровка просматривает весь выбранный маршрут. После калибровки оставайтесь на месте до статуса «Сопровождение»; при отсутствии или неоднозначности совпадения сцена прямо сообщит причину и не начнёт сопровождение.'
:'Участки от 3 до 40 м. Начальная привязка — выбранное место старта и одинаковое направление.'}</small>
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!canStartPlanningRoute(length,mode)||(mode==='recording'&&(!t.source||t.loading||!canStartPlanningRoute(t.length,'recording')))} onClick={onStart}><Icon name="play"/>{mode==='scanner'?'Начать новый проход':'Запустить совмещение'}</Button>
{(p.error||t.error)&&<p role="alert">{p.error||t.error}</p>}
</div>},
]}/>;
]}/>
<div className="inspector-control-stack">
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
{p.error&&<p role="alert">{p.error}</p>}
</div>
</>;
}
@@ -2,10 +2,10 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode } from "../../core/observation/sessionOverviewSpatial";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean }) {
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [retry, setRetry] = useState(0);
@@ -14,6 +14,10 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
const [mode, setMode] = useState<OverviewViewMode>("3d");
const [viewError, setViewError] = useState<string | null>(null);
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
const [updating, setUpdating] = useState(false);
const comparison = metadata?.comparison;
const appliedMode = useRef<OverviewViewMode | null>(null);
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
useEffect(() => {
@@ -23,7 +27,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
appliedMode.current = null;
setState("loading");
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(setMetadata).catch(() => { if (!disposed) setViewError("Параметры среза недоступны."); });
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
if (disposed) return;
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
@@ -44,29 +52,33 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
useEffect(() => {
if (state !== "ready" || !metadata || !controller.current) return;
const abort = new AbortController();
setUpdating(true);
const timer = setTimeout(() => {
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal).then(result => {
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
}).catch(() => { if (!abort.signal.aborted) setViewError("Не удалось обновить вид облака."); });
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry]);
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
const low = metadata?.height_min_m;
const high = metadata?.height_max_m;
const low = comparison?.height_min_m ?? metadata?.height_min_m;
const high = comparison?.height_max_m ?? metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
{toolbar}
</div>
<LoadingRegion loading={state === "loading"} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
@@ -74,7 +86,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
</div>}
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</LoadingRegion>
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? ""} точек</span>}
<span className="session-overview__note" role="status" aria-busy={updating}>
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
</span>
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</>;
}
@@ -1,244 +1,28 @@
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
type Vector3 = [number, number, number];
type DragMode = "rotate" | "pan" | "roll";
const ROTATION_RADIANS_PER_POINT = 0.004;
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
const DOM_WHEEL_DELTA_LINE = 1;
const DOM_WHEEL_DELTA_PAGE = 2;
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18],
lookTarget: [0, 0, 0],
eyeUp: [0, 0, 1],
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30],
lookTarget: [0, 0, 0],
eyeUp: [0, 1, 0],
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
};
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vector3, b: Vector3): Vector3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const length = (value: Vector3) => Math.hypot(...value);
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
const magnitude = length(value);
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
};
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
const axis = normalize(rawAxis, [0, 0, 1]);
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return add(
add(scale(value, cosine), scale(cross(axis, value), sine)),
scale(axis, dot(axis, value) * (1 - cosine)),
);
};
function cloneEye(value: RecordedRerunCameraEye): {
position: Vector3;
lookTarget: Vector3;
eyeUp: Vector3;
} {
return {
position: vector(value.position),
lookTarget: vector(value.lookTarget),
eyeUp: vector(value.eyeUp),
};
}
/**
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
* renderer. Rerun writes operator navigation into its active blueprint clone;
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
* here lets a later layer blueprint activate with the exact operator pose.
*/
export function createRecordedRerunCameraJournal(
canvas: HTMLCanvasElement,
scope: Window & typeof globalThis,
) {
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
let spatialViewportStart = 0;
let pointerId: number | null = null;
let dragMode: DragMode | null = null;
let previousPointer: readonly [number, number] | null = null;
let latestPointer: readonly [number, number] | null = null;
let maxOrbitalRadius = 1.0e17;
const isSpatialPoint = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
};
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
const usableUp = () => {
const fwd = forward();
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
? [1, 0, 0]
: cross(fwd, [0, 0, 1]);
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
const candidate = normalize(eye.eyeUp, fallback);
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
};
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
const rotate = (deltaX: number, deltaY: number) => {
const radius = orbitRadius();
const up = usableUp();
let fwd = forward();
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
fwd = normalize(
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
fwd,
);
const right = normalize(cross(fwd, up), [1, 0, 0]);
const maxPitch = 0.99 * Math.PI / 2;
const nextPitch = clamp(
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
-maxPitch,
maxPitch,
);
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
};
const pan = (deltaX: number, deltaY: number) => {
const fwd = forward();
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
const speed = 0.001 * orbitRadius();
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
eye.position = add(eye.position, translation);
eye.lookTarget = add(eye.lookTarget, translation);
};
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
const rect = canvas.getBoundingClientRect();
const left = rect.left + rect.width * spatialViewportStart;
const centerX = left + (rect.right - left) / 2;
const centerY = rect.top + rect.height / 2;
const relativeX = event.clientX - centerX;
const relativeY = event.clientY - centerY;
const divisor = relativeX * relativeX + relativeY * relativeY;
if (divisor <= Number.EPSILON) return;
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
};
const pointerDown = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (!isSpatialPoint(event)) return;
pointerId = event.pointerId;
previousPointer = [event.clientX, event.clientY];
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
};
const pointerMove = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
const deltaX = event.clientX - previousPointer[0];
const deltaY = event.clientY - previousPointer[1];
previousPointer = [event.clientX, event.clientY];
if (dragMode === "rotate") rotate(deltaX, deltaY);
else if (dragMode === "pan") pan(deltaX, deltaY);
else roll(event, deltaX, deltaY);
};
const pointerEnd = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
pointerMove(event);
pointerId = null;
previousPointer = null;
dragMode = null;
};
const wheel = (event: WheelEvent) => {
const rect = canvas.getBoundingClientRect();
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
? event.clientX
: latestPointer?.[0];
if (wheelX === undefined || rect.width <= 0) return;
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
? RERUN_WEB_LINE_SCROLL_POINTS
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
? rect.height
: 1;
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
// eframe negates the DOM delta before Rerun applies
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
// exponent products collapse to the raw accumulated DOM delta below.
const divisor = event.ctrlKey
? RERUN_WEB_PINCH_SCROLL_DIVISOR
: RERUN_ORBITAL_SCROLL_DIVISOR;
const radiusFactor = Math.exp(scrollPoints / divisor);
const offset = subtract(eye.position, eye.lookTarget);
const radius = length(offset);
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
// the scene-derived cap is allowed to stay there and is never snapped in.
const nextRadius = clamp(
radius * radiusFactor,
RERUN_MIN_ORBIT_DISTANCE,
Math.max(radius, maxOrbitalRadius),
);
eye.position = add(
eye.lookTarget,
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
);
};
canvas.addEventListener("pointerdown", pointerDown, true);
scope.addEventListener("pointermove", pointerMove, true);
scope.addEventListener("pointerup", pointerEnd, true);
scope.addEventListener("pointercancel", pointerEnd, true);
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
return {
current(): RecordedRerunCameraEye {
return {
position: [...eye.position],
lookTarget: [...eye.lookTarget],
eyeUp: [...eye.eyeUp],
};
},
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
eye = cloneEye(nextEye);
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
pointerId = null;
dragMode = null;
previousPointer = null;
},
setSpatialViewportStart(nextSpatialViewportStart: number) {
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
},
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
if (
Number.isFinite(nextMaxOrbitalRadius) &&
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
) {
maxOrbitalRadius = nextMaxOrbitalRadius;
}
},
dispose() {
canvas.removeEventListener("pointerdown", pointerDown, true);
scope.removeEventListener("pointermove", pointerMove, true);
scope.removeEventListener("pointerup", pointerEnd, true);
scope.removeEventListener("pointercancel", pointerEnd, true);
scope.removeEventListener("wheel", wheel, true);
},
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
if (value == null) return null; // No 3D frame rendered yet.
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
const eye = value as Record<string, unknown>;
const vector = (key: string): [number, number, number] => {
const item = eye[key];
if (!Array.isArray(item) || item.length !== 3 ||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
throw new Error("Invalid native Rerun camera vector");
}
return [item[0], item[1], item[2]];
};
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
}
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
get_camera_eye: () => RecordedRerunCameraEye;
set_camera_viewport_start: (spatialViewportStart: number) => void;
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
get_camera_eye: () => RecordedRerunCameraEye | null;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
@@ -90,16 +87,7 @@ export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
configure_camera_journal: (eye, spatialViewportStart) => command(
"configure-camera-journal", [eye, spatialViewportStart],
),
get_camera_eye: () => command("get-camera-eye"),
set_camera_viewport_start: (spatialViewportStart) => command(
"set-camera-viewport-start", [spatialViewportStart],
),
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
"set-camera-max-orbital-radius", [maxOrbitalRadius],
),
};
return { facade, dispose, notify };
}
@@ -1,6 +1,6 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
cameraJournal?.dispose();
cameraJournal = null;
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting && starting.canvas) {
cameraJournal?.dispose();
cameraJournal = createRecordedRerunCameraJournal(
starting.canvas,
window as Window & typeof globalThis,
);
}
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
@@ -87,10 +77,9 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
channels.delete(id);
}
break;
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
case "get-camera-eye": result = cameraJournal?.current(); break;
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
case "get-camera-eye": result = readNativeRerunCameraEye(
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
@@ -0,0 +1,66 @@
import {useEffect, useRef, useState} from 'react';
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
import type {RecordedPointColorLoadState} from '../RerunViewport';
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
sourceGeneration?: string;
}) {
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
const loadRef = useRef(onLoad); loadRef.current = onLoad;
const failed = useRef(false);
const [retry, setRetry] = useState(0);
const key = settings ? pointDisplayKey(settings) : '';
const percent = settings?.pointDecimationPercent ?? 0;
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
const matched = sameSource && applied?.key === key;
useEffect(() => {
// A new settings interaction retries a failed request, but accumulation or
// size edits must not cancel an already-running preparation of the same key.
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
}, [settings]);
useEffect(() => {
if (!endpoint || !settings || !ready) return;
if (percent === 0 || percent === 100) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
return;
}
if (matched) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: 'Прореженное облако готово.'});
return;
}
const owner = ownerRef.current();
if (!owner) return;
const abort = new AbortController();
failed.current = false;
// Never append another generation at the same temporal entity path: an
// accumulated range would otherwise draw older samples as well.
const bank = crypto.randomUUID().replaceAll('-', '');
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
if (!abort.signal.aborted) owner.send(chunk);
}, undefined, sourceGeneration).then(bytes => {
if (abort.signal.aborted) return;
setApplied({endpoint, revision, key, bank, sourceGeneration});
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
message: 'Прореженное облако готово.'});
}).catch(() => {
if (!abort.signal.aborted) {
failed.current = true;
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
}
});
return () => abort.abort();
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
hidePoints: percent === 100};
}
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
import { useCallback, useEffect, useMemo, useState } from "react";
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const initializeRoute = useRef(true);
const [sessions, setSessions] = useState<SessionOption[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Draft[]>([]);
@@ -10,8 +9,6 @@ export function useMissionPlanner() {
const [name, setName] = useState("");
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(1);
const [direction, setDirection] = useState<Direction>("forward");
const [error, setError] = useState<string | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
@@ -19,12 +16,13 @@ export function useMissionPlanner() {
const [catalogBusy, setCatalogBusy] = useState(true);
const [loadVersion, setLoadVersion] = useState(0);
const [sourceVersion, setSourceVersion] = useState(0);
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
const [check, setCheck] = useState<RouteCheck | null>(null);
useEffect(() => {
const abort = new AbortController(); setCatalogBusy(true); setError(null);
void Promise.all([
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
@@ -34,26 +32,29 @@ export function useMissionPlanner() {
useEffect(() => {
const abort = new AbortController(); setSource(null); setSourceError(null);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) { const next = validatePlanningSource(data, sessionId); setSource(next); if (initializeRoute.current) { setStart(0); setEnd(endAtDistance(next, 0, 30)); initializeRoute.current = false; } } })
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion]);
}, [sessionId, sourceVersion, pinnedGeneration]);
const currentSource = source?.session_id === sessionId ? source : null;
// New executions always own the complete reference. No independent interval
// state can survive a source change or collapse on same-source reselection.
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
const chooseSource = (id: string) => { initializeRoute.current = true; setSessionId(id); setStart(0); setEnd(1); setCheck(null); };
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
const openDraft = async (id: string) => {
setBusy(true); setError(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
setStart(next.route.start_index); setEnd(next.route.end_index); setDirection(next.route.direction); setCheck(null);
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
setDirection(next.route.direction); setCheck(null);
setSourceVersion(n => n + 1);
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
@@ -63,7 +64,7 @@ export function useMissionPlanner() {
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
generation: currentSource.generation, start_index: start, end_index: end, direction,
generation: currentSource.generation, whole_recording: true, direction,
}) });
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
return next;
@@ -79,11 +80,11 @@ export function useMissionPlanner() {
if (!cursor) return;
setCatalogBusy(true);
try {
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
};
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource, start, setStart, end, setEnd,
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource } from "./planner";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
export interface RegistrationReport {
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
@@ -15,6 +15,25 @@ export function useRegistrationTest(draft: Draft | null) {
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
const [catalogBusy,setCatalogBusy]=useState(false);
// Repeat recordings belong to the planner, including its own captured passes;
// they are not restricted to the independent-reference catalog.
useEffect(()=>{
const abort=new AbortController();setCatalogBusy(true);
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
return()=>abort.abort();
},[]);
const loadMore=async()=>{
if(!cursor||catalogBusy)return;setCatalogBusy(true);
try{
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
};
useEffect(() => {
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
@@ -35,5 +54,5 @@ export function useRegistrationTest(draft: Draft | null) {
return data;
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
};
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run };
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
}
@@ -0,0 +1,68 @@
import type {SceneSettings} from '../../sceneSettings';
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
export function pointDisplayKey(settings: PointDisplaySettings): string {
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
}
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
export async function streamRecordedPointDisplay(
blueprintUrl: string, settings: PointDisplaySettings,
identity: {applicationId: string; recordingId: string}, displayBank: string,
signal: AbortSignal, send: (chunk: Uint8Array) => void,
fetcher: typeof fetch = fetch,
sourceGeneration?: string,
): Promise<number> {
const endpoint = new URL(blueprintUrl, window.location.origin);
const percent = settings.pointDecimationPercent ?? 0;
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
point_decimation_percent: percent, display_bank: displayBank,
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
throw new Error('Не удалось подготовить прореженное облако');
}
const reader = response.body.getReader();
let bytes = 0;
let pending = new Uint8Array(0);
let admitted = false;
let complete = false;
try {
while (true) {
const {done, value} = await reader.read();
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
if (done) break;
bytes += value.byteLength;
const combined = new Uint8Array(pending.length + value.length);
combined.set(pending); combined.set(value, pending.length); pending = combined;
if (!admitted) {
if (pending.length < 4) continue;
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
pending = pending.slice(4); admitted = true;
}
while (pending.length >= 4 && !complete) {
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
if (length === 0) { pending = pending.slice(4); complete = true; break; }
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
if (pending.length < length + 4) break;
const payload = pending.slice(4, length + 4);
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
send(payload); pending = pending.slice(length + 4);
}
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
}
if (!complete || pending.length) throw new Error('Поток облака не завершён');
return bytes;
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
@@ -23,7 +23,7 @@ export type ObservationSessionStatus =
| "interrupted"
| "failed";
export type ObservationSessionScope = "all" | "source" | "laboratory";
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
export interface ObservationLabInstance {
labId: string;
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
seekable: true;
byteLength: number;
sha256: string;
mapGeneration?: string;
playback: {
speed: number;
loop: boolean;
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
"seekable",
"byte_length",
"sha256",
"map_generation",
"playback",
"media_sources",
]);
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
}
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
}
const viewerSourceUrl = requireString(
launch.viewer_source_url,
"launch.viewer_source_url",
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
integer: true,
}),
sha256: launch.sha256,
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
playback: { speed, loop: launch.playback.loop },
mediaSources,
};
@@ -0,0 +1,54 @@
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
const schema = 'missioncore.session-display-profile/v1';
const fields = {
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
} as const;
function endpoint(sessionId: string) {
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
}
export function encodeSessionDisplay(settings: SceneSettings) {
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
}
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
}
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
}
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
throw new Error('Некорректные настройки отображения');
}
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
}
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
const document = await response.json();
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
: decodeSessionDisplay(document, sessionId);
}
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
decodeSessionDisplay(await response.json(), sessionId);
}
@@ -1,5 +1,14 @@
export type OverviewViewMode = "3d" | "top";
export interface OverviewSpatialMetadata { height_min_m: number | null; height_max_m: number | null; sample_points: number; }
export type OverviewRepresentation = "original" | "corrected";
export interface OverviewComparison {
generation: string; map_generation: string; sample_points: number; source_points: number;
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
}
export interface OverviewSpatialMetadata {
default_representation?: OverviewRepresentation;
height_min_m: number | null; height_max_m: number | null; sample_points: number;
comparison?: OverviewComparison;
}
function endpoint(source: string) {
const url = new URL(source, window.location.origin);
@@ -12,12 +21,15 @@ export async function fetchOverviewSpatial(source: string, signal: AbortSignal):
if (!response.ok) throw new Error("Параметры среза недоступны.");
return response.json();
}
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal) {
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
const url = endpoint(source);
const generation = url.searchParams.get("generation");
url.search = "";
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect }) });
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
comparison_generation: comparisonGeneration, representation }) });
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
@@ -0,0 +1,38 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
const [error, setError] = useState<string | null>(null);
const applyRef = useRef(apply); applyRef.current = apply;
const current = useRef(sessionId); current.current = sessionId;
const writeTail = useRef(Promise.resolve());
const revision = useRef(0);
useEffect(() => {
setError(null);
if (!sessionId) return;
const abort = new AbortController();
const readRevision = revision.current;
applyRef.current({...defaultSceneSettings});
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
if (abort.signal.aborted || current.current !== sessionId) return;
if (revision.current === readRevision) applyRef.current(settings);
}).catch(reason => {
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
});
return () => abort.abort();
}, [sessionId]);
const edited = useCallback(() => { revision.current += 1; }, []);
const save = useCallback((settings: SceneSettings) => {
const id = current.current;
if (!id) return;
const snapshot = {...settings};
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
if (current.current === id) setError(null);
}).catch(reason => {
if (current.current === id) setError(String(reason.message ?? reason));
});
}, []);
return {save, edited, error, dismissError: () => setError(null)};
}
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import { useRegistrationTest } from '../../core/missions/useRegistrationTest';
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
@@ -17,9 +16,7 @@ import '../../styles/mission-planner.css';
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const t=useRegistrationTest(p.saved);
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
const [view,setView]=useState<'cloud'|'route'>('cloud');
const [starting,setStarting]=useState(false);
const [pending,setPending]=useState<(()=>void)|null>(null);
@@ -39,7 +36,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
};
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
const newProject=()=>replace(()=>{
projects.select('');p.newDraft();t.setSessionId('');setMode('scanner');setView('cloud');
projects.select('');p.newDraft();setView('cloud');
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
});
const start=async()=>{
@@ -47,13 +44,8 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
try {
const draft=p.saved&&!p.dirty?p.saved:await p.save();
if(!draft)return;
if(mode==='scanner') {
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
} else {
const result=await t.run(draft);
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
}
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
projects.refresh();
} finally {setStarting(false);}
};
@@ -81,13 +73,13 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} generation={p.source.generation} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
:project?<>
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись остаётся в разделе «Данные».</small></div>}
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись прохода сохранена отдельно от результата.</small></div>}
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
@@ -96,7 +88,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
className="planning-project__inspector">
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
{editing?<PlanningProjectSettings p={p} onStart={()=>void start()} starting={starting}/>
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
{live.error&&editing&&<p role="alert">{live.error}</p>}
@@ -46,11 +46,11 @@ export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
separatorLabel="Высота графика интервалов" className="session-overview__split"
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
primary={<GlassSurface className="session-overview__panel" radius="panel">
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} compareVersions /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
</GlassSurface>}
secondary={<GlassSurface className="session-overview__panel" radius="panel">
<h2>Сведения о записи</h2><div className="session-overview__facts">
<h2>Сведения об исходной записи</h2><div className="session-overview__facts">
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
</div>
@@ -554,7 +554,7 @@ export function SpatialWorkspace({
) : null}
</>}
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
@@ -572,6 +572,7 @@ export function SpatialWorkspace({
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
accumulationMaxSeconds={sceneSettings.accumulationMaxSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
@@ -184,6 +184,15 @@ function preparation(overrides = {}) {
};
}
test("replay preserves an optional corrected-map identity and rejects malformed versions", () => {
const decoded = decodeObservationSessionReplay(replay({ map_generation: "b".repeat(64) }));
assert.equal(decoded.mapGeneration, "b".repeat(64));
assert.equal(decodeObservationSessionReplay(replay()).mapGeneration, undefined);
for (const value of [null, "latest", "../private", 123]) {
assert.throws(() => decodeObservationSessionReplay(replay({ map_generation: value })), /версию/);
}
});
const preparationEtag = '"prepare-20260717T131400Z"';
test("session catalog decodes canonical snake_case into a path-free camelCase model", () => {
@@ -412,7 +421,7 @@ test("data recordings keep the compact session dropdown and laboratory results s
assert.doesNotMatch(laboratorySource, /fetchAdvancedLaboratoryResults/);
});
test("recording preparation statuses share the viewer's left alignment", async () => {
test("recording preparation statuses occupy the viewer's upper-right corner", async () => {
const spatialStyles = await readFile(
new URL("../../../packages/spatial-ui/src/spatial.css", import.meta.url),
"utf8",
@@ -422,9 +431,10 @@ test("recording preparation statuses share the viewer's left alignment", async (
spatialStyles.indexOf(".scene-operation-status {"),
);
assert.match(statusStack, /left:\s*0\.85rem/);
assert.match(statusStack, /justify-items:\s*start/);
assert.doesNotMatch(statusStack, /right:/);
assert.match(statusStack, /right:\s*0\.85rem/);
assert.match(statusStack, /top:\s*0\.85rem/);
assert.match(statusStack, /justify-items:\s*end/);
assert.doesNotMatch(statusStack, /left:|bottom:/);
});
test("source and laboratory catalogs are requested as disjoint backend projections", async () => {
@@ -150,14 +150,15 @@ test("follow toggles retain the current recorded camera journal", () => {
assert.notEqual(afterReset, before);
});
test("observation camera windows tile from the bottom-right above the live timeline", () => {
test("observation camera windows tile from the bottom-left above the live timeline", () => {
const bounds = { width: 1280, height: 720 };
const left = initialObservationWindowRect(0, 2, bounds);
const right = initialObservationWindowRect(1, 2, bounds);
assert.equal(left.y, right.y);
assert.ok(left.x + left.width < right.x);
assert.equal(right.x + right.width, bounds.width - 18);
assert.equal(left.x, 18);
assert.ok(right.x + right.width <= bounds.width - 18);
assert.ok(right.y + right.height <= bounds.height - 64);
});
@@ -323,10 +324,14 @@ test("recorded observation timeline rejects empty and non-finite ranges", () =>
test("accumulation control normalizes UI values and distinguishes a single frame", () => {
assert.equal(normalizeAccumulationSeconds(-3), 0);
assert.equal(normalizeAccumulationSeconds(12.6), 13);
assert.equal(normalizeAccumulationSeconds(999), 120);
assert.equal(normalizeAccumulationSeconds(999), 999);
assert.equal(normalizeAccumulationSeconds(1800), 1800);
assert.equal(normalizeAccumulationSeconds(9999), 9999);
assert.equal(normalizeAccumulationSeconds(Number.NaN), 0);
assert.equal(formatAccumulationDuration(0), "Кадр");
assert.equal(formatAccumulationDuration(12), "12 с");
assert.equal(formatAccumulationDuration(1800), "30 мин");
assert.equal(formatAccumulationDuration(125), "2 мин 5 с");
});
test("spatial timeline renders synchronized accumulation and playback controls", () => {
@@ -606,6 +611,32 @@ test("recorded blueprint fetch is bounded, strict and sends only display setting
);
});
test("recorded tracking quantizes the native fractional-nanosecond cursor without accepting invalid times", async () => {
const bodies = [];
const request = currentTimeNs => fetchRecordedBlueprintRrd(
"/api/v1/observation-sessions/session-1/blueprint.rrd",
{accumulationSeconds: 10, showGrid: true, showPoints: true,
showTrajectory: true, pointSize: 0.5, colorMode: "intensity",
palette: "turbo", customColor: "#ffffff"},
{applicationId: "nodedc_mission_core_recorded", recordingId: "recording-001"},
{origin: "http://127.0.0.1:8000", blueprintSessionId: "a".repeat(32),
cameraEye: {position: [3, 4, 5], lookTarget: [1, 2, 0], eyeUp: [0, 0, 1]},
eyeRelativeToTracking: true, currentTimeNs,
fetcher: async (_input, init) => {
bodies.push(JSON.parse(init.body));
return new Response(new Uint8Array([0x52, 0x52, 0x46, 0x32]), {
headers: {"Content-Type": "application/vnd.rerun.rrd"},
});
}},
);
await request(536_460_021_972.65625);
assert.equal(bodies[0].current_time_ns, 536_460_021_973);
for (const invalid of [-0.1, NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) {
await assert.rejects(request(invalid), /Unsafe recorded blueprint request/);
}
assert.equal(bodies.length, 1);
});
test("recorded point colors use one strict same-origin component overlay", async () => {
const endpoint = resolveRecordedPointColorsUrl(
"/api/v1/observation-sessions/session-1/recording.rrd",
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
import {readFile} from 'node:fs/promises';
let server,usePlanner,Settings;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({useMissionPlanner:usePlanner}=await server.ssrLoadModule('/src/core/missions/useMissionPlanner.ts'));
({PlanningProjectSettings:Settings}=await server.ssrLoadModule('/src/components/missions/PlanningProjectSettings.tsx'));
});
after(async()=>{await server?.close();});
// Bounded hook dispatcher: execute the real state/effect transitions, without
// mounting another browser or replacing the production hook with a fake.
function harness(){
const slots=[];let index=0,pending=[];
const hooks={
useState(initial){const i=index++;if(!slots[i])slots[i]={value:initial};return [slots[i].value,v=>{slots[i].value=typeof v==='function'?v(slots[i].value):v;}];},
useEffect(fn,deps){const i=index++,old=slots[i];if(!old||deps.some((d,n)=>!Object.is(d,old.deps[n])))pending.push(()=>{old?.cleanup?.();slots[i]={deps,cleanup:fn()};});},
useMemo(fn){return fn();},useCallback(fn){return fn;},
};
const render=()=>{index=0;const internal=React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,old=internal.H;internal.H=hooks;try{return usePlanner();}finally{internal.H=old;}};
return {render,async settle(){let state;for(let n=0;n<5;n++){state=render();const effects=pending;pending=[];effects.forEach(fn=>fn());await new Promise(resolve=>setImmediate(resolve));}return render();},close(){slots.forEach(s=>s.cleanup?.());}};
}
const source={schema_version:'missioncore.planning-source/v1',session_id:'ring',generation:'a'.repeat(64),label:'Ring',units:'m',path_m:580,
poses:Array.from({length:60},(_,index)=>({index,position:[index*10,0,0],distance_m:index*10}))};
test('same recording reselection cannot collapse the reference or disable a named project',async()=>{
const oldFetch=globalThis.fetch,requests=[];const h=harness();
globalThis.fetch=async(url,options={})=>{
requests.push({url,body:options.body&&JSON.parse(options.body)});
if(options.method==='POST')return {ok:true,json:async()=>({id:'draft',revision:1,name:'Seam',zone:{session_id:'ring',generation:source.generation},route:{start_index:0,end_index:59,direction:'forward'}})};
return {ok:true,json:async()=>url.includes('/sources/')?source:{items:[],next_cursor:null}};
};
try{
let p=await h.settle();p.chooseSource('ring');p.setName('Seam');p=await h.settle();
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
for(let i=0;i<4;i++){p.chooseSource('ring');p=await h.settle();assert.equal(p.poses.length,60);assert.equal(p.ready,true);}
await p.save();const body=requests.find(r=>r.body)?.body;
assert.equal(body.whole_recording,true);assert.equal(body.start_index,undefined);assert.equal(body.end_index,undefined);
assert.ok(requests.some(r=>r.url.includes('scope=standalone')));
p=h.render();p.newDraft();p=await h.settle();p.chooseSource('ring');p.setName('Next');p=await h.settle();
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
assert.equal(p.setStart,undefined);assert.equal(p.setEnd,undefined);
}finally{h.close();globalThis.fetch=oldFetch;}
});
test('product removes reference crop controls and Data requests independent captures',async()=>{
const settings=await readFile(new URL('../src/components/missions/PlanningProjectSettings.tsx',import.meta.url),'utf8');
assert.doesNotMatch(settings,/Участок эталона|Начало участка|Конец участка|30 м от начала участка|p\.set(Start|End)/);
assert.match(settings,/Вся запись/);
const selector=await readFile(new URL('../src/components/ObservationSessionSelect.tsx',import.meta.url),'utf8');
assert.match(selector,/scope: "standalone"/);
});
test('planner offers one direct live start without a repeated-pass mode or catalogue',async()=>{
const p={name:'Ring',poses:source.poses,source,sessionId:'ring',options:[{value:'ring',label:'Ring'}],direction:'forward',ready:true};
const render=(overrides={},starting=false)=>renderToStaticMarkup(React.createElement(Settings,{p:{...p,...overrides},starting,onStart:()=>{}}));
const ready=render();
assert.doesNotMatch(ready,/Повторный проход|Из записи|Источник повторного прохода|Повторная запись|После запуска/);
const startButton=html=>[...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(m=>m[0]).find(button=>button.includes('Начать новый проход'));
assert.ok(startButton(ready));
assert.doesNotMatch(startButton(ready),/disabled=/);
for(const state of [{ready:false},{busy:true},{poses:source.poses.slice(0,1)}])assert.match(startButton(render(state)),/disabled=/);
assert.match(startButton(render({},true)),/disabled=/);
let started=0;
const tree=Settings({p,starting:false,onStart:()=>{started++;}});
tree.props.children[1].props.children[1].props.onClick();
assert.equal(started,1);
const workspace=await readFile(new URL('../src/workspaces/missions/MissionPlannerWorkspace.tsx',import.meta.url),'utf8');
assert.doesNotMatch(workspace,/useRegistrationTest|setMode|t\.run\(/);
assert.match(workspace,/live\.begin\(draft\)/);
});
@@ -1,101 +1,42 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { after, before, test } from "node:test";
import { createServer } from "vite";
let server, createRecordedRerunCameraJournal, initialEye;
let server, readNativeRerunCameraEye;
before(async () => {
server = await createServer({ appType: "custom", logLevel: "silent",
server: { middlewareMode: true } });
const module = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunCameraJournal.ts");
createRecordedRerunCameraJournal = module.createRecordedRerunCameraJournal;
initialEye = module.RECORDED_RERUN_ORBITAL_EYE;
({ readNativeRerunCameraEye } = await server.ssrLoadModule(
"/src/components/rerun/recordedRerunCameraJournal.ts"));
});
after(async () => { await server?.close(); });
class FakeEvent {
constructor(type, init = {}) { this.type = type; Object.assign(this, init); }
}
class FakeTarget {
listeners = new Map();
emitted = [];
addEventListener(type, listener) {
const listeners = this.listeners.get(type) ?? [];
listeners.push(listener); this.listeners.set(type, listeners);
}
removeEventListener(type, listener) {
this.listeners.set(type, (this.listeners.get(type) ?? []).filter(item => item !== listener));
}
dispatchEvent(event) {
this.emitted.push(event);
for (const listener of this.listeners.get(event.type) ?? []) listener(event);
return true;
test("camera snapshot copies exact native pose, without approximating input", () => {
const native = { position: [287, -74, 8], lookTarget: [286, -72, 0], eyeUp: [0, 0, 1] };
const eye = readNativeRerunCameraEye(native);
assert.deepEqual(eye, native);
native.position[0] = 999;
assert.equal(eye.position[0], 287);
assert.equal(readNativeRerunCameraEye(null), null);
});
test("invalid native pose fails closed instead of substituting a guessed camera", () => {
for (const value of [{}, "pose", { position: [NaN, 0, 1], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1] }]) {
assert.throws(() => readNativeRerunCameraEye(value), /Invalid native Rerun camera/);
}
}
const radius = (eye) => Math.hypot(
eye.position[0] - eye.lookTarget[0],
eye.position[1] - eye.lookTarget[1],
eye.position[2] - eye.lookTarget[2],
);
test("recorded orbital eye tracks only 3D viewport navigation", () => {
const scope = new FakeTarget();
const timers = [];
Object.assign(scope, {
WheelEvent: FakeEvent,
requestAnimationFrame(callback) { callback(); return 1; },
setTimeout(callback) { timers.push(callback); return timers.length; },
});
const canvas = new FakeTarget();
Object.assign(canvas, {
clientHeight: 200,
isConnected: true,
getBoundingClientRect: () => ({
left: 100, right: 500, top: 50, bottom: 250, width: 400, height: 200,
}),
});
const journal = createRecordedRerunCameraJournal(canvas, scope);
journal.configure(initialEye, 0.46);
const pointer = (type, x, y, buttons) => new FakeEvent(type, {
clientX: x, clientY: y, button: 0, buttons, pointerId: 7, pointerType: "mouse",
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
});
canvas.dispatchEvent(pointer("pointerdown", 200, 100, 1));
scope.dispatchEvent(pointer("pointermove", 240, 120, 1));
scope.dispatchEvent(pointer("pointerup", 240, 120, 0));
assert.deepEqual(journal.current(), initialEye);
canvas.dispatchEvent(pointer("pointerdown", 380, 100, 1));
scope.dispatchEvent(pointer("pointermove", 420, 120, 1));
scope.dispatchEvent(pointer("pointerup", 420, 120, 0));
const rotated = journal.current();
assert.notDeepEqual(rotated.position, initialEye.position);
assert.ok(Math.abs(radius(rotated) - radius(initialEye)) < 1e-9);
journal.setMaxOrbitalRadius(40);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: 2_000, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
assert.ok(Math.abs(radius(journal.current()) - 40) < 1e-9);
journal.configure(rotated, 0.46);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 0, clientY: 0, deltaX: 0, deltaY: -20, deltaMode: 0,
altKey: false, ctrlKey: false, metaKey: false, shiftKey: false,
}));
const zoomed = journal.current();
assert.ok(Math.abs(radius(zoomed) - radius(rotated) * Math.exp(-20 / 200)) < 1e-9);
const snapshot = journal.current();
journal.setSpatialViewportStart(0.8);
scope.dispatchEvent(new FakeEvent("wheel", {
clientX: 380, clientY: 130, deltaX: 0, deltaY: -20, deltaMode: 0,
}));
assert.deepEqual(journal.current(), snapshot);
journal.dispose();
});
test("iframe owner reads the native camera and installs no shadow input listeners", () => {
const owner = readFileSync(new URL("../src/components/rerun/recordedRerunOwner.ts", import.meta.url), "utf8");
const camera = readFileSync(new URL("../src/components/rerun/recordedRerunCameraJournal.ts", import.meta.url), "utf8");
assert.match(owner, /get_camera_eye\?\.\(\)/);
assert.doesNotMatch(owner + camera, /createRecordedRerunCameraJournal|addEventListener|Math\.exp/);
});
test("display blueprint activation carries the current native eye, not the embedded preset", () => {
const viewport = readFileSync(new URL("../src/components/RerunViewport.tsx", import.meta.url), "utf8");
assert.match(viewport, /!enablingFollow \? active\.getCameraEye\?\.\(\) \?\? undefined : undefined/);
assert.match(viewport, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,/);
assert.match(viewport, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
});
@@ -59,6 +59,17 @@ function bridge(native, mount = {}) {
return parent;
}
test("camera snapshot crosses the disposable realm as copied primitive data", () => {
const eye = { position: [300, 7, 8], lookTarget: [298, 9, 0], eyeUp: [0, 0, 1] };
const { facade, dispose } = bridge({ stop() {}, get_camera_eye: () => eye });
const received = facade.get_camera_eye();
assert.deepEqual(received, eye);
assert.notEqual(received, eye);
assert.notEqual(received.position, eye.position);
dispose();
assert.throws(() => facade.get_camera_eye(), /disposed/);
});
test("recorded realm terminates on close even if upstream stop throws", async (t) => {
const f = fixture(t, { stopFails: true });
const scope = createIsolatedRerunHost(f.host);
@@ -251,6 +251,10 @@ test("recorded RRD bytes are never split across LogChannel.send_rrd calls", asyn
assert.match(source, /recordedPerceptionLayers\.costmap,/);
assert.match(source, /recordedPerceptionLayers\.costmap !== undefined && status !== "ready"/);
assert.match(source, /perceptionLayers\.costmap === undefined \? \{\} : \{\s*show_costmap: perceptionLayers\.costmap,\s*reactivate_updates: true/s);
// Ordinary archives need activation too, not only the LAB costmap path.
assert.match(source, /requestBlueprint\(\s*firstEye,\s*firstEyeIsTrackingRelative,[\s\S]*?\n\s*true,\s*\);/);
// Display changes must not trigger a full-file camera-bounds scan.
assert.match(source, /currentTimeNs: eyeRelativeToTracking \? currentTimeNs : undefined/);
assert.match(source, /viewer\.start\(\s*rerunViewerInitialSource\(resolvedSource\)/s);
assert.doesNotMatch(source, /rerunViewerOpenOptions/);
assert.match(
@@ -1,4 +1,5 @@
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import test from "node:test";
@@ -7,13 +8,41 @@ const root = resolve(import.meta.dirname, "..");
const packageRoot = resolve(root, "node_modules/@rerun-io/web-viewer");
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
test("Mission Core uses the exact upstream Rerun 0.36.3 web package", () => {
test("Mission Core pins Rerun 0.36.3 with the bounded native navigation installer", () => {
const application = readJson(resolve(root, "package.json"));
const installed = readJson(resolve(packageRoot, "package.json"));
assert.equal(application.dependencies["@rerun-io/web-viewer"], "0.36.3");
assert.equal(installed.version, "0.36.3");
assert.equal(application.scripts.postinstall, undefined);
assert.equal(application.scripts.postinstall, "node scripts/install-rerun-navigation.mjs");
assert.equal(application.scripts.prebuild, application.scripts.postinstall);
});
test("native navigation artifacts and source patch match their provenance", () => {
const vendorRoot = resolve(root, "vendor/rerun-web-viewer-0.36.3");
const manifest = readJson(resolve(vendorRoot, "navigation-build.json"));
const sha = bytes => createHash("sha256").update(bytes).digest("hex");
assert.equal(manifest.upstreamVersion, "0.36.3");
assert.equal(manifest.upstreamCommit, "6ded109d33c549e98185f7c95fa8009d44e4adef");
assert.equal(sha(readFileSync(resolve(vendorRoot, "NODEDC_NAVIGATION.patch"))), manifest.patchSha256);
for (const [name, identity] of Object.entries(manifest.files)) {
if (!identity.artifact) continue;
assert.equal(sha(readFileSync(resolve(vendorRoot, identity.artifact))), identity.sha256, name);
assert.equal(sha(readFileSync(resolve(packageRoot, name))), identity.sha256, name);
}
});
test("generated JS and native WASM share one ABI including the camera snapshot", () => {
const glue = readFileSync(resolve(packageRoot, "re_viewer.js"), "utf8");
const wasm = new WebAssembly.Module(readFileSync(resolve(packageRoot, "re_viewer_bg.wasm")));
const names = new Set(WebAssembly.Module.exports(wasm).map(item => item.name));
assert.ok(names.has("webhandle_nodedc_camera_eye"));
for (const [, name] of glue.matchAll(/\bwasm\.([a-zA-Z_$][\w$]*)/g)) {
assert.ok(names.has(name), `Missing WASM export: ${name}`);
}
assert.match(readFileSync(resolve(packageRoot, "index.js"), "utf8"), /this\.#handle\.nodedc_camera_eye\(\)/);
assert.match(readFileSync(resolve(packageRoot, "index.d.ts"), "utf8"), /get_camera_eye\(\)/);
assert.match(readFileSync(resolve(packageRoot, "re_viewer.d.ts"), "utf8"), /nodedc_camera_eye\(\)/);
});
test("the active application never imports or installs the archived vendor fork", () => {
@@ -0,0 +1,134 @@
import assert from 'node:assert/strict';
import {before, after, test} from 'node:test';
import {createServer} from 'vite';
import {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
import ts from 'typescript';
let server, profile, points, defaults, Controls, Timeline;
before(async () => {
server = await createServer({appType: 'custom', logLevel: 'silent', server: {middlewareMode: true}});
profile = await server.ssrLoadModule('/src/core/observation/sessionDisplayProfile.ts');
points = await server.ssrLoadModule('/src/core/observation/recordedPointDisplay.ts');
defaults = (await server.ssrLoadModule('/src/sceneSettings.ts')).defaultSceneSettings;
({SceneDisplayControls: Controls} = await server.ssrLoadModule('../../packages/spatial-ui/src/SceneDisplayControls.tsx'));
({ObservationTimeline: Timeline} = await server.ssrLoadModule('/src/components/ObservationTimeline.tsx'));
});
after(async () => {await server?.close();});
const document = (settings, id='session-a') => ({schema_version: 'missioncore.session-display-profile/v1',
session_id: id, scene_settings: profile.encodeSessionDisplay(settings)});
test('session profile round trips all display settings and fractional decimation', () => {
const settings = {...defaults, accumulationMaxSeconds: 600, accumulationSeconds: 590, pointDecimationPercent: 49.5};
assert.deepEqual(profile.decodeSessionDisplay(document(settings), 'session-a'), settings);
assert.throws(() => profile.decodeSessionDisplay(document(settings), 'session-b'), /другой записи/);
for (const percent of [-1, 100.1, NaN, Infinity]) {
assert.throws(() => profile.decodeSessionDisplay(document({...settings, pointDecimationPercent: percent}), 'session-a'));
}
for (const percent of [0, 50, 100]) assert.equal(profile.decodeSessionDisplay(document({...settings,
pointDecimationPercent: percent}), 'session-a').pointDecimationPercent, percent);
assert.equal(defaults.accumulationMaxSeconds, 180);
});
test('timeline uses per-session range instead of a fixed duration cap', () => {
const html = renderToStaticMarkup(createElement(Timeline, {accumulationSeconds: 47,
accumulationMaxSeconds: 600, onAccumulationChange: () => {}}));
assert.match(html, /max="600"/);
});
test('display exposes precise thinning only for saved playback', () => {
const props = {displayDraft: defaults, stageDisplayPatch: () => {}, commitDisplayPatch: () => {}, flushDisplaySettings: () => {}};
assert.match(renderToStaticMarkup(createElement(Controls, {...props, replayPresented: true})), /Прореживание облака/);
assert.doesNotMatch(renderToStaticMarkup(createElement(Controls, props)), /Прореживание облака/);
});
test('open Display previews 100%, 0% and accumulation; close alone persists the latest draft', async () => {
// Execute the actual composition callbacks with deterministic timers. This
// catches the former open-inspector early return, not just helper behavior.
const source = await readFile(new URL('../src/App.tsx', import.meta.url), 'utf8');
const ast = ts.createSourceFile('App.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const callbacks = new Map();
const names = ['stageDisplayPatch', 'flushDisplaySettings', 'commitDisplaySettings'];
let close;
function visit(node) {
if (ts.isVariableDeclaration(node) && names.includes(node.name.getText(ast))) {
callbacks.set(node.name.getText(ast), node.initializer.getText(ast));
}
if (ts.isBinaryExpression(node) && node.left.getText(ast) === 'closeDisplayRef.current') close = node.getText(ast);
ts.forEachChild(node, visit);
}
visit(ast);
assert.equal(callbacks.size, names.length); assert.ok(close);
const applied = [], saved = [], timers = new Map(); let timerId = 0;
const context = {
useCallback: fn => fn, displayWindowOpenRef: {current: true}, replayActiveRef: {current: true},
displayDraftRef: {current: {...defaults, pointDecimationPercent: 86}}, setDisplayDraft() {},
viewerSettingsCommitTimerRef: {current: null}, viewerSettingsQuietPeriodMs: 750,
sceneSettingsCommitterRef: {current: {enqueue: value => applied.push(value)}}, closeDisplayRef: {current: null},
sessionDisplayProfile: {edited() {}, save: value => saved.push(value)},
window: {setTimeout: fn => {timers.set(++timerId, fn); return timerId;}, clearTimeout: id => timers.delete(id)},
};
const code = [...callbacks].map(([name, init]) => `const ${name} = ${init};`).join('\n') +
`\n${close}; ({stageDisplayPatch, flushDisplaySettings, close: closeDisplayRef.current});`;
const actions = runInNewContext(ts.transpileModule(code, {compilerOptions: {target: ts.ScriptTarget.ES2022}}).outputText, context);
actions.stageDisplayPatch({pointDecimationPercent: 100});
assert.equal(applied.at(-1).pointDecimationPercent, 100);
actions.stageDisplayPatch({pointDecimationPercent: 0});
assert.equal(applied.at(-1).pointDecimationPercent, 0);
actions.stageDisplayPatch({accumulationSeconds: 180});
actions.flushDisplaySettings();
assert.equal(applied.at(-1).accumulationSeconds, 180);
actions.stageDisplayPatch({accumulationSeconds: 10});
actions.flushDisplaySettings();
assert.equal(applied.at(-1).accumulationSeconds, 10);
actions.stageDisplayPatch({pointDecimationPercent: 49.5});
assert.equal(timers.size, 1);
const pending = [...timers.values()][0]; timers.clear(); pending();
assert.equal(applied.at(-1).pointDecimationPercent, 49.5);
assert.equal(applied.at(-1).accumulationSeconds, 10);
assert.equal(saved.length, 0);
actions.close();
assert.equal(saved.length, 1);
assert.equal(saved[0].pointDecimationPercent, 49.5);
assert.equal(saved[0].accumulationSeconds, 10);
});
test('display stream validates fragmented header and sends bounded chunks', async () => {
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
try {
const wire = [78,80,68,49,8,0,0,0,82,82,70,50,1,2,3,4,0,0,0,0];
const chunks = [new Uint8Array(wire.slice(0,1)), new Uint8Array(wire.slice(1,7)),
new Uint8Array(wire.slice(7,10)), new Uint8Array(wire.slice(10))];
const sent = []; let request;
const fetcher = async (url, options) => {request = {url, ...options}; return new Response(new ReadableStream({
start(controller) {chunks.forEach(chunk => controller.enqueue(chunk)); controller.close();},
}), {headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}});};
const total = await points.streamRecordedPointDisplay('/api/v1/observation-sessions/session-a/blueprint.rrd',
{...defaults, pointDecimationPercent: 49.5}, {applicationId: 'app', recordingId: 'rec'}, 'a'.repeat(32),
new AbortController().signal, chunk => sent.push(...chunk), fetcher, 'a'.repeat(64));
assert.equal(total, 20); assert.deepEqual(sent, [82,82,70,50,1,2,3,4]);
assert.equal(JSON.parse(request.body).point_decimation_percent, 49.5);
assert.equal(JSON.parse(request.body).source_generation, 'a'.repeat(64));
assert.match(request.url, /point-display\.rrd$/);
} finally {globalThis.window = previous;}
});
test('invalid, cross-origin and canceled streams never become active', async () => {
const previous = globalThis.window; globalThis.window = {location: {origin: 'http://localhost'}};
try {
const call = (url, signal, fetcher) => points.streamRecordedPointDisplay(url, {...defaults, pointDecimationPercent: 50},
{applicationId: 'app', recordingId: 'rec'}, 'b'.repeat(32), signal, () => assert.fail('must not publish'), fetcher);
const url = '/api/v1/observation-sessions/session-a/blueprint.rrd';
await assert.rejects(call('https://elsewhere.test'+url, new AbortController().signal, () => assert.fail('must not fetch')));
await assert.rejects(call(url, new AbortController().signal, async () => new Response('bad!', {
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Некорректный поток/);
await assert.rejects(call(url, new AbortController().signal, async () => new Response('NPD1', {
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /не завершён/);
const abort = new AbortController(); abort.abort();
await assert.rejects(call(url, abort.signal, async () => new Response('RRF2', {
headers: {'Content-Type': 'application/vnd.nodedc.point-display-stream'}})), /Aborted/);
} finally {globalThis.window = previous;}
});
@@ -0,0 +1,55 @@
import assert from 'node:assert/strict';
import { before, after, test } from 'node:test';
import { createServer } from 'vite';
import { readFile } from 'node:fs/promises';
let server, api;
before(async () => {
server = await createServer({ appType: 'custom', logLevel: 'silent', server: { middlewareMode: true } });
api = await server.ssrLoadModule('/src/core/observation/sessionOverviewSpatial.ts');
});
after(async () => { await server?.close(); });
test('comparison pins identity and changes geometry without requesting a camera reset', async () => {
const oldFetch = globalThis.fetch, oldWindow = globalThis.window;
const requests = [];
globalThis.window = { location: { origin: 'http://localhost:8000' } };
globalThis.fetch = async (url, options) => {
requests.push({ url: String(url), body: JSON.parse(options.body) });
return { ok: true, arrayBuffer: async () => new ArrayBuffer(8), headers: new Headers({ 'X-Overview-Visible-Points': '42' }) };
};
try {
const source = '/api/v1/observation-sessions/example/overview/scene.rrd?generation=' + 'a'.repeat(64);
const signal = new AbortController().signal;
for (const representation of ['original', 'corrected', 'original']) {
const result = await api.updateOverviewSpatial(source, 80, null, 1.5, signal, 'b'.repeat(64), representation);
assert.equal(result.eye, null);
assert.equal(result.visiblePoints, 42);
}
assert.deepEqual(requests.map(r => r.body.representation), ['original', 'corrected', 'original']);
for (const request of requests) {
assert.equal(request.body.mode, null);
assert.equal(request.body.comparison_generation, 'b'.repeat(64));
assert.equal(request.body.generation, 'a'.repeat(64));
assert.ok(request.url.endsWith('/overview/spatial'));
}
await assert.rejects(api.updateOverviewSpatial(source, null, null, 1, signal, null, 'corrected'), /недоступна/);
assert.equal(requests.length, 3);
} finally { globalThis.fetch = oldFetch; globalThis.window = oldWindow; }
});
test('comparison control is opt-in while default geometry and pinned planner versions are shared', async () => {
const source = await readFile(new URL('../src/components/observation/SessionOverviewScene.tsx', import.meta.url), 'utf8');
assert.match(source, /compareVersions=false/);
assert.match(source, /}, \[sourceUrl, retry\]\);/);
assert.match(source, /appliedMode.current === mode \? null : mode/);
assert.doesNotMatch(source, /key=\{representation\}|setRetry\([^\n]*representation/);
assert.match(source, /label="Версия облака"/);
assert.match(source, /setAppliedRepresentation\(comparison \? representation/);
assert.match(source, /setRepresentation\(data.default_representation/);
assert.match(source, /const comparison = metadata\?\.comparison/);
assert.match(source, /compareVersions && comparison/);
const planner = await readFile(new URL('../src/components/missions/MissionZonePreview.tsx', import.meta.url), 'utf8');
assert.doesNotMatch(planner, /compareVersions/);
assert.match(planner, /reference_generation=\$\{encodeURIComponent\(generation\)\}/);
const logic = await readFile(new URL('../src/core/missions/useMissionPlanner.ts', import.meta.url), 'utf8');
assert.match(logic, /setPinnedGeneration\(next.zone.generation\)/);
assert.match(logic, /setPinnedGeneration\(null\)/);
});
@@ -13,7 +13,8 @@ before(async () => {
after(async () => { await server?.close(); });
const render = (focused = false) => renderToStaticMarkup(createElement(SpatialScene, {
viewportRef: { current: null }, primaryFocused: focused, toolbar: null, renderer: null,
viewportRef: { current: null }, primaryFocused: focused, toolbar: null,
renderer: createElement('div', { 'data-testid': 'renderer' }, 'RENDERER'),
sourceControls: createElement('div', { className: focused ? 'scene-focus-exit' : 'scene-source-controls' }, 'SOURCE_CONTROLS'),
status: { label: 'Накопление данных', tone: 'neutral', message: 'Сканер неподвижен.' },
metrics: createElement('div', null, 'METRICS'),
@@ -37,6 +38,18 @@ test('focus exit remains viewport-owned outside the hidden information stack', (
assert.equal((markup.match(/SOURCE_CONTROLS/g) ?? []).length, 1);
});
test('normal and expanded scenes retain the renderer without mouse-navigation copy', async () => {
for (const focused of [false, true]) {
const markup = render(focused);
assert.match(markup, /data-testid="renderer">RENDERER/);
assert.doesNotMatch(markup, /scene-navigation-hint|Навигация по 3D-сцене|ЛКМ|ПКМ|Колесо/);
}
const source = await readFile(new URL('../../../packages/spatial-ui/src/SpatialScene.tsx', import.meta.url), 'utf8');
const css = await readFile(new URL('../../../packages/spatial-ui/src/observation.css', import.meta.url), 'utf8');
assert.doesNotMatch(source, /navigationReady|scene-navigation-hint/);
assert.doesNotMatch(css, /scene-navigation-hint/);
});
test('scene layout uses flow, retains compact metrics and removes only the calibration perimeter', async () => {
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
const responsive = await readFile(new URL('../src/styles/responsive.css', import.meta.url), 'utf8');
@@ -48,3 +61,13 @@ test('scene layout uses flow, retains compact metrics and removes only the calib
assert.doesNotMatch(responsive, /\.scene-metrics\s*\{\s*display: none/);
assert.match(calibration, /\.xgrids-k1-spatial-controls \{[^}]*border: 0;/);
});
test('preparation status shares the source controls top axis on the opposite side', async () => {
const css = await readFile(new URL('../../../packages/spatial-ui/src/spatial.css', import.meta.url), 'utf8');
const stack = css.match(/\.scene-operation-status-stack \{[^}]*\}/)?.[0] ?? '';
assert.match(stack, /top: 0\.85rem/);
assert.match(stack, /right: 0\.85rem/);
assert.match(stack, /min-height: 2\.75rem/);
assert.match(stack, /align-content: center/);
assert.doesNotMatch(stack, /left:|bottom:/);
});
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,25 @@
Copyright (c) 2022 Rerun Technologies AB <opensource@rerun.io>
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,361 @@
--- a/crates/viewer/re_view_spatial/src/eye.rs
+++ b/crates/viewer/re_view_spatial/src/eye.rs
@@ -2,7 +2,7 @@
use glam::{Mat4, Quat, Vec3, vec3};
use macaw::IsoTransform;
use re_log_types::EntityPath;
-use re_sdk_types::blueprint::archetypes::EyeControls3D;
+use re_sdk_types::blueprint::archetypes::{EyeControls3D, LineGrid3D};
use re_sdk_types::blueprint::components::{AngularSpeed, Eye3DKind};
use re_sdk_types::components::{LinearSpeed, Position3D, Vector3D};
use re_view::controls::{
@@ -214,6 +214,8 @@
pub last_look_target: Option<Vec3>,
pub last_orbit_radius: Option<f32>,
pub last_eye_up: Option<Vec3>,
+ /// NODE.DC: time of the last rendered eye, for the read-only WebViewer snapshot.
+ pub last_render_time: f64,
}
#[derive(Clone, Debug, PartialEq, re_byte_size::SizeBytes)]
@@ -241,6 +243,7 @@
fov_y: Option<f32>,
did_interact: bool,
+ grid_plane: macaw::Plane3,
}
impl EyeController {
@@ -250,16 +253,8 @@
/// Avoids breaking the view by zooming in too far.
pub const MIN_ORBIT_DISTANCE: f32 = Eye::PERSPECTIVE_NEAR_PLANE * 2.0;
- /// Cap on the orbital camera radius, as a multiple of the scene bounding box diagonal.
- ///
- /// Only applied when scroll-to-zoom wants to grow the radius further. Zoom-in, rotate,
- /// pan, WASD, and every other form of motion are left alone — this is intentionally a
- /// local restriction on orbital zoom-out only, not a general movement envelope. The 2D
- /// view has its own, separate zoom-out cap (see `ui_2d::MAX_ZOOM_OUT_FACTOR`).
- ///
- /// If the radius already exceeds this (e.g. right after loading) the current radius is
- /// used as the cap instead, so the camera isn't pulled back in.
- const MAX_ORBITAL_ZOOM_OUT_FACTOR: f32 = 5.0;
+ // NODE.DC: finite arithmetic guard only, independent of route/scene bounds.
+ const MAX_ORBITAL_RADIUS: f32 = 1.0e17;
fn get_eye(&self) -> Eye {
Eye {
@@ -317,6 +312,10 @@
.0,
);
+ let grid = ViewProperty::from_archetype::<LineGrid3D>(ctx);
+ let grid_plane = grid.component_or_fallback::<re_sdk_types::components::Plane3D>(
+ ctx, LineGrid3D::descriptor_plane().component,
+ )?;
Ok(Self {
pos,
look_target,
@@ -325,6 +324,7 @@
eye_up,
did_interact: false,
fov_y,
+ grid_plane: grid_plane.into(),
})
}
@@ -482,12 +482,45 @@
let up = rot * Vec3::Y;
let right = rot * -Vec3::X; // TODO(emilk): why do we need a negation here? O.o
+ // Mission Core's map grid is XY. Screen-plane pan otherwise lifts the
+ // orbit target into the air, leaving scroll-to-target stranded there.
+ // First-person navigation intentionally retains upstream 3D movement.
+ let (right, up) = if self.kind == Eye3DKind::Orbital {
+ let normal = self.grid_plane.normal;
+ let project = |v: Vec3| v - normal * normal.dot(v);
+ let right = project(right).normalize_or(project(Vec3::X).normalize_or(Vec3::Y));
+ let up = project(up).normalize_or(normal.cross(-right));
+ (right, up)
+ } else {
+ (right, up)
+ };
let translate = delta_in_view.x * right + delta_in_view.y * up;
self.pos += translate;
self.look_target += translate;
}
+ /// Translate the whole rig, not its orientation/radius, onto the grid.
+ /// Called only by deliberate navigation while not following an entity.
+ fn anchor_orbit_to_grid(&mut self) {
+ if self.kind == Eye3DKind::Orbital {
+ let correction = self.grid_plane.normal
+ * (self.grid_plane.d - self.grid_plane.normal.dot(self.look_target));
+ self.pos += correction;
+ self.look_target += correction;
+ }
+ }
+
+ fn zoom_orbit(&mut self, zoom_factor: f32) {
+ if !zoom_factor.is_finite() || zoom_factor <= 0.0 {
+ return;
+ }
+ let new_radius = (self.radius() / zoom_factor)
+ .clamp(Self::MIN_ORBIT_DISTANCE, Self::MAX_ORBITAL_RADIUS);
+ self.pos = self.look_target - self.fwd() * new_radius;
+ self.did_interact = true;
+ }
+
fn handle_drag(&mut self, response: &egui::Response, drag_threshold: f32) {
if response.drag_delta().length() > drag_threshold {
let roll = response.dragged_by(ROLL_MOUSE)
@@ -513,7 +546,7 @@
}
/// Handle zoom/scroll input.
- fn handle_zoom(&mut self, egui_ctx: &egui::Context, scene_bounding_box: &macaw::BoundingBox) {
+ fn handle_zoom(&mut self, egui_ctx: &egui::Context, _scene_bounding_box: &macaw::BoundingBox) {
let zoom_factor = egui_ctx.input(|input| {
// egui's default horizontal_scroll_modifier is shift, which is also our speed-up modifier.
// This means that a user who wants to speed up scroll-to-zoom will generate a horizontal scroll delta.
@@ -528,22 +561,7 @@
match self.kind {
Eye3DKind::Orbital => {
- let radius = self.pos.distance(self.look_target);
-
- // Cap zoom-out against the scene bounding box. If we're already past the cap
- // (e.g. right after loading) use the current radius instead — no snap-back.
- let max_radius = max_orbital_radius(scene_bounding_box).max(radius);
- let new_radius = (radius / zoom_factor).clamp(Self::MIN_ORBIT_DISTANCE, max_radius);
-
- // The user may be scrolling to move the camera closer, but are not realizing
- // the radius is now tiny.
- // TODO(emilk): inform the users somehow that scrolling won't help, and that they should use WSAD instead.
- // It might be tempting to start moving the camera here on scroll, but that would is bad for other reasons.
-
- if f32::MIN_POSITIVE < new_radius {
- self.pos = self.look_target - self.fwd() * new_radius;
- self.did_interact = true;
- }
+ self.zoom_orbit(zoom_factor);
}
Eye3DKind::FirstPerson => {
// Move along the forward axis when zooming in first person mode.
@@ -707,25 +725,6 @@
}
}
-/// Cap on the orbital zoom-out radius, derived from the scene bounding box diagonal.
-///
-/// Returns `1.0e17` (a large fallback that avoids infinities downstream) when no usable scene
-/// bounding box is available.
-fn max_orbital_radius(scene_bounding_box: &macaw::BoundingBox) -> f32 {
- // `1.0e17` fallback is chosen with generous margin of an observed crash due to infinity.
- let fallback = 1.0e17;
-
- if !scene_bounding_box.is_finite() || scene_bounding_box.is_nothing() {
- return fallback;
- }
- let scene_diagonal = scene_bounding_box.size().length();
- if !scene_diagonal.is_finite() || scene_diagonal <= 0.0 {
- return fallback;
- }
- (scene_diagonal * EyeController::MAX_ORBITAL_ZOOM_OUT_FACTOR)
- .max(EyeController::MIN_ORBIT_DISTANCE)
-}
-
pub fn find_camera(cameras: &[PinholeWrapper], needle: &EntityPath) -> Option<Eye> {
let mut found_camera = None;
@@ -744,6 +743,103 @@
fn ease_out(t: f32) -> f32 {
1. - (1. - t) * (1. - t)
+}
+
+#[cfg(test)]
+mod nodedc_navigation_tests {
+ use super::*;
+
+ fn controller() -> EyeController {
+ EyeController {
+ pos: vec3(305.0, -80.0, 22.0),
+ look_target: vec3(290.0, -60.0, 4.0),
+ kind: Eye3DKind::Orbital,
+ speed: 30.0,
+ eye_up: Vec3::Z,
+ fov_y: Some(Eye::DEFAULT_FOV_Y),
+ did_interact: false,
+ grid_plane: macaw::Plane3 { normal: Vec3::Z, d: 0.0 },
+ }
+ }
+
+ #[test]
+ fn pan_is_on_grid_and_moves_the_whole_rig() {
+ let mut c = controller();
+ let offset = c.pos - c.look_target;
+ c.anchor_orbit_to_grid();
+ for _ in 0..100 {
+ c.translate(egui::vec2(0.5, 0.7));
+ }
+ assert_eq!(c.look_target.z, 0.0);
+ assert!((c.pos - c.look_target - offset).length() < 0.001);
+ }
+
+ #[test]
+ fn rotation_keeps_selected_pivot_and_radius() {
+ let mut c = controller();
+ c.anchor_orbit_to_grid();
+ let target = c.look_target;
+ let radius = c.radius();
+ for _ in 0..200 {
+ c.rotate(egui::vec2(2.0, 0.1));
+ }
+ assert_eq!(c.look_target, target);
+ assert!((c.radius() - radius).abs() < 0.01);
+ }
+
+ #[test]
+ fn zoom_passes_map_scale_without_moving_pivot() {
+ let mut c = controller();
+ c.anchor_orbit_to_grid();
+ let target = c.look_target;
+ c.zoom_orbit(0.001);
+ assert!(c.radius() > 20_000.0);
+ for _ in 0..12 {
+ c.zoom_orbit(4.0);
+ }
+ assert!(c.radius() < 0.03);
+ assert_eq!(c.look_target, target);
+ assert!(c.get_eye().world_from_rub_view.translation().is_finite());
+ }
+
+ #[test]
+ fn vertical_view_pan_stays_finite_on_grid() {
+ let mut c = controller();
+ c.pos = vec3(0.0, 0.0, 30.0);
+ c.look_target = Vec3::ZERO;
+ c.eye_up = Vec3::Y;
+ c.translate(egui::vec2(1.0, 1.0));
+ assert_eq!(c.look_target.z, 0.0);
+ assert!(c.pos.is_finite());
+ assert!(c.look_target.length() > 1.0);
+ }
+
+ #[test]
+ fn navigation_respects_the_actual_grid_plane() {
+ let mut c = controller();
+ c.grid_plane = macaw::Plane3 { normal: Vec3::Y, d: 3.0 };
+ c.anchor_orbit_to_grid();
+ let target = c.look_target;
+ c.translate(egui::vec2(2.0, 4.0));
+ c.rotate(egui::vec2(10.0, 3.0));
+ assert_eq!(c.look_target.y, 3.0);
+ assert_ne!(c.look_target, target);
+ }
+
+ #[test]
+ fn invalid_zoom_is_ignored_and_first_person_is_unchanged() {
+ let mut c = controller();
+ let before = c.pos;
+ for factor in [0.0, -1.0, f32::NAN, f32::INFINITY] {
+ c.zoom_orbit(factor);
+ assert_eq!(c.pos, before);
+ }
+ c.kind = Eye3DKind::FirstPerson;
+ c.anchor_orbit_to_grid();
+ assert_eq!(c.look_target.z, 4.0);
+ c.translate(egui::vec2(0.0, 1.0));
+ assert_ne!(c.look_target.z, 4.0);
+ }
}
impl EyeState {
@@ -804,9 +900,32 @@
}
}
+ // Anchor before the first paint, not on the first orbit drag. This
+ // avoids a one-off camera jump when the scene-bounds fallback is high.
+ if tracking_entity.is_none() && eye_controller.kind == Eye3DKind::Orbital
+ && (eye_controller.grid_plane.normal.dot(eye_controller.look_target)
+ - eye_controller.grid_plane.d).abs() > 1.0e-5
+ {
+ eye_controller.anchor_orbit_to_grid();
+ eye_controller.did_interact = true;
+ }
+
// Handle spinning before inputs because some inputs depend on view direction.
self.handle_spinning(ctx, eye_property, &mut eye_controller)?;
+ // NODE.DC: pin manual map navigation to the XY grid. Tracking and
+ // explicit camera presets retain their own target until manual input.
+ let manual_input = response.drag_delta().length_sq() > 0.0
+ || (response.hovered() && response.ctx.input(|i| {
+ i.smooth_scroll_delta != egui::Vec2::ZERO || i.zoom_delta() != 1.0
+ }));
+ if manual_input {
+ self.stop_interpolation();
+ if tracking_entity.is_none() {
+ eye_controller.anchor_orbit_to_grid();
+ }
+ }
+
// We do input before tracking entity, because the input can cause the eye
// to stop tracking.
let gamepad_navigation_status = eye_controller.handle_input(
@@ -1241,6 +1360,7 @@
};
self.last_eye = Some(eye);
+ self.last_render_time = ctx.egui_ctx().time();
Ok(eye)
}
--- a/crates/viewer/re_viewer_context/src/view/view_states.rs
+++ b/crates/viewer/re_viewer_context/src/view/view_states.rs
@@ -173,6 +173,13 @@
}
impl ViewStates {
+ /// NODE.DC: read-only per-recording state access for native camera snapshots.
+ pub fn states_for_store<'a>(&'a self, store_id: &'a StoreId) -> impl Iterator<Item = &'a dyn ViewState> {
+ self.states.iter().filter_map(move |((id, _), state)| {
+ (id == store_id).then_some(state.as_ref())
+ })
+ }
+
pub fn get(&self, store_id: &StoreId, view_id: ViewId) -> Option<&dyn ViewState> {
self.states
.get(&(store_id.clone(), view_id))
--- a/crates/viewer/re_viewer/src/web.rs
+++ b/crates/viewer/re_viewer/src/web.rs
@@ -384,6 +384,26 @@
let recording = hub.entity_db(recording_id)?;
Some(recording.store_id().recording_id().to_string())
+ }
+
+ /// NODE.DC navigation/v1. Last rendered native eye, never a replay of DOM deltas.
+ /// Each Mission Core iframe admits one active 3D pane. Timestamp selection
+ /// excludes states retained for a previously selected/reset pane.
+ #[wasm_bindgen]
+ pub fn nodedc_camera_eye(&self) -> Option<String> {
+ let app = self.runner.app_mut::<crate::App>()?;
+ let store_id = app.active_recording_id()?;
+ let eye_state = app.state.view_states.states_for_store(store_id)
+ .filter_map(|state| state.as_any().downcast_ref::<re_view_spatial::SpatialViewState>())
+ .map(|state| &state.state_3d.eye_state)
+ .filter(|state| state.last_eye.is_some() && state.last_look_target.is_some())
+ .max_by(|a, b| a.last_render_time.total_cmp(&b.last_render_time))?;
+ let eye = eye_state.last_eye?;
+ Some(serde_json::json!({
+ "position": eye.pos_in_world().to_array(),
+ "lookTarget": eye_state.last_look_target?.to_array(),
+ "eyeUp": eye_state.last_eye_up?.to_array(),
+ }).to_string())
}
//TODO(#10737): we should refer to logical recordings using store id (recording id is ambiguous)
@@ -0,0 +1,44 @@
# Rerun 0.36.3 · NODE.DC navigation/v1
This is the bounded native camera amendment in ADR 0052, not the archived
0.34.1 renderer fork. `navigation-build.json` binds the exact upstream commit,
portable source patch and paired generated runtime. Keep the upstream licenses.
## Rebuild
1. Download the source archive for the manifest's `upstreamCommit` from
`https://codeload.github.com/rerun-io/rerun/tar.gz/<commit>` and verify its hash.
2. Extract into a new temporary directory and run `git apply --check` followed
by `git apply` with `NODEDC_NAVIGATION.patch` at the extracted root.
3. Pack that root as `patched-source.tar.gz`. On macOS use
`COPYFILE_DISABLE=1 tar --no-mac-metadata --exclude='._*'`; AppleDouble files
are not shaders and must not enter the build.
4. On Worker006, create a temporary container from the pinned Rust image in
the manifest, with 4 CPUs, 16 GB memory, no GPU, no ports and no private data.
Use the `ndc-` name and NODE.DC ownership labels required by AGENTS.md.
Copy the archive to `/work/patched-source.tar.gz` and the repository's
`scripts/build-rerun-navigation.sh` to `/work/build-rerun-navigation.sh`.
Entrypoint: `bash /work/build-rerun-navigation.sh`.
5. Require the native navigation tests to pass. The upstream
`rerun_js/web-viewer/build-wasm.mjs --mode release` produces the matching
`re_viewer.js`, `re_viewer.d.ts` and `re_viewer_bg.wasm`. Copy all three,
never only WASM, to the artifact names in this directory's manifest.
6. Recheck source patch, build script and generated file hashes. Update the
manifest, run the installer twice (idempotency), vendor ABI checks, frontend
contracts/build, then real product-browser QA. Remove the temporary build
container after copying artifacts and evidence.
The source archive and build cache are not runtime dependencies. No compiler,
Docker or hardware access is needed on a clean operator install: the existing
exact npm package plus `postinstall` installs the audited paired artifacts.
An unfamiliar package or artifact is rejected before any file is changed.
## Upgrade / rollback
Rebase against the next exact upstream source, not against minified JS. Retest
grid-plane pan, fixed-pivot orbit, close/far zoom, explicit reset/top/follow,
display updates, normal/expanded views and disposable-iframe cleanup.
To roll back, disable `postinstall`/`prebuild`, restore the official exact npm
package and rebuild. Missing snapshots are represented as null, not simulated
camera state. Recordings, corrected maps and scanner sessions are unchanged.
@@ -0,0 +1,35 @@
{
"schema": "nodedc.rerun-navigation/v1",
"upstreamVersion": "0.36.3",
"upstreamCommit": "6ded109d33c549e98185f7c95fa8009d44e4adef",
"upstreamArchiveSha256": "3f92aef3f33f63c79b275b5c66a65d73192e75967cbcd3d1f16c75010996f896",
"patchSha256": "a893a4b4e7523cca88284316c88b930c3b91ba973ed3f954c9ca632f71252e64",
"cargoLockSha256": "74cdf9c3c8c5dbe5a368f1a9a58b011e2a45720843b30e552bc7b1d8ac21d791",
"buildScriptSha256": "e3cbd5da889403eda3ffba7c020b6075bdfb025b823d1bf503b639f59688135a",
"buildDate": "2026-09-21",
"rustImage": "rust:1.95-bookworm@sha256:6258907abe69656e41cd992e0b705cdcfabcbbe3db374f92ed2d47121282d4a1",
"binaryenVersion": "117",
"binaryenArchiveSha256": "3dc677006555b355ea2da5e82602065a161d5e83eaefd3f759afa00b96e83212",
"wasmBindgenVersion": "0.2.126",
"nativeTestsPassed": 6,
"files": {
"index.js": { "upstreamSha256": "009aa5f66c8674ea268a91926c00e2f82cc7a7b7e5535056d93bd4656d6f6812" },
"index.ts": { "upstreamSha256": "087b25170f720a28aa69c411903133286238900050ac775d2cc511b919846696" },
"index.d.ts": { "upstreamSha256": "649637dad4e50ef4f2310fc1920b580d16832226a0dda5238355059bf167efba" },
"re_viewer.js": {
"upstreamSha256": "d2a94836dd0e6de40538c4d657cfb423b1803e354e6ee0cd550735eb5aaab09b",
"artifact": "re_viewer.nodedc.js",
"sha256": "610d89abffcc5c329797e77794ae2ab5b426c80a5b23fd24064c7d47c6583d3a"
},
"re_viewer.d.ts": {
"upstreamSha256": "9a77a4e1e1aa8311185a3f275bf326a8fff2844e468e561c59d5f0ee7aa6debe",
"artifact": "re_viewer.nodedc.d.ts",
"sha256": "cfbf010d66560af919aa68d255a6ddc7120829f8ee8f27a41f56d943cd54e598"
},
"re_viewer_bg.wasm": {
"upstreamSha256": "02d6d9c3a569d3faceb01cf89104342d970fc85e7c31a08dedd357b6c7104d2f",
"artifact": "re_viewer_bg.nodedc.wasm",
"sha256": "2b661de545ac90bb950eda1a28fc5a7c3ce4ca7f5bb24894789acf7589495185"
}
}
}
@@ -0,0 +1,200 @@
declare namespace wasm_bindgen {
/* tslint:disable */
/* eslint-disable */
/**
* The `ReadableStreamType` enum.
*
* *This API requires the following crate features to be activated: `ReadableStreamType`*
*/
export type ReadableStreamType = "bytes";
export class IntoUnderlyingByteSource {
private constructor();
free(): void;
[Symbol.dispose](): void;
cancel(): void;
pull(controller: ReadableByteStreamController): Promise<any>;
start(controller: ReadableByteStreamController): void;
readonly autoAllocateChunkSize: number;
readonly type: ReadableStreamType;
}
export class IntoUnderlyingSink {
private constructor();
free(): void;
[Symbol.dispose](): void;
abort(reason: any): Promise<any>;
close(): Promise<any>;
write(chunk: any): Promise<any>;
}
export class IntoUnderlyingSource {
private constructor();
free(): void;
[Symbol.dispose](): void;
cancel(): void;
pull(controller: ReadableStreamDefaultController): Promise<any>;
}
export class WebHandle {
free(): void;
[Symbol.dispose](): void;
/**
* Add a new receiver streaming data from the given url.
*
* Websocket streams are always opened in `Following` mode.
*
* It is an error to open a channel twice with the same id.
*/
add_receiver(url: string): void;
/**
* Close an existing channel for streaming data.
*
* No-op if the channel is already closed.
*/
close_channel(id: string): void;
destroy(): void;
get_active_recording_id(): string | undefined;
get_active_timeline(recording_id: string): string | undefined;
get_playing(recording_id: string): boolean | undefined;
get_time_for_timeline(recording_id: string, timeline_name: string): number | undefined;
get_timeline_time_range(recording_id: string, timeline_name: string): any;
has_panicked(): boolean;
constructor(app_options: any);
/**
* NODE.DC navigation/v1. Last rendered native eye, never a replay of DOM deltas.
* Each Mission Core iframe admits one active 3D pane. Timestamp selection
* excludes states retained for a previously selected/reset pane.
*/
nodedc_camera_eye(): string | undefined;
/**
* Open a new channel for streaming data.
*
* It is an error to open a channel twice with the same id.
*/
open_channel(id: string, channel_name: string): void;
override_panel_state(panel: string, state?: string | null): void;
panic_callstack(): string | undefined;
panic_message(): string | undefined;
remove_receiver(url: string): void;
/**
* Add an rrd to the viewer directly from a byte array.
*/
send_rrd_to_channel(id: string, data: Uint8Array): void;
send_table_to_channel(id: string, data: Uint8Array): void;
set_active_recording_id(recording_id: string): void;
/**
* Set the active timeline.
*
* This does nothing if the timeline can't be found.
*/
set_active_timeline(recording_id: string, timeline_name: string): void;
set_credentials(access_token: string, email: string): void;
set_playing(recording_id: string, value: boolean): void;
set_time_for_timeline(recording_id: string, timeline_name: string, time: number): void;
start(canvas: any): Promise<void>;
toggle_panel_overrides(value?: boolean | null): void;
}
}
declare type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
declare interface InitOutput {
readonly memory: WebAssembly.Memory;
readonly __wbg_webhandle_free: (a: number, b: number) => void;
readonly webhandle_add_receiver: (a: number, b: number, c: number) => void;
readonly webhandle_close_channel: (a: number, b: number, c: number) => void;
readonly webhandle_destroy: (a: number) => void;
readonly webhandle_get_active_recording_id: (a: number) => [number, number];
readonly webhandle_get_active_timeline: (a: number, b: number, c: number) => [number, number];
readonly webhandle_get_playing: (a: number, b: number, c: number) => number;
readonly webhandle_get_time_for_timeline: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly webhandle_get_timeline_time_range: (a: number, b: number, c: number, d: number, e: number) => any;
readonly webhandle_has_panicked: (a: number) => number;
readonly webhandle_new: (a: any) => [number, number, number];
readonly webhandle_nodedc_camera_eye: (a: number) => [number, number];
readonly webhandle_open_channel: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_override_panel_state: (a: number, b: number, c: number, d: number, e: number) => [number, number];
readonly webhandle_panic_callstack: (a: number) => [number, number];
readonly webhandle_panic_message: (a: number) => [number, number];
readonly webhandle_remove_receiver: (a: number, b: number, c: number) => void;
readonly webhandle_send_rrd_to_channel: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_send_table_to_channel: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_set_active_recording_id: (a: number, b: number, c: number) => void;
readonly webhandle_set_active_timeline: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_set_credentials: (a: number, b: number, c: number, d: number, e: number) => void;
readonly webhandle_set_playing: (a: number, b: number, c: number, d: number) => void;
readonly webhandle_set_time_for_timeline: (a: number, b: number, c: number, d: number, e: number, f: number) => void;
readonly webhandle_start: (a: number, b: any) => any;
readonly webhandle_toggle_panel_overrides: (a: number, b: number) => void;
readonly rust_lz4_wasm_shim_calloc: (a: number, b: number) => number;
readonly rust_lz4_wasm_shim_free: (a: number) => void;
readonly rust_lz4_wasm_shim_malloc: (a: number) => number;
readonly rust_lz4_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
readonly rust_lz4_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
readonly rust_lz4_wasm_shim_memmove: (a: number, b: number, c: number) => number;
readonly rust_lz4_wasm_shim_memset: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_calloc: (a: number, b: number) => number;
readonly rust_zstd_wasm_shim_free: (a: number) => void;
readonly rust_zstd_wasm_shim_malloc: (a: number) => number;
readonly rust_zstd_wasm_shim_memcmp: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_memcpy: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_memmove: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_memset: (a: number, b: number, c: number) => number;
readonly rust_zstd_wasm_shim_qsort: (a: number, b: number, c: number, d: number) => void;
readonly __wbg_intounderlyingbytesource_free: (a: number, b: number) => void;
readonly __wbg_intounderlyingsink_free: (a: number, b: number) => void;
readonly __wbg_intounderlyingsource_free: (a: number, b: number) => void;
readonly intounderlyingbytesource_autoAllocateChunkSize: (a: number) => number;
readonly intounderlyingbytesource_cancel: (a: number) => void;
readonly intounderlyingbytesource_pull: (a: number, b: any) => any;
readonly intounderlyingbytesource_start: (a: number, b: any) => void;
readonly intounderlyingbytesource_type: (a: number) => number;
readonly intounderlyingsink_abort: (a: number, b: any) => any;
readonly intounderlyingsink_close: (a: number) => any;
readonly intounderlyingsink_write: (a: number, b: any) => any;
readonly intounderlyingsource_cancel: (a: number) => void;
readonly intounderlyingsource_pull: (a: number, b: any) => any;
readonly wasm_bindgen__convert__closures_____invoke__ha28703b0fc0ac5f5: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__hd1708d5debff0eb7: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4_10: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h17feb392561402d4_11: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h68d110bc138a9729: (a: number, b: number, c: any) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h4a090e5af75dc439: (a: number, b: number, c: any, d: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h72e0675ea71ceaf9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h76e373640bdbaace: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h912ec1a6c04fd19b: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h76e373640bdbaace_4: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__ha366fcce789d0db1: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h3d976baa4adbebda: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__h912ec1a6c04fd19b_9: (a: number, b: number, c: any) => void;
readonly wasm_bindgen__convert__closures_____invoke__hbf8a291ae3f8f46d: (a: number, b: number) => [number, number];
readonly wasm_bindgen__convert__closures_____invoke__h0e2ab714131e0149: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h2a38a854c15eed3d: (a: number, b: number) => void;
readonly wasm_bindgen__convert__closures_____invoke__h46084f6dced1097a: (a: number, b: number) => void;
readonly __wbindgen_malloc: (a: number, b: number) => number;
readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
readonly __externref_table_alloc: () => number;
readonly __wbindgen_externrefs: WebAssembly.Table;
readonly __wbindgen_exn_store: (a: number) => void;
readonly __wbindgen_free: (a: number, b: number, c: number) => void;
readonly __wbindgen_destroy_closure: (a: number, b: number) => void;
readonly __externref_table_dealloc: (a: number) => void;
readonly __wbindgen_start: () => void;
}
/**
* If `module_or_path` is {RequestInfo} or {URL}, makes a request and
* for everything else, calls `WebAssembly.instantiate` directly.
*
* @param {{ module_or_path: InitInput | Promise<InitInput> }} module_or_path - Passing `InitInput` directly is deprecated.
*
* @returns {Promise<InitOutput>}
*/
declare function wasm_bindgen (module_or_path: { module_or_path: InitInput | Promise<InitInput> } | InitInput | Promise<InitInput>): Promise<InitOutput>;
export type WebHandle = wasm_bindgen.WebHandle;
export default function(): wasm_bindgen;
File diff suppressed because it is too large Load Diff