Author SHA1 Message Date
DCCONSTRUCTIONS 9d3329f608 docs: record rover integration closeout and verification 2026-09-25 16:49:52 +03:00
DCCONSTRUCTIONS 399af98841 merge: integrate final rover control with operator simulation workspace 2026-09-25 16:48:42 +03:00
DCCONSTRUCTIONS 71a648fec8 feat(fleet): preserve operator VESC integration before final driver merge 2026-09-25 16:40:46 +03:00
DCCONSTRUCTIONS dad11b47d7 docs(simulation): record polygon experiments and bounded route acceptance 2026-09-25 16:40:46 +03:00
DCCONSTRUCTIONS e627500295 feat(control-station): add AI polygon setup streaming and mission controls 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS f01bd39037 feat(simulation): add Worker AI polygon runtime and terrain navigation 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS a7c64e009d fix(core): keep launchd service logs outside protected Downloads 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS 6bbbd5bf7d docs(rover): record calibration control acceptance and operating boundaries 2026-09-25 16:39:19 +03:00
DCCONSTRUCTIONS bc55901d4d fix(core): serialize planning reports outside the control event loop 2026-09-25 16:37:57 +03:00
DCCONSTRUCTIONS 6d772b29fe feat(control-station): add rover scene telemetry and guarded keyboard control 2026-09-25 16:37:57 +03:00
DCCONSTRUCTIONS 63cbb08ea0 feat(rover): add drive profiles and leased remote command channel 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 53818230f9 feat(node): ship managed environment startup and USB recovery 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 45fb14b206 feat(vesc): integrate native calibration diagnostics and configuration archives 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 24bbaefb00 chore(repo): keep private evidence local and version rover assets with LFS 2026-09-25 16:37:20 +03:00
DCCONSTRUCTIONS 2e5d52521f feat: finalize corrected-route planning and Rerun recording review 2026-09-22 10:10:03 +03:00
DCCONSTRUCTIONS c804d89b18 fix(lab): separate direct device launches from planning profiles 2026-09-21 09:19:14 +03:00
442 changed files with 56546 additions and 1117 deletions
+2
View File
@@ -10,3 +10,5 @@
*.las binary
*.lcc binary
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/public/rover-scene/*.glb filter=lfs diff=lfs merge=lfs -text
+7
View File
@@ -49,3 +49,10 @@ mqtt.summary.json
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
!tests/fixtures/**/*.pcap
!tests/fixtures/**/*.pcapng
# Dependency caches may be links into an existing local checkout.
.venv
node_modules
# Private experiment evidence and generated build archives.
/outputs/
+18
View File
@@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code.
- Synthetic or explicitly redacted fixtures may be committed under
`tests/fixtures/`.
## Onboard environment ownership — owner requirement, 2026-09-24
- Every onboard OS change belongs to the shipped, versioned installer or the
application's environment/device preparation workflow from the first test.
The operator installs the build and configures it in the application; never
require hand-written service files, USB rules, permission fixes or commands.
- This applies to all Node features, including GUI/service autostart and USB
recovery, not just individual camera drivers. Read-only SSH inspection and
bounded artifact-owned build staging remain allowed.
- Detect the distribution, architecture and required capabilities. Maintain
explicit supported environment profiles; do not claim arbitrary Linux
compatibility from a qualified Ubuntu build. Preserve foreign configuration,
report unsupported features, make preparation repeatable, and ship rollback.
- Do not add USB administration controls or port resets to inventory refresh.
Automatic startup recovery may retry terminal enumeration failures only;
preserve enumerated devices and active companion ports. Device identity and
assignments must survive changes of USB port and tty number.
## Insta360 and clean-host installation — owner requirement, 2026-09-08
- The current X4 starting point is USB enumeration only. SDK installation,
+1
View File
@@ -0,0 +1 @@
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
+24
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",
@@ -14,6 +15,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
@@ -898,6 +900,19 @@
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"link": true
},
"node_modules/@nvidia/ov-web-rtc": {
"version": "6.7.0",
"resolved": "https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/@nvidia/ov-web-rtc/-/@nvidia/ov-web-rtc-6.7.0.tgz",
"integrity": "sha512-Knt393yvuI1tFmQgJM7VuMlVKDh5GyuKq8fAIarNLyY7Ucxw+lSY/72ceWR+3HyCnNtL39J0DZ27dn9riqpGWw==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/@rerun-io/web-viewer": {
"version": "0.36.3",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
@@ -1614,6 +1629,15 @@
"node": ">=6.9.0"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+3
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",
@@ -17,6 +19,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
Binary file not shown.
@@ -0,0 +1,39 @@
{
"source": "DCD-006_rover_v020.blend",
"source_sha256": "c49a0470974bbe65b4e65b4b5c06cb887730715a1ddfa231b86426fed6de8f79",
"model": "DCD-006 v020 complete rover",
"source_object_count": 1426,
"render_mesh_count": 1,
"original_bounds": [
[
-0.527999997138977,
-0.45249998569488525,
0.0
],
[
0.6350000500679016,
0.45249998569488525,
0.734000027179718
]
],
"dimensions_m": [
1.1630001068115234,
0.9049999713897705,
0.734000027179718
],
"translation_m": [
-0.05350002646446228,
-0.0,
-0.0
],
"glb_bytes": 54960576,
"glb_sha256": "15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79",
"material_source_sha256": "ac50e29fe9fe7ab4012628c52fce30e262e9e0bba4a4d49abe59a90c84391b2e",
"material_rules": {
"metal": 1,
"rubber": 2,
"steel": 6,
"paint": 3
},
"source_saved": false
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

@@ -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).");
+57 -9
View File
@@ -14,6 +14,7 @@ import {
Inspector,
StatusBadge,
TextField,
ToastStack,
UserProfileMenu,
Window,
WindowFooterActions,
@@ -49,6 +50,7 @@ import type {
} from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import {
@@ -69,6 +71,9 @@ import {
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css";
@@ -151,6 +156,7 @@ export default function App() {
polygonDatasetRoute.active ? "data" : null,
);
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
const [sourceUrl, setSourceUrl] = useState("");
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
@@ -177,6 +183,7 @@ export default function App() {
const appliedProfileKeyRef = useRef<string | null>(null);
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
const closeDisplayRef = useRef<() => void>(() => {});
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
@@ -224,6 +231,13 @@ export default function App() {
);
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
replayActiveRef.current = replayPresented;
const sessionDisplayProfile = useSessionDisplayProfile(replayPresented ? recordedReplay?.sessionId ?? null : null, (settings) => {
sceneSettingsRef.current = settings;
displayDraftRef.current = settings;
confirmedSceneSettingsRef.current = settings;
setSceneSettings(settings);
setDisplayDraft(settings);
});
useEffect(() => {
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
@@ -306,6 +320,7 @@ export default function App() {
const remote = runtime.state?.viewerSettings;
if (
!remote ||
replayActiveRef.current ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
@@ -328,7 +343,7 @@ export default function App() {
useEffect(() => {
const profile = workspaceLayoutProfile.profile;
if (!profile) return;
if (!profile || selectedRecordedSessionIdRef.current) return;
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
viewerSettingsCommitTimerRef.current = null;
@@ -399,7 +414,10 @@ export default function App() {
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
if (windowId === "sources") setSourceWindowOpen(false);
if (windowId === "display") setDisplayWindowOpen(false);
if (windowId === "display") {
setDisplayWindowOpen(false);
closeDisplayRef.current();
}
if (windowId === "layers") setLayerInspectorOpen(false);
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
}, []);
@@ -431,9 +449,10 @@ export default function App() {
}
};
const openView = (viewId: string) => {
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
const definition = workspaceById(viewId);
if (!definition) return;
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
setActiveRoot(definition.root);
workspace.openView(viewId);
};
@@ -459,14 +478,22 @@ export default function App() {
const next = { ...displayDraftRef.current, ...patch };
displayDraftRef.current = next;
setDisplayDraft(next);
sessionDisplayProfile.edited();
if (viewerSettingsCommitTimerRef.current !== null) {
window.clearTimeout(viewerSettingsCommitTimerRef.current);
}
// Preview and persistence are separate. An open inspector must not freeze
// either its controls or the shared accumulation slider below the scene.
if (replayActiveRef.current && (patch.pointDecimationPercent === 0 || patch.pointDecimationPercent === 100)) {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(next);
return;
}
viewerSettingsCommitTimerRef.current = window.setTimeout(() => {
viewerSettingsCommitTimerRef.current = null;
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
}, viewerSettingsQuietPeriodMs);
}, []);
}, [sessionDisplayProfile.edited]);
const flushDisplaySettings = useCallback(() => {
if (viewerSettingsCommitTimerRef.current === null) return;
@@ -476,8 +503,15 @@ export default function App() {
}, []);
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
sessionDisplayProfile.edited();
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
}, [commitDisplaySettings]);
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
closeDisplayRef.current = () => {
const settings = {...displayDraftRef.current};
commitDisplaySettings(settings);
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
};
const openDisplay = () => {
if (!sceneWorkspaceActive) return;
@@ -788,7 +822,7 @@ export default function App() {
/>
) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
@@ -806,15 +840,25 @@ export default function App() {
utilityActions={contentActions}
onClose={workspace.closeView}
>
<PlanningCaptureGuard
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
onResume={() => openView('local-device', 'planning')}
>
{activeDefinition.kind === "device" ? (
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
launchProfile === 'planning' ? <PlanningConnectionWindow>
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
</PlanningConnectionWindow> : <DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene", 'direct')}
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
/>
) : activeDefinition.kind === "recordings" && sessionOverview.open && recordedReplay && replayPresented ? (
<SessionOverviewWorkspace key={recordedReplay.sessionId} sessionId={recordedReplay.sessionId} />
) : (
<WorkspaceRenderer
launchProfile={launchProfile}
definition={activeDefinition}
fleetCreateRequest={fleetCreateRequest}
headerToolsHost={workspaceHeaderToolsHost}
@@ -858,6 +902,7 @@ export default function App() {
}}
/>
)}
</PlanningCaptureGuard>
</ApplicationPanel>
) : null}
/>
@@ -1020,8 +1065,11 @@ export default function App() {
onClose={() => closeSceneWindow("display")}
>
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
commitDisplayPatch={commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
</Window>
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
onDismiss={sessionDisplayProfile.dismissError} />
<Window
open={sceneWorkspaceActive && layerInspectorOpen}
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
const sessions = useObservationSessions({
limit,
scope: "source",
scope: "standalone",
replayEnabled: blockedReason === null,
onReplayBegin,
onReplayAccepted,
@@ -8,6 +8,7 @@ import {
} from "./rerun/recordedRerunCameraJournal";
import type { SceneSettings } from "../sceneSettings";
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
import {
advanceLiveReceiverOpenWatchdog,
advanceLiveReceiverWatchdog,
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
sceneSettings?: Pick<
SceneSettings,
| "pointDecimationPercent"
| "accumulationSeconds"
| "showGrid"
| "showPoints"
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
cameraContract?: string | null;
appliedFollowTrajectory?: boolean | null;
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
configureCameraJournal?: (
eye: RecordedRerunCameraEye,
spatialViewportStart: number,
) => void;
getCameraEye?: () => RecordedRerunCameraEye;
setCameraViewportStart?: (spatialViewportStart: number) => void;
getCameraEye?: () => RecordedRerunCameraEye | null;
getCurrentTimeNs?: () => number | null;
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
channel: {
readonly ready: boolean;
send_rrd: (rrdBytes: Uint8Array) => void;
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
currentTimeNs,
reactivateUpdates = false,
onCameraMaxOrbitalRadius,
displayPointBank,
perceptionLayers = {
enabled: false,
detections2d: false,
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
currentTimeNs?: number | null;
reactivateUpdates?: boolean;
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
displayPointBank?: string | null;
perceptionLayers?: RecordedPerceptionLayers;
fetcher?: typeof globalThis.fetch;
},
): Promise<Uint8Array> {
const resolvedUnifiedPerception =
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
// Rerun exposes its interpolated TimeReal cursor as f64 nanoseconds, even
// when paused after seeking. The API needs an integer pose-query timestamp.
// Quantize at this boundary; never round the viewer's actual playback cursor.
const requestTimeNs = currentTimeNs == null ? currentTimeNs : Math.round(currentTimeNs);
const base = new URL(origin);
const endpoint = new URL(endpointUrl, base.origin);
if (
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
...cameraEye.eyeUp,
].some((value) => !Number.isFinite(value))) ||
(currentTimeNs !== undefined && currentTimeNs !== null && (
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
)) ||
[
perceptionLayers.enabled,
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
application_id: identity.applicationId,
recording_id: identity.recordingId,
blueprint_session_id: blueprintSessionId,
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
accumulation_seconds: settings.accumulationSeconds,
show_grid: settings.showGrid,
show_points: settings.showPoints,
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
eye_up: cameraEye?.eyeUp ?? null,
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
current_time_ns: currentTimeNs,
current_time_ns: requestTimeNs,
}),
show_detections_2d: perceptionLayers.detections2d,
show_camera_image: perceptionLayers.cameraImage ?? true,
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
followTrajectory: boolean;
},
): string {
// Following is a tracking property of the current native eye. It must never
// initialize the browser journal again: doing so replaces the operator's
// current pose with the startup preset immediately before the blueprint is
// sent. Plan/3D and explicit reset are the only preset transitions.
// Following uses the native eye snapshot, never the startup preset.
// Plan/3D and explicit reset are the only preset transitions.
return [activeView, viewResetGeneration, planView].join(":");
}
@@ -841,6 +842,7 @@ function RerunViewportInstance({
const presentationGateRef = useRef(presentationGate);
presentationGateRef.current = presentationGate;
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
const recordedBlueprintUrl = sourceUrl
? resolveRecordedBlueprintUrl(
@@ -857,6 +859,17 @@ function RerunViewportInstance({
const recordedPointColorsUrl = recordedBlueprintUrl
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
const pointDisplay = useRecordedPointDisplay({
endpoint: recordedBlueprintUrl, settings: sceneSettings, revision: blueprintChannelRevision,
sourceGeneration: recordedArtifact?.sha256,
ready: status === 'ready' && pointDisplayVisibilityGate === `${recordedBlueprintUrl}:${blueprintChannelRevision}`,
onLoad: onPointColorLoadChange,
getOwner: () => {
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
return active?.channel.ready && identity ? {identity, send: bytes => active.channel.send_rrd(bytes)} : null;
},
});
const presentationStatus = rerunPresentationStatus(
status,
presentationGate,
@@ -1638,29 +1651,14 @@ function RerunViewportInstance({
cameraContract: null,
appliedFollowTrajectory: null,
pendingFollowCameraEye: null,
configureCameraJournal: (eye, spatialViewportStart) => {
if ("configure_camera_journal" in viewer) {
viewer.configure_camera_journal(eye, spatialViewportStart);
}
},
getCameraEye: () => "get_camera_eye" in viewer
? viewer.get_camera_eye()
: RECORDED_RERUN_ORBITAL_EYE,
setCameraViewportStart: (spatialViewportStart) => {
if ("set_camera_viewport_start" in viewer) {
viewer.set_camera_viewport_start(spatialViewportStart);
}
},
: null,
getCurrentTimeNs: () => {
const currentIdentity = recordedIdentityRef.current;
if (!currentIdentity) return null;
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
},
setCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if ("set_camera_max_orbital_radius" in viewer) {
viewer.set_camera_max_orbital_radius(maxOrbitalRadius);
}
},
channel,
};
blueprintChannelRef.current = blueprintChannel;
@@ -2010,6 +2008,7 @@ function RerunViewportInstance({
useEffect(() => {
if (!recordedPointColorsUrl || !sceneSettings) return;
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
const active = blueprintChannelRef.current;
const identity = recordedIdentityRef.current;
if (
@@ -2089,6 +2088,7 @@ function RerunViewportInstance({
sceneSettings?.colorMode,
sceneSettings?.customColor,
sceneSettings?.palette,
sceneSettings?.pointDecimationPercent,
]);
useEffect(() => {
@@ -2113,12 +2113,6 @@ function RerunViewportInstance({
},
);
const cameraContractChanged = active.cameraContract !== cameraContract;
if (cameraContractChanged) {
active.configureCameraJournal?.(
recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE,
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
}
const abort = new AbortController();
const previousFollow = active.appliedFollowTrajectory ?? false;
const enablingFollow = recordedFollowTrajectory && !previousFollow;
@@ -2143,7 +2137,9 @@ function RerunViewportInstance({
if (eyeRelativeToTracking && currentTimeNs == null) {
throw new Error("Recorded tracking cursor is unavailable");
}
return fetchRecordedBlueprintRrd(recordedBlueprintUrl, sceneSettings, identity, {
return fetchRecordedBlueprintRrd(recordedBlueprintUrl,
{...sceneSettings, showPoints: sceneSettings.showPoints && !pointDisplay.hidePoints}, identity, {
displayPointBank: pointDisplay.bank,
origin: window.location.origin,
blueprintSessionId: blueprintSessionIdRef.current,
signal: abort.signal,
@@ -2157,13 +2153,11 @@ function RerunViewportInstance({
planView: recordedPlanView,
cameraEye,
eyeRelativeToTracking,
currentTimeNs,
// Only following-eye transitions need a pose at the cursor. Supplying
// time for display-only updates makes the API scan the entire archive
// for camera bounds, although neither the camera nor its pose changed.
currentTimeNs: eyeRelativeToTracking ? currentTimeNs : undefined,
reactivateUpdates,
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
if (blueprintChannelRef.current === active) {
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
}
},
});
};
const canApply = () => (
@@ -2175,24 +2169,29 @@ function RerunViewportInstance({
const applyPayload = (payload: Uint8Array) => {
if (!canApply()) return false;
active.channel.send_rrd(payload);
active.setCameraViewportStart?.(
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
);
// Exclude inactive point generations before admitting their data into
// cached recordings whose embedded blueprint predates display controls.
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
return true;
};
void (async () => {
const firstEye = disablingFollow
? transitionEye ?? undefined
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
? pendingFollowEye ?? active.getCameraEye?.()
: undefined;
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
disablingFollow || recordedFollowTrajectory
);
const firstPayload = await requestBlueprint(
firstEye,
firstEyeIsTrackingRelative,
enablingFollow || disablingFollow || Boolean(pendingFollowEye),
// Upstream copies incoming blueprints into an active store. Every
// settings update must activate it; merely appending rows is invisible.
// Activation copies the incoming blueprint store, not the operator's
// edited store. Preserve its actual native eye on display-only updates;
// omitting the eye resurrects the embedded startup camera.
true,
);
if (!applyPayload(firstPayload)) return;
active.cameraContract = cameraContract;
@@ -2213,9 +2212,10 @@ function RerunViewportInstance({
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
if (!applyPayload(stabilizedPayload)) return;
active.pendingFollowCameraEye = null;
})().catch(() => {
})().catch((error: unknown) => {
// The recording remains usable with its embedded default blueprint.
// A later settings change retries through the same small channel.
if (!abort.signal.aborted) console.warn("Recorded scene settings were not applied", error);
});
return () => abort.abort();
}, [
@@ -2243,6 +2243,9 @@ function RerunViewportInstance({
sceneSettings?.showPoints,
sceneSettings?.showTrajectory,
sceneSettings?.showGrid,
pointDisplay.ready,
pointDisplay.bank,
pointDisplay.hidePoints,
]);
return (
@@ -2,11 +2,11 @@ import type {ReactNode} from "react";
import { Button, LoadingRegion } from "@nodedc/ui-react";
import { useSessionOverview } from "../../core/observation/useSessionOverview";
import { SessionOverviewScene } from "../observation/SessionOverviewScene";
export function MissionZonePreview({ sessionId, toolbar }: { sessionId: string; toolbar?:ReactNode }) {
export function MissionZonePreview({ sessionId, generation, toolbar }: { sessionId: string; generation: string; toolbar?:ReactNode }) {
const { data, error, retry } = useSessionOverview(sessionId);
const pending = !error && (!data || data.state === "queued" || data.state === "preparing");
const failure = error || (data?.state === "error" ? data.message : null);
if (pending) return <LoadingRegion loading label="Подготовка облака зоны" className="mission-planner__zone-loading" />;
if (failure || !data?.scene_url) return <div className="session-overview__empty"><span>{failure || "Облако записи недоступно."}</span><Button onClick={retry}>Повторить</Button></div>;
return <SessionOverviewScene sourceUrl={data.scene_url} toolbar={toolbar} hideTitle />;
return <SessionOverviewScene sourceUrl={`${data.scene_url}&reference_generation=${encodeURIComponent(generation)}`} toolbar={toolbar} hideTitle />;
}
@@ -0,0 +1,23 @@
import type {ReactNode} from 'react';
import {Button, StatusBadge} from '@nodedc/ui-react';
import {planningAwaitsCapture, usePlanningTest} from '../../core/missions/PlanningTestContext';
/** Composition-level handoff; never sends a device or acquisition command. */
export function PlanningCaptureGuard({enabled, onResume, children}: {
enabled: boolean;
onResume: () => void;
children: ReactNode;
}) {
const planning = usePlanningTest();
if (!enabled || !planningAwaitsCapture(planning.test)) return <>{children}</>;
return <div className="mission-planner__check">
<StatusBadge tone="warning">Исследование ожидает новую запись</StatusBadge>
<p>{planning.test!.draft.name}</p>
<p>Для самостоятельной съёмки завершите подготовленное исследование. Запись и подключение сканера не будут остановлены.</p>
{planning.error && <p role="alert">{planning.error}</p>}
<div className="mission-planner__actions">
<Button loading={planning.busy} onClick={() => void planning.finish()}>Завершить исследование</Button>
<Button disabled={planning.busy} onClick={onResume}>Вернуться к исследованию</Button>
</div>
</div>;
}
@@ -8,11 +8,11 @@ export function PlanningConnectionWindow({children}:{children:ReactNode}){
const p=usePlanningTest(); const [open,setOpen]=useState(true);
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
useEffect(()=>{
if(!p.selected||!p.test||selection||selectionTransitionPending)return;
if(!p.test||planningTestTerminal(p.test)||selection||selectionTransitionPending)return;
const models=registry.models.filter(m=>m.plugin.manifest.metadata.id===p.test!.plugin_id);
if(models.length===1)void selectModel(models[0].model.id);
},[p.selected,p.test?.plugin_id,selection,registry,selectModel,selectionTransitionPending]);
if(!p.selected||!p.test)return <>{children}</>;
},[p.test?.plugin_id,p.test?.state,selection,registry,selectModel,selectionTransitionPending]);
if(!p.test)return <>{children}</>;
const t=p.test, terminal=planningTestTerminal(t);
const details=<><StatusBadge tone="neutral">Профиль · Планирование</StatusBadge>
<p>{t.draft.name} · эталон {t.draft.zone.label} · {t.draft.route.length_m.toFixed(1)} м</p>
@@ -1,53 +1,30 @@
import { Button, Icon, Inspector, InspectorSelectField, LoadingRegion, RangeControl, SegmentedControl, TextField } from '@nodedc/ui-react';
import { endAtDistance, indexAtDistance, routeLength, canSelectSession, canStartPlanningRoute } from '../../core/missions/planner';
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import type { useRegistrationTest } from '../../core/missions/useRegistrationTest';
export function PlanningProjectSettings({p,t,mode,setMode,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; t:ReturnType<typeof useRegistrationTest>;
mode:'scanner'|'recording';setMode:(mode:'scanner'|'recording')=>void;onStart:()=>void;starting:boolean;
export function PlanningProjectSettings({p,onStart,starting}:{
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
}) {
const disabled=p.busy||starting;
const length=routeLength(p.poses);
return <Inspector variant="panel" defaultOpen={['project','zone','route','query']} sections={[
const routeReady=canStartPlanningRoute(length,'scanner');
return <><Inspector variant="panel" defaultOpen={['project','zone']} sections={[
{id:'project',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
<TextField label="Название проекта" placeholder="Название совмещения" value={p.name} maxLength={120} disabled={disabled} onChange={e=>p.setName(e.target.value)}/>
</div>},
{id:'zone',label:'Эталон',icon:<Icon name="globe"/>,content:<div className="inspector-control-stack">
<InspectorSelectField label="Сохранённая запись" searchable value={p.sessionId} options={p.options} disabled={disabled||p.catalogBusy} onChange={p.chooseSource}/>
{p.cursor&&<Button disabled={p.catalogBusy} onClick={()=>void p.loadMore()}>Ещё записи</Button>}
{p.source&&<small>{p.source.poses.length.toLocaleString('ru-RU')} положений · {p.source.path_m.toFixed(2)} м</small>}
</div>},
{id:'route',label:'Участок эталона',icon:<Icon name="plan"/>,content:<div className="inspector-control-stack">
{p.source&&!p.sourceChanged?<>
<RangeControl label="Начало участка" value={p.source.poses[p.start]?.distance_m??0} min={0} max={p.source.poses[p.source.poses.length-2].distance_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>{const n=indexAtDistance(p.source!,distance,0,p.source!.poses.length-2);p.setStart(n);if(n>=p.end)p.setEnd(n+1);}}/>
<RangeControl label="Конец участка" value={p.source.poses[p.end]?.distance_m??0} min={p.source.poses[p.start+1]?.distance_m??0} max={p.source.path_m} step="any" disabled={disabled}
formatValue={n=>`${n.toFixed(1)} м`} onChange={distance=>p.setEnd(indexAtDistance(p.source!,distance,p.start+1,p.source!.poses.length-1,true))}/>
<Button disabled={disabled} onClick={()=>p.setEnd(endAtDistance(p.source!,p.start,30))}>30 м от начала участка</Button>
<Button disabled={disabled} onClick={()=>{p.setStart(0);p.setEnd(p.source!.poses.length-1);}}>Вся траектория</Button>
{p.source&&<>
<small>Вся запись · {p.source.path_m.toFixed(2)} м · {p.source.poses.length.toLocaleString('ru-RU')} положений</small>
<InspectorSelectField label="Направление" value={p.direction} onChange={p.setDirection} disabled={disabled} options={[{value:'forward',label:'По записи'},{value:'reverse',label:'В обратную сторону'}]}/>
<small>{length.toFixed(2)} м · {p.poses.length.toLocaleString('ru-RU')} положений</small>
</>:<p>Выберите сохранённую запись эталона.</p>}
</div>},
{id:'query',label:'Повторный проход',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
<SegmentedControl label="Источник повторного прохода" value={mode} onChange={setMode} items={[{value:'scanner',label:'Новый проход',disabled},{value:'recording',label:'Из записи',disabled}]}/>
{mode==='scanner'?<p>После запуска откроется подключение сканера. Новый проход записывается отдельным проектом.</p>:<>
<InspectorSelectField label="Повторная запись" value={t.sessionId} disabled={disabled} searchable onChange={t.setSessionId}
options={[{value:'',label:'Выберите повторный проход'},...p.sessions.filter(canSelectSession).map(s=>({value:s.id,label:s.label,description:s.id===p.sessionId?'Та же запись · внутренняя проверка':'Сохранённые облако и траектория'}))]}/>
<LoadingRegion loading={t.loading} label="Подготовка повторного прохода">
{t.source&&<div className="inspector-control-stack">
<RangeControl label="Начало повторного участка" min={0} max={t.source.poses[t.source.poses.length-2].distance_m} step="any" value={t.source.poses[t.start].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`}
onChange={n=>{const start=indexAtDistance(t.source!,n,0,t.source!.poses.length-2);t.setStart(start);t.setEnd(endAtDistance(t.source!,start,20));}}/>
<RangeControl label="Конец повторного участка" min={t.source.poses[t.start+1].distance_m} max={t.source.path_m} step="any" value={t.source.poses[t.end].distance_m} disabled={disabled} formatValue={n=>`${n.toFixed(1)} м`} onChange={n=>t.setEnd(indexAtDistance(t.source!,n,t.start+1))}/>
</div>}
</LoadingRegion>
</>}
<small>{mode==='scanner'
?'Длина выбранного участка задаёт предел прохода. Ограничения по времени нет; запись сканера не останавливается автоматически. Неподвижная калибровка просматривает весь выбранный маршрут. После калибровки оставайтесь на месте до статуса «Сопровождение»; при отсутствии или неоднозначности совпадения сцена прямо сообщит причину и не начнёт сопровождение.'
:'Участки от 3 до 40 м. Начальная привязка — выбранное место старта и одинаковое направление.'}</small>
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!canStartPlanningRoute(length,mode)||(mode==='recording'&&(!t.source||t.loading||!canStartPlanningRoute(t.length,'recording')))} onClick={onStart}><Icon name="play"/>{mode==='scanner'?'Начать новый проход':'Запустить совмещение'}</Button>
{(p.error||t.error)&&<p role="alert">{p.error||t.error}</p>}
</div>},
]}/>;
]}/>
<div className="inspector-control-stack">
{p.source&&!routeReady&&<small>В записи недостаточно перемещения для привязки. Выберите другой эталон.</small>}
<Button variant="primary" loading={starting} disabled={disabled||!p.ready||!routeReady} onClick={onStart}><Icon name="play"/>Начать новый проход</Button>
{p.error&&<p role="alert">{p.error}</p>}
</div>
</>;
}
@@ -2,10 +2,10 @@ import { type ReactNode, useEffect, useRef, useState } from "react";
import { Button, LoadingRegion, RangeControl, SegmentedControl } from "@nodedc/ui-react";
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
import type { RecordedRerunViewer } from "../rerun/recordedRerunFacade";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode } from "../../core/observation/sessionOverviewSpatial";
import { fetchOverviewSpatial, updateOverviewSpatial, type OverviewSpatialMetadata, type OverviewViewMode, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean }) {
export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false, compareVersions=false }: { sourceUrl: string; toolbar?:ReactNode; hideTitle?:boolean; compareVersions?:boolean }) {
const host = useRef<HTMLDivElement>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [retry, setRetry] = useState(0);
@@ -14,6 +14,10 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
const [mode, setMode] = useState<OverviewViewMode>("3d");
const [viewError, setViewError] = useState<string | null>(null);
const [visiblePoints, setVisiblePoints] = useState<number | null>(null);
const [representation, setRepresentation] = useState<OverviewRepresentation>("original");
const [appliedRepresentation, setAppliedRepresentation] = useState<OverviewRepresentation>("original");
const [updating, setUpdating] = useState(false);
const comparison = metadata?.comparison;
const appliedMode = useRef<OverviewViewMode | null>(null);
const controller = useRef<{ viewer: RecordedRerunViewer; channel: ReturnType<RecordedRerunViewer["open_channel"]> } | null>(null);
useEffect(() => {
@@ -23,7 +27,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
appliedMode.current = null;
setState("loading");
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(setMetadata).catch(() => { if (!disposed) setViewError("Параметры среза недоступны."); });
setRepresentation("original"); setAppliedRepresentation("original"); setUpdating(false);
void fetchOverviewSpatial(sourceUrl, abort.signal).then(data => {
if (disposed) return;
setRepresentation(data.default_representation ?? "original"); setMetadata(data);
}).catch(() => { if (!disposed) { setState("error"); setViewError("Версия облака недоступна."); } });
const runtime = createIsolatedRerunHost(host.current);
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
void runtime.ready.then(async ({ viewer, mount }) => {
@@ -44,29 +52,33 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
useEffect(() => {
if (state !== "ready" || !metadata || !controller.current) return;
const abort = new AbortController();
setUpdating(true);
const timer = setTimeout(() => {
const aspect = Math.max(.1, Math.min(20, (host.current?.clientWidth ?? 1) / Math.max(1, (host.current?.clientHeight ?? 1) - 28)));
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal).then(result => {
void updateOverviewSpatial(sourceUrl, ceiling, appliedMode.current === mode ? null : mode, aspect, abort.signal,
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
if (abort.signal.aborted || !controller.current) return;
controller.current.channel.send_rrd(result.bytes);
if (result.eye) controller.current.viewer.configure_camera_journal(result.eye, 0);
appliedMode.current = mode;
setVisiblePoints(result.visiblePoints); setViewError(null);
}).catch(() => { if (!abort.signal.aborted) setViewError("Не удалось обновить вид облака."); });
setAppliedRepresentation(comparison ? representation : "original"); setUpdating(false);
}).catch(() => { if (!abort.signal.aborted) { setUpdating(false); setViewError("Не удалось обновить вид облака. Показан предыдущий вид."); } });
}, 180);
return () => { abort.abort(); clearTimeout(timer); };
}, [state, metadata, sourceUrl, ceiling, mode, retry]);
}, [state, metadata, sourceUrl, ceiling, mode, retry, comparison, representation]);
const low = metadata?.height_min_m;
const high = metadata?.height_max_m;
const low = comparison?.height_min_m ?? metadata?.height_min_m;
const high = comparison?.height_max_m ?? metadata?.height_max_m;
return <>
<div className={`session-overview__scene-head ${hideTitle?"session-overview__scene-head--end":""}`}>{!hideTitle&&<h2>Облако и траектория</h2>}
{compareVersions && comparison && <SegmentedControl label="Версия облака" value={representation} onChange={setRepresentation}
items={[{ value: "original", label: "Исходное", disabled: state !== "ready" }, { value: "corrected", label: "Исправленное", disabled: state !== "ready" }]} />}
<SegmentedControl label="Вид облака" value={mode} onChange={setMode}
items={[{ value: "top", label: "Сверху", disabled: state !== "ready" }, { value: "3d", label: "3D", disabled: state !== "ready" }]} />
{toolbar}
</div>
<LoadingRegion loading={state === "loading"} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
<LoadingRegion loading={state === "loading" || (state === "ready" && !!metadata && visiblePoints === null && !viewError)} label="Загрузка облака" className="session-overview__scene">
<div ref={host} className={`session-overview__runtime ${hideTitle?"rerun-single-view-content":""}`} style={{ visibility: state === "ready" && metadata && visiblePoints !== null ? "visible" : "hidden" }} />
{low != null && high != null && high > low && state === "ready" && <div className="session-overview__height">
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling ?? high} min={low} max={high} step="any"
formatValue={value => `${value.toFixed(1).replace('.', ',')} м`} formatLimit={value => value.toFixed(1).replace('.', ',')}
@@ -74,7 +86,11 @@ export function SessionOverviewScene({ sourceUrl, toolbar, hideTitle=false }: {
</div>}
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</LoadingRegion>
{viewError ? <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>
: <span className="session-overview__note">{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек</span>}
<span className="session-overview__note" role="status" aria-busy={updating}>
{updating ? "Обновление · " : comparison ? `${appliedRepresentation === "corrected" ? "Исправленное" : "Исходное"} · ` : ""}
{ceiling == null ? "Без среза" : `Высота ≤ ${ceiling.toFixed(1)} м`} · {visiblePoints?.toLocaleString("ru-RU") ?? "—"} точек
{comparison && <><br />Выборка для просмотра. Исходная запись сохранена отдельно.</>}
</span>
{viewError && <div className="session-overview__note" role="alert">{viewError}<Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
</>;
}
@@ -1,244 +1,28 @@
// Presets and the native snapshot boundary. No DOM-input journal: only Rerun
// knows the actual eye after navigation, auto-fit, interpolation and tracking.
export type RecordedRerunCameraEye = {
readonly position: readonly [number, number, number];
readonly lookTarget: readonly [number, number, number];
readonly eyeUp: readonly [number, number, number];
};
type Vector3 = [number, number, number];
type DragMode = "rotate" | "pan" | "roll";
const ROTATION_RADIANS_PER_POINT = 0.004;
const RERUN_WEB_LINE_SCROLL_POINTS = 8;
const RERUN_ORBITAL_SCROLL_DIVISOR = 200;
const RERUN_WEB_PINCH_SCROLL_DIVISOR = 100;
const RERUN_MIN_ORBIT_DISTANCE = 0.02;
const DOM_WHEEL_DELTA_LINE = 1;
const DOM_WHEEL_DELTA_PAGE = 2;
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
position: [16, -16, 18],
lookTarget: [0, 0, 0],
eyeUp: [0, 0, 1],
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
};
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
position: [0, 0, 30],
lookTarget: [0, 0, 0],
eyeUp: [0, 1, 0],
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
};
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
const cross = (a: Vector3, b: Vector3): Vector3 => [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
const length = (value: Vector3) => Math.hypot(...value);
const normalize = (value: Vector3, fallback: Vector3): Vector3 => {
const magnitude = length(value);
return magnitude > Number.EPSILON ? scale(value, 1 / magnitude) : fallback;
};
const clamp = (value: number, minimum: number, maximum: number) => Math.max(minimum, Math.min(maximum, value));
const rotateAroundAxis = (value: Vector3, rawAxis: Vector3, angle: number): Vector3 => {
const axis = normalize(rawAxis, [0, 0, 1]);
const cosine = Math.cos(angle);
const sine = Math.sin(angle);
return add(
add(scale(value, cosine), scale(cross(axis, value), sine)),
scale(axis, dot(axis, value) * (1 - cosine)),
);
};
function cloneEye(value: RecordedRerunCameraEye): {
position: Vector3;
lookTarget: Vector3;
eyeUp: Vector3;
} {
return {
position: vector(value.position),
lookTarget: vector(value.lookTarget),
eyeUp: vector(value.eyeUp),
};
}
/**
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
* renderer. Rerun writes operator navigation into its active blueprint clone;
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
* here lets a later layer blueprint activate with the exact operator pose.
*/
export function createRecordedRerunCameraJournal(
canvas: HTMLCanvasElement,
scope: Window & typeof globalThis,
) {
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
let spatialViewportStart = 0;
let pointerId: number | null = null;
let dragMode: DragMode | null = null;
let previousPointer: readonly [number, number] | null = null;
let latestPointer: readonly [number, number] | null = null;
let maxOrbitalRadius = 1.0e17;
const isSpatialPoint = (event: MouseEvent) => {
const rect = canvas.getBoundingClientRect();
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
};
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
const usableUp = () => {
const fwd = forward();
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
? [1, 0, 0]
: cross(fwd, [0, 0, 1]);
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
const candidate = normalize(eye.eyeUp, fallback);
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
};
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
const rotate = (deltaX: number, deltaY: number) => {
const radius = orbitRadius();
const up = usableUp();
let fwd = forward();
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
fwd = normalize(
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
fwd,
);
const right = normalize(cross(fwd, up), [1, 0, 0]);
const maxPitch = 0.99 * Math.PI / 2;
const nextPitch = clamp(
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
-maxPitch,
maxPitch,
);
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
};
const pan = (deltaX: number, deltaY: number) => {
const fwd = forward();
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
const speed = 0.001 * orbitRadius();
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
eye.position = add(eye.position, translation);
eye.lookTarget = add(eye.lookTarget, translation);
};
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
const rect = canvas.getBoundingClientRect();
const left = rect.left + rect.width * spatialViewportStart;
const centerX = left + (rect.right - left) / 2;
const centerY = rect.top + rect.height / 2;
const relativeX = event.clientX - centerX;
const relativeY = event.clientY - centerY;
const divisor = relativeX * relativeX + relativeY * relativeY;
if (divisor <= Number.EPSILON) return;
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
};
const pointerDown = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (!isSpatialPoint(event)) return;
pointerId = event.pointerId;
previousPointer = [event.clientX, event.clientY];
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
};
const pointerMove = (event: PointerEvent) => {
latestPointer = [event.clientX, event.clientY];
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
const deltaX = event.clientX - previousPointer[0];
const deltaY = event.clientY - previousPointer[1];
previousPointer = [event.clientX, event.clientY];
if (dragMode === "rotate") rotate(deltaX, deltaY);
else if (dragMode === "pan") pan(deltaX, deltaY);
else roll(event, deltaX, deltaY);
};
const pointerEnd = (event: PointerEvent) => {
if (event.pointerId !== pointerId) return;
pointerMove(event);
pointerId = null;
previousPointer = null;
dragMode = null;
};
const wheel = (event: WheelEvent) => {
const rect = canvas.getBoundingClientRect();
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
? event.clientX
: latestPointer?.[0];
if (wheelX === undefined || rect.width <= 0) return;
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
? RERUN_WEB_LINE_SCROLL_POINTS
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
? rect.height
: 1;
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
// eframe negates the DOM delta before Rerun applies
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
// exponent products collapse to the raw accumulated DOM delta below.
const divisor = event.ctrlKey
? RERUN_WEB_PINCH_SCROLL_DIVISOR
: RERUN_ORBITAL_SCROLL_DIVISOR;
const radiusFactor = Math.exp(scrollPoints / divisor);
const offset = subtract(eye.position, eye.lookTarget);
const radius = length(offset);
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
// the scene-derived cap is allowed to stay there and is never snapped in.
const nextRadius = clamp(
radius * radiusFactor,
RERUN_MIN_ORBIT_DISTANCE,
Math.max(radius, maxOrbitalRadius),
);
eye.position = add(
eye.lookTarget,
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
);
};
canvas.addEventListener("pointerdown", pointerDown, true);
scope.addEventListener("pointermove", pointerMove, true);
scope.addEventListener("pointerup", pointerEnd, true);
scope.addEventListener("pointercancel", pointerEnd, true);
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
return {
current(): RecordedRerunCameraEye {
return {
position: [...eye.position],
lookTarget: [...eye.lookTarget],
eyeUp: [...eye.eyeUp],
};
},
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
eye = cloneEye(nextEye);
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
pointerId = null;
dragMode = null;
previousPointer = null;
},
setSpatialViewportStart(nextSpatialViewportStart: number) {
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
},
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
if (
Number.isFinite(nextMaxOrbitalRadius) &&
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
) {
maxOrbitalRadius = nextMaxOrbitalRadius;
}
},
dispose() {
canvas.removeEventListener("pointerdown", pointerDown, true);
scope.removeEventListener("pointermove", pointerMove, true);
scope.removeEventListener("pointerup", pointerEnd, true);
scope.removeEventListener("pointercancel", pointerEnd, true);
scope.removeEventListener("wheel", wheel, true);
},
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
if (value == null) return null; // No 3D frame rendered yet.
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
const eye = value as Record<string, unknown>;
const vector = (key: string): [number, number, number] => {
const item = eye[key];
if (!Array.isArray(item) || item.length !== 3 ||
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
throw new Error("Invalid native Rerun camera vector");
}
return [item[0], item[1], item[2]];
};
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
}
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
"set_current_time" | "set_playing"
> & {
open_channel: (name?: string) => Channel;
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
get_camera_eye: () => RecordedRerunCameraEye;
set_camera_viewport_start: (spatialViewportStart: number) => void;
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
get_camera_eye: () => RecordedRerunCameraEye | null;
};
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
@@ -90,16 +87,7 @@ export function createRecordedRerunFacade(invoke: RerunInvoke | null) {
set_active_timeline: (...args) => command("set_active_timeline", args),
set_current_time: (...args) => command("set_current_time", args),
set_playing: (...args) => command("set_playing", args),
configure_camera_journal: (eye, spatialViewportStart) => command(
"configure-camera-journal", [eye, spatialViewportStart],
),
get_camera_eye: () => command("get-camera-eye"),
set_camera_viewport_start: (spatialViewportStart) => command(
"set-camera-viewport-start", [spatialViewportStart],
),
set_camera_max_orbital_radius: (maxOrbitalRadius) => command(
"set-camera-max-orbital-radius", [maxOrbitalRadius],
),
};
return { facade, dispose, notify };
}
@@ -1,6 +1,6 @@
import type { WebViewer } from "@rerun-io/web-viewer";
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
let notify: RerunNotify | null = null;
const subscriptions = new Map<number, () => void>();
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
let cameraJournal: ReturnType<typeof createRecordedRerunCameraJournal> | null = null;
const send = (message: object) => notify?.(JSON.stringify(message));
const stop = () => {
notify = null;
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
try { channel.close(); } catch { /* Other channels must still close. */ }
}
channels.clear();
cameraJournal?.dispose();
cameraJournal = null;
const current = native;
native = null;
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
const starting = required();
try {
await starting.start(args[0], mount, args[1]);
if (native === starting && starting.canvas) {
cameraJournal?.dispose();
cameraJournal = createRecordedRerunCameraJournal(
starting.canvas,
window as Window & typeof globalThis,
);
}
if (native === starting) send({ type: "started", id });
} catch (error) {
if (native === starting) send({ type: "start-failed", id, message: String(error) });
@@ -87,10 +77,9 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
channels.delete(id);
}
break;
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
case "get-camera-eye": result = cameraJournal?.current(); break;
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
case "get-camera-eye": result = readNativeRerunCameraEye(
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
); break;
case "open": required().open(args[0]); break;
case "close": required().close(args[0]); break;
case "override_panel_state": required().override_panel_state(args[0], args[1]); break;
@@ -0,0 +1,66 @@
import {useEffect, useRef, useState} from 'react';
import {pointDisplayKey, streamRecordedPointDisplay, type PointDisplaySettings} from '../../core/observation/recordedPointDisplay';
import type {RecordedPointColorLoadState} from '../RerunViewport';
type Owner = {send: (bytes: Uint8Array) => void; identity: {applicationId: string; recordingId: string}};
export function useRecordedPointDisplay({endpoint, settings, revision, ready, getOwner, onLoad, sourceGeneration}: {
endpoint: string | null; settings: PointDisplaySettings | undefined; revision: number; ready: boolean;
getOwner: () => Owner | null; onLoad?: (state: RecordedPointColorLoadState) => void;
sourceGeneration?: string;
}) {
const [applied, setApplied] = useState<{endpoint: string; revision: number; key: string; bank: string; sourceGeneration?: string} | null>(null);
const ownerRef = useRef(getOwner); ownerRef.current = getOwner;
const loadRef = useRef(onLoad); loadRef.current = onLoad;
const failed = useRef(false);
const [retry, setRetry] = useState(0);
const key = settings ? pointDisplayKey(settings) : '';
const percent = settings?.pointDecimationPercent ?? 0;
const sameSource = applied?.endpoint === endpoint && applied?.revision === revision && applied?.sourceGeneration === sourceGeneration;
const matched = sameSource && applied?.key === key;
useEffect(() => {
// A new settings interaction retries a failed request, but accumulation or
// size edits must not cancel an already-running preparation of the same key.
if (failed.current) { failed.current = false; setRetry(value => value + 1); }
}, [settings]);
useEffect(() => {
if (!endpoint || !settings || !ready) return;
if (percent === 0 || percent === 100) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: percent === 100 ? 'Точки скрыты.' : 'Все точки.'});
return;
}
if (matched) {
loadRef.current?.({phase: 'ready', receivedBytes: 0, totalBytes: 0,
progress: 1, message: 'Прореженное облако готово.'});
return;
}
const owner = ownerRef.current();
if (!owner) return;
const abort = new AbortController();
failed.current = false;
// Never append another generation at the same temporal entity path: an
// accumulated range would otherwise draw older samples as well.
const bank = crypto.randomUUID().replaceAll('-', '');
loadRef.current?.({phase: 'loading', receivedBytes: 0, totalBytes: null, progress: null,
message: 'Готовим прореженное облако. Текущий вид сохранён.'});
void streamRecordedPointDisplay(endpoint, settings, owner.identity, bank, abort.signal, chunk => {
if (!abort.signal.aborted) owner.send(chunk);
}, undefined, sourceGeneration).then(bytes => {
if (abort.signal.aborted) return;
setApplied({endpoint, revision, key, bank, sourceGeneration});
loadRef.current?.({phase: 'ready', receivedBytes: bytes, totalBytes: bytes, progress: 1,
message: 'Прореженное облако готово.'});
}).catch(() => {
if (!abort.signal.aborted) {
failed.current = true;
loadRef.current?.({phase: 'error', receivedBytes: 0, totalBytes: null,
progress: null, message: 'Прореживание не применено. Предыдущий вид сохранён. Измените значение или закройте настройки для повтора.'});
}
});
return () => abort.abort();
}, [endpoint, key, revision, ready, retry, sourceGeneration]);
return {ready: !endpoint || percent === 0 || percent === 100 || matched,
bank: percent > 0 && percent < 100 && sameSource ? applied?.bank ?? null : null,
hidePoints: percent === 100};
}
@@ -0,0 +1,25 @@
import type {RoverState} from '../../core/fleet/useRoverControl';
import {drivePositions} from '../../../../../plugins/vesc/frontend/src/model';
const number=(v:number|undefined,digits=1)=>typeof v==='number'&&Number.isFinite(v)?v.toLocaleString('ru-RU',{maximumFractionDigits:digits}):'—';
export function RoverTelemetry({state}:{state:RoverState}){
const devices=state.snapshot.devices??[],profile=state.snapshot.profile;
const positions=drivePositions(profile?.layout??null);
const assigned=Object.entries(profile?.bindings??{}).map(([slot,binding])=>({
id:binding.device_id,uuid:binding.uuid,label:positions[slot]??slot,
reading:devices.find(d=>d.id===binding.device_id&&d.uuid===binding.uuid),
}));
const readings=[...assigned,...devices.filter(d=>!assigned.some(a=>a.id===d.id&&a.uuid===d.uuid)).map(d=>({...d,reading:d}))];
return <div className="rover-telemetry">
{!state.fresh?<p>Нет свежих показаний VESC.</p>:!readings.length?<p>Получение показаний VESC…</p>:readings.map(d=>{
const fresh=!!d.reading&&d.reading.age_ms<1000,v=fresh?d.reading?.values:undefined;
return <section key={d.uuid}><strong>{d.label}</strong><span>VESC {d.uuid.slice(0,6).toUpperCase()}{!d.reading?' · Нет связи':!fresh?' · Нет свежих данных':''}</span>
<dl><div><dt>Ток мотора</dt><dd>{number(v?.motor_current_a)} А</dd></div>
<div><dt>Ток батареи</dt><dd>{number(v?.input_current_a)} А</dd></div>
<div><dt>Напряжение</dt><dd>{number(v?.input_voltage_v)} В</dd></div>
<div><dt>Обороты, ERPM</dt><dd>{number(v?.erpm,0)}</dd></div>
<div><dt>Контроллер</dt><dd>{number(v?.mos_temperature_c)} °C</dd></div>
<div><dt>Ошибка VESC</dt><dd>{v?v.fault_code===0?'Нет':String(v.fault_code):'—'}</dd></div></dl>
</section>;
})}
</div>;
}
@@ -0,0 +1,70 @@
import {lazy,Suspense,useCallback,useEffect,useRef,useState} from 'react';
import {createPortal} from 'react-dom';
import {Button,Icon,IconButton,KeyButton,LoadingRegion,Select,StatusBadge,Switch,TextField,ToastStack,Window} from '@nodedc/ui-react';
import {keysFor,roverSettings,type RoverMode} from '../../core/fleet/roverInput';
import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput';
import type {RoverController} from '../../core/fleet/useRoverControl';
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
import './rover.css';
const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer'));
const labels:Record<string,string>={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
const storage=`missioncore.rover-view.v1:${vehicleID}`;
const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState<Set<string>>(new Set());
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null);
const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
useEffect(()=>{if(!active)stop();},[active,stop]);
useEffect(()=>{
if(!c.ready||!active||open||!stage.current)return;
const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
input.current=binding;c.setInputGuard(binding.valid);
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
},[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
const showSettings=()=>{stop();setOpen(true);};
const preparing=c.pending||(c.armed&&!c.ready);
const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
return <>
{createPortal(<IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton>,header.actionsTarget)}
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
<Suspense fallback={null}><Scene modelUrl="/rover-scene/dcd006-v020.glb" cameraAutoRotate={false} onModelState={setModelState}/></Suspense>
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div>
{c.ready&&<div className="rover-view__controls">
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
disabled={!c.ready||!active} pressed={held.has(code)}
onPointerDown={e=>{if(!c.ready||!e.isTrusted||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);stage.current?.focus();input.current?.pointerDown(e.pointerId,code);}}
onPointerUp={e=>input.current?.pointerUp(e.pointerId)}
onPointerCancel={e=>input.current?.pointerCancel(e.pointerId)}
onLostPointerCapture={e=>input.current?.pointerCancel(e.pointerId)}>{code.slice(3)}</KeyButton>)}
</div>
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
</div>}
<div className="rover-view__authority">
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
</div>
</div>
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
<Select label="3D-модель" value={settings.model} options={[{value:'',label:'Не выбрана'},{value:'dcd006-v020',label:'DCD-006 · полный ровер v020'}]} onChange={model=>setSettings(v=>({...v,model}))}/>
<TextField label="Предел тока каждого мотора, А" type="number" min={.5} max={30} step={.5} value={settings.currentA} onChange={e=>setSettings(v=>({...v,currentA:Number(e.target.value)}))}/>
<TextField label="Скорость, ERPM" hint="300–3000" description="Электрические обороты двигателя. Скорость по земле требует параметров трансмиссии." type="number" min={300} max={3000} step={100} value={settings.maxErpm} onChange={e=>setSettings(v=>({...v,maxErpm:Number(e.target.value)}))}/>
<Switch label="Моторы остановлены, движение можно начинать" checked={confirmed} onChange={setConfirmed}/>
{availability&&<StatusBadge tone="warning">{availability}</StatusBadge>}
<p>Движение — только при удержании клавиш или кнопок. При уходе из окна команда снимается. Команда с пульта прекращает удалённое управление.</p>
<Button variant="primary" tone="neutral" disabled={!confirmed||!c.canArm||!(settings.currentA>=.5&&settings.currentA<=30&&settings.maxErpm>=300&&settings.maxErpm<=3000)} loading={c.pending}
onClick={async()=>{const acquired=await c.arm(settings.currentA,settings.maxErpm);if(acquired){setOpen(false);if(document.hasFocus())stage.current?.focus();}}}>Включить управление</Button>
</div></Window>
<ToastStack onDismiss={c.clearError} items={c.error?[{id:'rover-control-error',tone:'error',title:'Управление остановлено',description:c.error}]:[]}/>
</>;
}
@@ -0,0 +1,16 @@
{
"source": "NodeDC ThreeDAssetNode / PlayCanvasViewer",
"files": {
"PlayCanvasViewer.tsx": "34cd6df2e47a7714288aeb5afb2859b1058eb91dc1819abbf296c6a8937a1533",
"playcanvasPostFx.ts": "91cfb7f8ba821fef14681c6c25a61f430ce25aae65b0f181d359f38e37ccd014",
"sceneTree.ts": "eead378c2402f69e2c68cd1fc2a5ec9028555dced53858c600c599f613dda516",
"environment-map.png": "793f72dce207c1a4d2bdb262610f688bd655a79066298b672b642f228cf7d230"
},
"adaptations": [
"Domain asset URL and error/loading callbacks",
"No fallback to a different vehicle",
"Removed unused node-editor type helper; rendering settings unchanged",
"Observe the host pane on resize; preserve responsive canvas CSS sizing",
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect"
]
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,393 @@
export type PostFxSettings = {
lighting: {
exposure: number
skyBoxIntensity: number
}
envAtlas: {
enabled: boolean
background: boolean
reflection: boolean
intensity: number
reflectionIntensity: number
brightness: number
contrast: number
saturation: number
toneMapping: number
mip: number
rotation: number
}
skybox: {
enabled: boolean
background: boolean
reflection: boolean
intensity: number
reflectionIntensity: number
mip: number
rotation: number
colorA: string
colorB: string
}
rendering: {
backgroundColor: string
wireframe: boolean
renderFormat: number
renderFormatFallback0: number
renderFormatFallback1: number
stencil: boolean
renderTargetScale: number
samples: number
sharpness: number
toneMapping: number
sceneColorMap: boolean
sceneDepthMap: boolean
fog: 'none' | 'linear' | 'exp' | 'exp2'
fogColor: string
fogRange: [number, number]
fogDensity: number
fogStart: number
fogEnd: number
}
grid: {
enabled: boolean
colorX: string
colorZ: string
colorMain: string
alphaX: number
alphaZ: number
alphaMain: number
dotsEnabled: boolean
dotsColor: string
dotsAlpha: number
dotsDiameter: number
crossEnabled: boolean
crossColor: string
crossAlpha: number
crossLength: number
crossWidth: number
fadeStart: number
fadeEnd: number
}
ssao: {
type: 'none' | 'lighting' | 'combine'
blurEnabled: boolean
intensity: number
radius: number
samples: number
power: number
minAngle: number
scale: number
}
bloom: {
enabled: boolean
intensity: number
lastMipLevel: number
}
chromaticAberration: {
enabled: boolean
intensity: number
}
taa: {
enabled: boolean
jitter: number
}
grading: {
enabled: boolean
brightness: number
contrast: number
saturation: number
tint: string
}
lut: {
intensity: number
textureUrl?: string | null
}
vignette: {
enabled: boolean
intensity: number
inner: number
outer: number
curvature: number
color: string
}
directionalLight: {
enabled: boolean
color: string
intensity: number
azimuth: number
elevation: number
castShadows: boolean
shadowIntensity: number
shadowDistance: number
shadowResolution: number
shadowBias: number
normalOffsetBias: number
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
vsmBlurSize: number
}
shadowCatcher: {
enabled: boolean
size: number
yOffset: number
lightIntensity: number
lightColor: string
lightAzimuth: number
lightElevation: number
shadowIntensity: number
shadowDistance: number
shadowResolution: number
shadowBias: number
normalOffsetBias: number
shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3'
vsmBlurSize: number
}
}
export const DEFAULT_POSTFX: PostFxSettings = {
lighting: {
exposure: 1.21,
skyBoxIntensity: 0.86,
},
envAtlas: {
enabled: true,
background: false,
reflection: true,
intensity: 0.76,
reflectionIntensity: 0.6,
brightness: 1.65,
contrast: 1,
saturation: 1,
toneMapping: 0,
mip: 1,
rotation: 247,
},
skybox: {
enabled: false,
background: false,
reflection: true,
intensity: 0.62,
reflectionIntensity: 1,
mip: 0,
rotation: 0,
colorA: '#c5bfbf',
colorB: '#c7bcbc',
},
rendering: {
backgroundColor: '#111113',
wireframe: false,
renderFormat: 18,
renderFormatFallback0: 12,
renderFormatFallback1: 14,
stencil: false,
renderTargetScale: 1,
samples: 4,
sharpness: 0,
toneMapping: 4,
sceneColorMap: false,
sceneDepthMap: false,
fog: 'exp',
fogColor: '#dcc2ff',
fogRange: [0, 100],
fogDensity: 0.008,
fogStart: 0,
fogEnd: 100,
},
grid: {
enabled: true,
colorX: '#ffffff',
colorZ: '#ffffff',
colorMain: '#7a3cff',
alphaX: 0.18,
alphaZ: 0.18,
alphaMain: 0.6,
dotsEnabled: false,
dotsColor: '#ffffff',
dotsAlpha: 0.5,
dotsDiameter: 0.06,
crossEnabled: false,
crossColor: '#ffffff',
crossAlpha: 0.5,
crossLength: 0.5,
crossWidth: 0.06,
fadeStart: 0,
fadeEnd: 0,
},
ssao: {
type: 'none',
blurEnabled: true,
intensity: 0.5,
radius: 30,
samples: 12,
power: 6,
minAngle: 10,
scale: 1,
},
bloom: {
enabled: true,
intensity: 0.03,
lastMipLevel: 4,
},
chromaticAberration: {
enabled: true,
intensity: 30,
},
taa: {
enabled: false,
jitter: 1,
},
grading: {
enabled: true,
brightness: 0.837,
contrast: 1.1,
saturation: 1.126,
tint: '#ffffff',
},
lut: {
intensity: 1,
textureUrl: null,
},
vignette: {
enabled: true,
intensity: 1,
inner: 0.25,
outer: 1.52,
curvature: 0.78,
color: '#000000',
},
directionalLight: {
enabled: true,
color: '#ffffff',
intensity: 0.4,
azimuth: 0,
elevation: 0,
castShadows: false,
shadowIntensity: 0.5,
shadowDistance: 16,
shadowResolution: 1024,
shadowBias: 0,
normalOffsetBias: 0,
shadowType: 'vsm16',
vsmBlurSize: 8,
},
shadowCatcher: {
enabled: true,
size: 9,
yOffset: 0.001,
lightIntensity: 0.3,
lightColor: '#ffffff',
lightAzimuth: 20,
lightElevation: 60,
shadowIntensity: 0.29,
shadowDistance: 16,
shadowResolution: 2048,
shadowBias: 0,
normalOffsetBias: 0,
shadowType: 'vsm16',
vsmBlurSize: 8,
},
}
function isObject(value: any): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value)
}
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | null): PostFxSettings {
if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings
const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX))
const merge = (target: any, source: any) => {
if (!isObject(source)) return
for (const [key, value] of Object.entries(source)) {
if (isObject(value)) {
if (!isObject(target[key])) target[key] = {}
merge(target[key], value)
} else {
target[key] = value
}
}
}
merge(out, raw)
const legacy: any = raw as any
if (legacy?.hdrLight) {
const src = legacy.hdrLight
const env = legacy?.envAtlas ?? {}
if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled
if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox
if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity
if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip
if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation
}
if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) {
if (typeof legacy.hdrReflection.enabled === 'boolean') {
out.envAtlas.reflection = legacy.hdrReflection.enabled
}
}
if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') {
out.envAtlas.reflectionIntensity = out.envAtlas.intensity
}
if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') {
out.envAtlas.brightness = 1
}
if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') {
out.envAtlas.contrast = 1
}
if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') {
out.envAtlas.saturation = 1
}
if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') {
out.envAtlas.toneMapping = 0
}
if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') {
out.skybox.reflectionIntensity = out.skybox.intensity
}
if (out.grid && typeof out.grid.enabled !== 'boolean') {
out.grid.enabled = DEFAULT_POSTFX.grid.enabled
}
if (out.grid && typeof out.grid.colorX !== 'string') {
out.grid.colorX = DEFAULT_POSTFX.grid.colorX
}
if (out.grid && typeof out.grid.colorZ !== 'string') {
out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ
}
if (out.grid && typeof out.grid.colorMain !== 'string') {
out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain
}
if (out.grid && typeof out.grid.alphaX !== 'number') {
out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX
}
if (out.grid && typeof out.grid.alphaZ !== 'number') {
out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ
}
if (out.grid && typeof out.grid.alphaMain !== 'number') {
out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain
}
if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') {
out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled
}
if (out.grid && typeof out.grid.dotsColor !== 'string') {
out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor
}
if (out.grid && typeof out.grid.dotsAlpha !== 'number') {
out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha
}
if (out.grid && typeof out.grid.dotsDiameter !== 'number') {
out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter
}
if (out.grid && typeof out.grid.crossEnabled !== 'boolean') {
out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled
}
if (out.grid && typeof out.grid.crossColor !== 'string') {
out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor
}
if (out.grid && typeof out.grid.crossAlpha !== 'number') {
out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha
}
if (out.grid && typeof out.grid.crossLength !== 'number') {
out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength
}
if (out.grid && typeof out.grid.crossWidth !== 'number') {
out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth
}
return out as PostFxSettings
}
@@ -0,0 +1,20 @@
import type * as pc from 'playcanvas'
export type SceneTreeNode = {
id: string
name: string
enabled: boolean
children: SceneTreeNode[]
}
export function buildSceneTree(root: pc.GraphNode, map: Map<string, pc.Entity>): SceneTreeNode {
const id = String((root as any).getGuid?.() ?? (root as any)._guid ?? root.name)
const children = (root.children || []).map((c) => buildSceneTree(c, map))
map.set(id, root as pc.Entity)
return {
id,
name: root.name || 'Entity',
enabled: root.enabled !== false,
children,
}
}
@@ -0,0 +1,20 @@
.rover-view { position:relative; height:100%; min-height:200px; overflow:hidden; container-type:inline-size; }
.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
.rover-view .rover-view__scene { position:absolute; inset:0; }
.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); }
.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); }
.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; }
.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); }
.rover-key-KeyW { grid-column:2; }.rover-key-KeyA { grid-column:1; grid-row:2; }.rover-key-KeyS { grid-column:2; grid-row:2; }.rover-key-KeyD { grid-column:3; grid-row:2; }
.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; }
.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
@container (max-width: 380px) {
.rover-view__authority { top:48px; bottom:auto; }
}
.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); }
.rover-telemetry { height:100%; overflow:auto; padding:var(--nodedc-space-3); display:flex; flex-wrap:wrap; align-content:start; gap:var(--nodedc-space-4); font-size:var(--nodedc-font-size-sm); }
.rover-telemetry section { flex:1; min-width:170px; }.rover-telemetry section > span { display:block; color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-xs); margin-top:var(--nodedc-space-1); }
.rover-telemetry dl { display:grid; gap:var(--nodedc-space-1); margin-bottom:0; }.rover-telemetry dl > div { display:flex; justify-content:space-between; gap:var(--nodedc-space-3); }
.rover-telemetry dt { color:var(--nodedc-text-secondary); }.rover-telemetry dd { margin:0; font-variant-numeric:tabular-nums; }
@@ -0,0 +1,26 @@
import { FieldFrame, Select } from "@nodedc/ui-react";
import type { AiModules, AiSelection } from "../../core/simulation/aiPolygon";
const labels = { segmentation: "Поверхность", detection: "Объекты", geometry: "Рельеф",
range: "Расстояния", motion: "Навигация", policy: "Поведение" };
export function AiCompositionFields({ modules, selection, disabled, onChange }: {
modules: AiModules; selection: AiSelection; disabled: boolean; onChange: (value: AiSelection) => void;
}) {
return <section className="ai-polygon__section" aria-label="Композиция AI">
<h3>Модули AI</h3>
{modules.catalog.groups.filter(({ modules: options }) => options.length > 0).map(({ group, modules: options }) =>
<FieldFrame key={group} label={labels[group]}>
<Select label={`Модуль: ${labels[group]}`} disabled={disabled}
value={selection.selections.find((row) => row.group === group)?.module_id ?? ""}
options={options.map((module) => ({ value: module.moduleId, label: module.label }))}
onChange={(value) => {
const module = options.find((item) => item.moduleId === value);
if (!module) return;
onChange({ ...selection, selections: [...selection.selections.filter((row) => row.group !== group), {
group, module_id: module.moduleId, module_sha256: module.moduleSha256, parameters: module.defaults,
}] });
}} />
</FieldFrame>)}
</section>;
}
@@ -0,0 +1,28 @@
import { useState } from "react";
import { Button, Icon, ToastStack } from "@nodedc/ui-react";
import { controlAiRun, type AiRun } from "../../core/simulation/aiPolygon";
export function AiRunActions({ run, onChange }: { run: AiRun; onChange: (run: AiRun) => void }) {
const [busy, setBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const terminal = ["completed", "stopped", "failed"].includes(run.state);
const pending = !terminal && (run.control_sequence ?? 0) > (run.telemetry?.control_sequence ?? 0);
const command = (value: "pause" | "play" | "stop") => {
setBusy(value); setError(null);
void controlAiRun(run, value).then(onChange).catch((caught: Error) => setError(caught.message))
.finally(() => setBusy(null));
};
return <>
{!terminal ? <>
<Button disabled={!!busy || pending || ["starting", "stopping", "disconnected"].includes(run.state)}
variant={run.state === "ready" ? "primary" : "secondary"}
loading={busy === "pause" || busy === "play"}
onClick={() => command(run.state === "running" ? "pause" : "play")}>
{run.state === "starting" ? "Подготовка…" : run.state === "ready" ? "Запустить AI" : run.state === "running" ? "Пауза" : "Продолжить"}
</Button>
<Button disabled={!!busy || run.state === "stopping"} loading={busy === "stop" || run.state === "stopping"}
icon={<Icon name="stop" />} onClick={() => command("stop")}>Завершить симуляцию</Button>
</> : null}
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Команда не выполнена", description: error }]} onDismiss={() => setError(null)} /> : null}
</>;
}
@@ -0,0 +1,52 @@
import type { ReactNode } from "react";
import { StatusBadge } from "@nodedc/ui-react";
import { type AiRun } from "../../core/simulation/aiPolygon";
const STATES: Record<AiRun["state"], string> = {
starting: "Подготовка симуляции", ready: "Сцена открыта · AI выключен",
running: "AI управляет ровером", paused: "Пауза · движение остановлено",
stopping: "Завершаем симуляцию и inference…", completed: "Симуляция завершена",
stopped: "Симуляция и inference остановлены", failed: "Симуляция прервана",
disconnected: "Нет связи с Worker · состояние уточняется",
};
const REASONS: Record<string, string> = {
road: "Движение по тропе", obstacle: "Остановка перед препятствием", "no-road": "Путь не найден",
uncertain: "Недостаточно уверенности", "inference-error": "Inference недоступен",
replanning: "Ищем другой проезд", stuck: "Ровер застрял · прогон остановлен",
"goal-reached": "Цель достигнута", unstable: "Остановка: опасный наклон",
waiting: "Ожидаем возможность движения",
};
export function AiRunView({ run, connection }: { run: AiRun; connection: ReactNode }) {
const terminal = ["completed", "stopped", "failed"].includes(run.state);
const telemetry = run.telemetry;
const pending = !terminal && (run.control_sequence ?? 0) > (telemetry?.control_sequence ?? 0);
const preparingModels = run.state === "running" && run.phase === "models";
const reason = telemetry?.stop_reason === "unstable" ? "Остановка: опасный наклон"
: telemetry?.stop_reason === "stale-camera" || telemetry?.stop_reason === "stale-command"
? "Остановка: решение AI устарело" : telemetry?.stop_reason === "inference-error"
? "Остановка: inference недоступен" : telemetry?.decision?.reason === "road" && Math.abs(telemetry.speed_mps) < 0.01
? "Ровер не движется" : REASONS[telemetry?.decision?.reason ?? ""];
return <section className="ai-polygon__run" aria-label="Управление симуляцией">
<div className="ai-polygon__telemetry-row">
<div className="ai-polygon__facts">
{run.state !== "ready" && run.state !== "running" || preparingModels || pending ? <StatusBadge tone={run.state === "failed" || run.state === "disconnected" ? "warning" : "neutral"}>
{preparingModels ? "Загружаем AI-модели" : pending && run.state !== "stopping" ? "Worker выполняет команду…" : STATES[run.state]}
</StatusBadge> : null}
{telemetry && !terminal && run.state !== "stopping" && run.state !== "disconnected" ? <div className="ai-polygon__facts">
{run.state === "running" && !preparingModels && reason ? <strong>{reason}</strong> : null}
<span>Скорость: {Math.abs(telemetry.speed_mps).toFixed(2)} м/с</span>
<span>Время мира: {(telemetry.simulation_time_ns / 1e9).toFixed(1)} с</span>
{run.state === "running" ? <>
<span>Симуляция: {telemetry.rtf.toFixed(2)}×</span>
<span>Камера: {telemetry.sensor_fps.toFixed(1)} FPS</span>
<span>AI: {telemetry.ai_hz.toFixed(1)} решений/с</span>
{telemetry.inference_ms !== null ? <span>Inference: {telemetry.inference_ms.toFixed(0)} мс</span> : null}
{telemetry.frame_age_ms !== null ? <span>Возраст кадра: {telemetry.frame_age_ms.toFixed(0)} мс</span> : null}
</> : null}
</div> : null}
</div>
{connection}
</div>
{run.state === "failed" && run.message ? <p>{run.message}</p> : null}
</section>;
}
@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from "react";
import { Button, Icon, ProgressBar, TextField, ToastStack, Window } from "@nodedc/ui-react";
import { importAiWorld, type AiSource, type AiWorld } from "../../core/simulation/aiPolygon";
export function AiWorldImport({ source, resume, onClose, onImported }: {
source: AiSource | null; resume?: AiWorld; onClose: () => void; onImported: (world: AiWorld) => void;
}) {
const [draft, setDraft] = useState<AiSource>(source ?? {
name: "", author: "", license: "", source_url: "",
});
const [file, setFile] = useState<File | null>(null);
const [pending, setPending] = useState<AiWorld | undefined>(resume);
const [progress, setProgress] = useState(0);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const picker = useRef<HTMLInputElement>(null);
const controller = useRef<AbortController | null>(null);
useEffect(() => () => controller.current?.abort(), []);
const submit = async () => {
if (!file || busy) return;
setBusy(true); setError(null);
const abort = new AbortController(); controller.current = abort;
try { onImported(await importAiWorld(file, draft, setProgress, abort.signal, pending, setPending)); }
catch (caught) { if (!abort.signal.aborted) setError(caught instanceof Error ? caught.message : "Импорт не завершён."); }
finally { if (!abort.signal.aborted) setBusy(false); }
};
return <Window open onClose={onClose} title={resume ? "Продолжить импорт" : "Импорт локации"}
footer={<Button variant="primary" loading={busy} disabled={!file || !draft.name.trim() || !draft.author.trim() || !draft.license.trim()}
onClick={() => void submit()}>Импортировать</Button>}>
<div className="ai-polygon__form">
<TextField label="Название" value={draft.name} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, name: e.target.value })} />
<TextField label="Автор" value={draft.author} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, author: e.target.value })} />
<TextField label="Лицензия" value={draft.license} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, license: e.target.value })} />
<TextField label="Страница источника" value={draft.source_url ?? ""} disabled={busy || !!resume}
onChange={(e) => setDraft({ ...draft, source_url: e.target.value })} />
<input ref={picker} type="file" accept=".ply" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<Button disabled={busy} icon={<Icon name="upload" />} onClick={() => picker.current?.click()}>
{file?.name ?? "Выбрать Gaussian PLY"}
</Button>
{busy ? <ProgressBar value={progress} label={`Передано ${Math.round(progress * 100)}%`} /> : null}
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Импорт не завершён", description: error }]} onDismiss={() => setError(null)} /> : null}
</div>
</Window>;
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import { Button, TextField, TextAreaField, ToastStack } from "@nodedc/ui-react";
import { configureAiWorld, type AiWorld, type AiWorldSettings } from "../../core/simulation/aiPolygon";
export function AiWorldSetup({ world, disabled, onSaved }: {
world: AiWorld; disabled: boolean; onSaved: (world: AiWorld) => void;
}) {
const [draft, setDraft] = useState<AiWorldSettings>(world.settings);
const [route, setRoute] = useState((world.settings.route_xy ?? []).map((p) => p.join(" ")).join("\n"));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const field = (label: string, value: number, change: (value: number) => void) =>
<TextField label={label} type="number" step="any" value={Number.isFinite(value) ? value : ""}
disabled={disabled || busy} onChange={(e) => change(e.target.value === "" ? NaN : Number(e.target.value))} />;
return <div className="ai-polygon__setup">
{field("Метров на единицу сцены", draft.meters_per_unit, (v) => setDraft({ ...draft, meters_per_unit: v }))}
{([0, 1, 2] as const).map((axis) => <div key={axis}>
{field(`Поворот ${["X", "Y", "Z"][axis]}, °`, draft.rotation_degrees[axis], (v) => {
const rotation = [...draft.rotation_degrees] as [number, number, number]; rotation[axis] = v;
setDraft({ ...draft, rotation_degrees: rotation });
})}
</div>)}
{field("Уровень грунта Z, м", draft.ground_z, (v) => setDraft({ ...draft, ground_z: v }))}
{field("Старт X, м", draft.spawn_xy[0], (v) => setDraft({ ...draft, spawn_xy: [v, draft.spawn_xy[1]] }))}
{field("Старт Y, м", draft.spawn_xy[1], (v) => setDraft({ ...draft, spawn_xy: [draft.spawn_xy[0], v] }))}
{field("Направление, °", draft.heading_degrees, (v) => setDraft({ ...draft, heading_degrees: v }))}
{field("Высота камеры, м", draft.camera_height_m, (v) => setDraft({ ...draft, camera_height_m: v }))}
{field("Скорость до, м/с", draft.max_speed_mps, (v) => setDraft({ ...draft, max_speed_mps: v }))}
<TextAreaField label="Точки маршрута, X Y в метрах" rows={3} value={route}
hint="Каждая точка — с новой строки. Пустое поле: движение по видимой тропе."
disabled={disabled || busy} onChange={(e) => setRoute(e.target.value)} />
<Button disabled={disabled} loading={busy} onClick={() => {
const points = route.trim() ? route.trim().split(/\n+/).map((line) => line.trim().split(/\s+/).map(Number)) : [];
if (points.length > 32 || points.some((p) => p.length !== 2 || p.some((v) => !Number.isFinite(v)))) {
setError("Укажите не более 32 точек: два числа X Y в каждой строке."); return;
}
setBusy(true); setError(null);
void configureAiWorld(world, { ...draft, route_xy: points as [number, number][], prepared: true }).then(onSaved)
.catch((caught: Error) => setError(caught.message)).finally(() => setBusy(false));
}}>Сохранить старт и масштаб</Button>
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Настройки не сохранены", description: error }]} onDismiss={() => setError(null)} /> : null}
</div>;
}
@@ -0,0 +1,133 @@
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
import { Button, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
import { setAiCamera, type AiCamera, type AiRun, type AiWorld } from "../../core/simulation/aiPolygon";
import { useSimulationFullscreen } from "./useSimulationFullscreen";
// NVIDIA has process-global browser listeners. Serialize replacement/cleanup,
// including React StrictMode, without letting an old viewer terminate a new one.
let streamLifecycle: Promise<unknown> = Promise.resolve();
/** Decode the actual Worker stream. This component never loads a world or creates a 3D engine. */
export function AiWorldStream({ world, run, onBack, onChange, controls, headerActions, actionsHost }: {
world: AiWorld; run?: AiRun | null; onBack: () => void; onChange: (run: AiRun) => void; controls?: ReactNode;
headerActions?: ReactNode; actionsHost?: HTMLElement | null;
}) {
const frame = useRef<HTMLDivElement>(null);
const video = useRef<HTMLVideoElement>(null);
const id = useId().replaceAll(":", "");
const { expanded, toggle } = useSimulationFullscreen(frame);
const [state, setState] = useState<"connecting" | "live" | "failed" | "stalled">("connecting");
const [fps, setFps] = useState<number | null>(null);
const [revision, setRevision] = useState(0);
const [cameraError, setCameraError] = useState(false);
const endpoint = run?.worker?.stream;
const terminal = !run || ["completed", "stopped", "failed"].includes(run.state);
const connectable = !terminal && !!run?.telemetry?.stream_ready && !!endpoint;
const endpointKey = JSON.stringify(endpoint);
useEffect(() => {
if (!connectable || !endpoint || !video.current) return;
let disposed = false;
let stream: import("@nvidia/ov-web-rtc").AppStreamer | undefined;
let settle!: () => void;
const settled = new Promise<void>((resolve) => { settle = resolve; });
let callback = 0;
let lastFrame = performance.now();
let frames = 0;
let measuredAt = performance.now();
let connected = false;
setState("connecting"); setFps(null);
const element = video.current;
const received = () => {
if (disposed) return;
lastFrame = performance.now(); frames += 1; connected = true; setState("live");
callback = element.requestVideoFrameCallback(received);
};
callback = element.requestVideoFrameCallback(received);
const timer = setInterval(() => {
const now = performance.now();
setFps(frames * 1000 / (now - measuredAt));
frames = 0; measuredAt = now;
if (connected && now - lastFrame > 3000) setState("stalled");
}, 1000);
const setup = streamLifecycle.catch(() => undefined).then(async () => {
const sdk = await import("@nvidia/ov-web-rtc");
if (disposed) { settle(); return; }
stream = new sdk.AppStreamer();
await stream.connect({ streamSource: sdk.StreamType.DIRECT, logLevel: sdk.LogLevel.WARN,
streamConfig: { signalingServer: endpoint.server, signalingPort: endpoint.signaling_port,
signalingPath: "/", mediaServer: endpoint.server, mediaPort: endpoint.media_port,
width: endpoint.width, height: endpoint.height, fps: endpoint.fps,
videoElementId: `polygon-video-${id}`, audioElementId: `polygon-audio-${id}`,
autoLaunch: true, mic: false, enableAV1Support: false, fitStreamResolution: false,
maxReconnects: 1, reconnectDelay: 2000, connectivityTimeout: 5000,
onStart: (event) => {
if (event.status === sdk.EventStatus.SUCCESS || event.status === sdk.EventStatus.ERROR
|| event.status === sdk.EventStatus.CANCELED) settle();
if (!disposed && event.status === sdk.EventStatus.ERROR) setState("failed");
},
onStop: () => { settle(); if (!disposed) setState("stalled"); },
} });
if (stream.streamStatus !== sdk.StreamStatus.STARTING) settle();
}).catch(() => { settle(); if (!disposed) setState("failed"); });
streamLifecycle = setup;
return () => {
disposed = true; clearInterval(timer); element.cancelVideoFrameCallback(callback);
// connect() resolves with IN_PROGRESS; terminate() during STARTING does
// not dispose the SDK. Await its final callback before admitting a new owner.
streamLifecycle = setup.then(async () => {
await settled;
await stream?.terminate(false);
}).catch(() => undefined);
};
// A telemetry refresh must never reconnect or reload the stream.
}, [connectable, endpointKey, run?.run_id, id, revision]);
const mode = run?.camera ?? "follow";
const changeCamera = (camera: AiCamera) => {
if (!run) return;
setCameraError(false);
void setAiCamera(run, camera).then(onChange).catch(() => setCameraError(true));
};
return <div ref={frame} className="ai-polygon__stage">
{!expanded && actionsHost ? createPortal(headerActions, actionsHost) : null}
{expanded ? <div className="ai-polygon__head">
<div className="ai-polygon__title">
<IconButton label="Назад к локациям" onClick={onBack}><Icon name="chevron-left" /></IconButton>
<h2>{world.name}</h2>
</div>
<div className="ai-polygon__actions">{headerActions}</div>
</div> : null}
{controls ? <div className="ai-polygon__viewer-controls">{controls}</div> : null}
<div className="ai-polygon__viewer">
<div className="ai-polygon__viewer-toolbar">
<SegmentedControl<AiCamera> label="Вид симуляции" size="dense" value={mode} onChange={changeCamera}
items={[{ value: "follow", label: "За ровером", disabled: terminal },
{ value: "overview", label: "Обзор", disabled: terminal }, { value: "camera", label: "Камера AI", disabled: terminal }]} />
<div className="ai-polygon__actions">
{connectable ? <StatusBadge tone={state === "live" ? "success" : "warning"}>
{state === "live" ? `Видео · ${fps?.toFixed(0) ?? "—"} FPS` : "Ожидаем видео"}
</StatusBadge> : null}
<IconButton label={expanded ? "Свернуть симуляцию" : "Развернуть симуляцию"} onClick={toggle}>
<Icon name={expanded ? "minimize" : "expand"} />
</IconButton>
</div>
</div>
{cameraError ? <p role="alert">Не удалось переключить камеру.</p> : null}
<div className="ai-polygon__viewport">
<LoadingRegion loading={!terminal && (!connectable || state === "connecting")}
label={run?.state === "disconnected" ? "Восстанавливаем связь с Worker" : !connectable ? "Подготавливаем сцену на Worker" : "Подключаем видео"}>
<div className="ai-polygon__video" inert>
<video ref={video} id={`polygon-video-${id}`} autoPlay muted playsInline tabIndex={-1}
aria-label={`Видео симуляции ${world.name} с Worker`} />
<audio id={`polygon-audio-${id}`} muted />
</div>
</LoadingRegion>
{terminal ? <div className="ai-polygon__preview-error"><p>{run?.state === "failed" ? run.message ?? "Симуляция прервана." : "Симуляция завершена."}</p></div>
: connectable && (state === "failed" || state === "stalled") ? <div className="ai-polygon__preview-error" role="status">
<p>Видеопоток прерван. Состояние симуляции показано выше.</p>
<Button onClick={() => setRevision((value) => value + 1)}>Подключить видео</Button>
</div> : null}
</div>
</div>
</div>;
}
@@ -0,0 +1,26 @@
import { useEffect, useState, type RefObject } from "react";
export function useSimulationFullscreen(view: RefObject<HTMLDivElement | null>) {
const [expanded, setExpanded] = useState(false);
useEffect(() => {
const sync = () => setExpanded(!!view.current && document.fullscreenElement === view.current);
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape" || !view.current || document.fullscreenElement !== view.current) return;
event.preventDefault();
event.stopPropagation();
void document.exitFullscreen().catch(() => undefined);
};
document.addEventListener("fullscreenchange", sync);
document.addEventListener("keydown", escape, true);
return () => {
document.removeEventListener("fullscreenchange", sync);
document.removeEventListener("keydown", escape, true);
};
}, [view]);
const toggle = () => {
if (!view.current) return;
void (document.fullscreenElement === view.current
? document.exitFullscreen() : view.current?.requestFullscreen())?.catch(() => undefined);
};
return { expanded, toggle };
}
@@ -1,3 +1,4 @@
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
@@ -10,4 +11,4 @@ export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
// Node-executed camera controls use the existing sensor contribution contract;
// they do not register a desktop capture/AI runtime or a new product workspace.
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]);
export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi,vescSensorUi]);
@@ -43,7 +43,7 @@ const defaultQuickActions: Record<
missions: ["routes", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", "local-device"],
polygon: ["observatory", "local-device"],
};
export function defaultEnvironmentSettings(): EnvironmentSettings {
@@ -140,6 +140,10 @@ function decodeBackground(value: unknown, path: string): EnvironmentBackground {
};
}
function currentQuickAction(value: string | null): string | null {
return value === "lab-archive" ? "observatory" : value;
}
function decodePage(value: unknown, path: string): EnvironmentPage {
const record = requireRecord(value, path);
return {
@@ -147,16 +151,16 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
title: requireString(record.title, `${path}.title`)!,
description: requireString(record.description, `${path}.description`)!,
primaryWorkspaceId: requireString(
primaryWorkspaceId: currentQuickAction(requireString(
record.primary_workspace_id,
`${path}.primary_workspace_id`,
true,
),
secondaryWorkspaceId: requireString(
)),
secondaryWorkspaceId: currentQuickAction(requireString(
record.secondary_workspace_id,
`${path}.secondary_workspace_id`,
true,
),
)),
background: decodeBackground(record.background, `${path}.background`),
};
}
@@ -0,0 +1,13 @@
import {createBoardLayoutStore,type BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
import {fleetRequest} from './useFleet';
const layouts=new Map<string,BoardLayoutStore>();
export function boardLayout(vehicleID:string):BoardLayoutStore {
let layout=layouts.get(vehicleID);
if(!layout){
const path=`/${encodeURIComponent(vehicleID)}/board-layout`;
layout=createBoardLayoutStore({read:()=>fleetRequest(path),patch:(section,open)=>fleetRequest(path,'PATCH',{section,open})});
layouts.set(vehicleID,layout);
}
return layout;
}
@@ -0,0 +1,66 @@
import {keyDemand,keysFor,type Demand,type RoverMode} from './roverInput';
// The network heartbeat is not evidence that a physical key is still held.
// Allow the initial OS repeat delay, then require continuing keyboard evidence.
export const firstKeyLeaseMs=1000,repeatKeyLeaseMs=300;
export function bindRoverHoldInput(scope:HTMLElement,mode:RoverMode,callbacks:{
held:(keys:Set<string>)=>void;demand:(value:Demand)=>void;pause:()=>void;stop:()=>void;
},now:()=>number=()=>performance.now()){
const doc=scope.ownerDocument,win=doc.defaultView!;
const keyboard=new Set<string>(),pointers=new Map<number,string>();
let deadline=0,closed=false,suspended=false;
const clear=()=>{keyboard.clear();pointers.clear();deadline=0;callbacks.held(new Set());};
const pause=()=>{if(closed||suspended)return;suspended=true;clear();callbacks.pause();};
const stop=()=>{if(closed)return;closed=true;clear();callbacks.stop();};
const focused=()=>!doc.hidden&&doc.hasFocus()&&scope.contains(doc.activeElement);
const valid=()=>{
if(closed)return false;
if(!focused()||(keyboard.size>0&&now()>=deadline)){pause();return false;}
return true;
};
const publish=()=>{const held=new Set([...keyboard,...pointers.values()]);callbacks.held(held);callbacks.demand(keyDemand(mode,held));};
const down=(e:KeyboardEvent)=>{
if(!e.isTrusted||closed)return;
if(e.code==='Space'||e.code==='Escape'){e.preventDefault();stop();return;}
if(e.altKey||e.ctrlKey||e.metaKey){pause();return;}
if(!keysFor(mode).includes(e.code)||!valid())return;
if((e.target as HTMLElement)?.closest('input,textarea,select,[role=dialog],[role=listbox]')){pause();return;}
// A repeat delivered after focus returns cannot become a fresh press.
if(e.repeat&&!keyboard.has(e.code))return;
e.preventDefault();suspended=false;keyboard.add(e.code);
deadline=now()+(e.repeat?repeatKeyLeaseMs:firstKeyLeaseMs);publish();
};
const up=(e:KeyboardEvent)=>{
if(!e.isTrusted||closed||!keyboard.delete(e.code))return;
e.preventDefault();if(!keyboard.size)deadline=0;publish();
};
const blur=(e:Event)=>{if(e.target===win)pause();};
const focusOut=(e:FocusEvent)=>{if(scope.contains(e.target as Node)&&!scope.contains(e.relatedTarget as Node|null))pause();};
const focusIn=()=>{if(!focused())pause();};
const hidden=()=>{if(doc.hidden)pause();};
const outside=(e:Event)=>{if(!scope.contains(e.target as Node))pause();};
const pointerUp=(e:PointerEvent)=>{if(!closed&&pointers.delete(e.pointerId))publish();};
const pointerCancel=(e:PointerEvent)=>{if(pointers.has(e.pointerId))pause();};
win.addEventListener('keydown',down,true);win.addEventListener('keyup',up,true);
win.addEventListener('blur',blur,true);win.addEventListener('pagehide',stop);
doc.addEventListener('focusout',focusOut,true);doc.addEventListener('focusin',focusIn,true);
doc.addEventListener('visibilitychange',hidden);doc.addEventListener('pointerdown',outside,true);
doc.addEventListener('pointerup',pointerUp,true);doc.addEventListener('pointercancel',pointerCancel,true);
doc.addEventListener('contextmenu',pause,true);
const timer=win.setInterval(valid,50);
return {
valid,
pointerDown(id:number,code:string){if(valid()){suspended=false;pointers.set(id,code);publish();}},
pointerUp(id:number){if(!closed&&pointers.delete(id))publish();},
pointerCancel(id:number){if(pointers.has(id))pause();},
dispose(){
closed=true;clear();callbacks.demand({left:0,right:0});win.clearInterval(timer);
win.removeEventListener('keydown',down,true);win.removeEventListener('keyup',up,true);
win.removeEventListener('blur',blur,true);win.removeEventListener('pagehide',stop);
doc.removeEventListener('focusout',focusOut,true);doc.removeEventListener('focusin',focusIn,true);
doc.removeEventListener('visibilitychange',hidden);doc.removeEventListener('pointerdown',outside,true);
doc.removeEventListener('pointerup',pointerUp,true);doc.removeEventListener('pointercancel',pointerCancel,true);
doc.removeEventListener('contextmenu',pause,true);
},
};
}
@@ -0,0 +1,20 @@
export type RoverMode='arcade'|'tank';
export type Demand={left:number;right:number};
export const keysFor=(mode:RoverMode)=>mode==='arcade'?['KeyW','KeyA','KeyS','KeyD']:['KeyQ','KeyE','KeyA','KeyD'];
export function keyDemand(mode:RoverMode,held:ReadonlySet<string>):Demand {
const axis=(positive:string,negative:string)=>Number(held.has(positive))-Number(held.has(negative));
if(mode==='tank')return {left:axis('KeyQ','KeyA'),right:axis('KeyE','KeyD')};
const forward=axis('KeyW','KeyS'),turn=axis('KeyD','KeyA');
const scale=Math.max(1,Math.abs(forward)+Math.abs(turn));
return {left:(forward+turn)/scale,right:(forward-turn)/scale};
}
export const defaultRoverSettings={version:1,mode:'arcade' as RoverMode,currentA:30,maxErpm:2000,model:''};
export function roverSettings(value:unknown):typeof defaultRoverSettings {
const v=value as Partial<typeof defaultRoverSettings>|null;
return {...defaultRoverSettings,...(v?.version===1?{
mode:v.mode==='tank'?'tank':'arcade',
currentA:typeof v.currentA==='number'&&Number.isFinite(v.currentA)?Math.max(.5,Math.min(30,v.currentA)):30,
maxErpm:typeof v.maxErpm==='number'&&Number.isFinite(v.maxErpm)?Math.max(300,Math.min(3000,v.maxErpm)):2000,
model:v.model==='dcd006-v020'?v.model:'',
}:{} )};
}
@@ -9,6 +9,7 @@ export function createFleetSensorTransport(vehicleID:string):SensorTransport {
return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'};
};
return {
configurationArchive:{list:(device,before)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations/${encodeURIComponent(version)}`)},
enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)},
inventory:async()=>inventory(await fleetRequest()),
subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();},
@@ -0,0 +1,79 @@
import {useCallback,useEffect,useRef,useState} from 'react';
import type {Demand} from './roverInput';
export interface RoverReading {
id:string;uuid:string;slot:string|null;label:string;age_ms:number;
values:{motor_current_a:number;input_current_a:number;input_voltage_v:number;erpm:number;duty:number;mos_temperature_c:number;fault_code:number};
}
export interface RoverState {
fresh:boolean;controlling:boolean;
snapshot:{supported?:boolean;instance?:string;state?:string;session_id?:string|null;message?:string|null;devices?:RoverReading[];
profile?:{layout:null|'1x1'|'2x2';bindings:Record<string,{device_id:string;uuid:string}>}};
}
const empty:RoverState={fresh:false,controlling:false,snapshot:{}};
async function request<T>(url:string,body?:unknown,keepalive=false):Promise<T>{
const response=await fetch(url,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:undefined,
body:body?JSON.stringify(body):undefined,cache:'no-store',signal:AbortSignal.timeout(body?350:1000),keepalive});
const result=await response.json();
if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Канал управления недоступен.');
return result;
}
export function useRoverControl(vehicleID:string,visible:boolean){
const url=`/api/v1/fleet/${encodeURIComponent(vehicleID)}/rover`;
const [state,setState]=useState<RoverState>(empty),[armed,setArmed]=useState(false),[pending,setPending]=useState(false),[error,setError]=useState<string|null>(null);
const [connecting,setConnecting]=useState(true),armPending=useRef(false);
const session=useRef<string|null>(null),seq=useRef(0),demand=useRef<Demand>({left:0,right:0}),sending=useRef(false),generation=useRef(0);
const inputGuard=useRef<(()=>boolean)|null>(null);
const setInputGuard=useCallback((guard:(()=>boolean)|null)=>{inputGuard.current=guard;},[]);
const stop=useCallback(()=>{
generation.current++;armPending.current=false;const id=session.current;session.current=null;demand.current={left:0,right:0};setArmed(false);setPending(false);
if(id)void request(url+'/command',{session_id:id,sequence:++seq.current,left:0,right:0,stop:true},true).catch(()=>{});
},[url]);
const send=useCallback(async()=>{
const id=session.current;if(!id)return;
// Check on every heartbeat, even when a previous request is still pending.
const inputValid=(demand.current.left===0&&demand.current.right===0)||inputGuard.current?.();
if(document.hidden||!document.hasFocus()||!inputValid)demand.current={left:0,right:0};
if(sending.current)return;
sending.current=true;
try{await request(url+'/command',{session_id:id,sequence:++seq.current,...demand.current,stop:false});}
catch(e){if(session.current===id){stop();setError(e instanceof Error?e.message:'Команда не подтверждена.');}}
finally{sending.current=false;}
},[url,stop]);
const setDemand=useCallback((next:Demand)=>{demand.current=next;void send();},[send]);
const pauseInput=useCallback(()=>{demand.current={left:0,right:0};void send();},[send]);
const arm=useCallback(async(currentA:number,maxErpm:number)=>{
if(session.current||armPending.current)return false;const g=++generation.current;armPending.current=true;setError(null);setPending(true);
try{
const result=await request<{session_id:string}>(url+'/arm',{standstill_confirmed:true,current_a:currentA,max_erpm:maxErpm});
if(g!==generation.current){void request(url+'/command',{session_id:result.session_id,sequence:1,left:0,right:0,stop:true},true).catch(()=>{});return false;}
// Retire every read started before this acknowledgement, including reads
// begun while POST /arm was pending. They describe the previous authority.
generation.current++;armPending.current=false;setPending(false);
seq.current=0;demand.current={left:0,right:0};session.current=result.session_id;setArmed(true);void send();
return true;
}catch(e){if(g===generation.current)setError(e instanceof Error?e.message:'Не удалось включить управление.');return false;}
finally{if(g===generation.current){armPending.current=false;setPending(false);}}
},[url,send]);
useEffect(()=>{
if(!visible){stop();setState(empty);setConnecting(false);return;}
let alive=true,busy=false,lastFault='';const started=performance.now();setConnecting(true);
const poll=async()=>{
if(busy)return;busy=true;const before=generation.current;
try{const value=await request<RoverState>(url);if(alive&&before===generation.current){setState(value);
setConnecting(!value.fresh&&performance.now()-started<2000);
if(session.current&&(!value.fresh||!value.controlling)){stop();}
const fault=value.snapshot.state==='fault'?value.snapshot.message??'':'';
if(fault&&lastFault!==fault){lastFault=fault;setError(fault);}
}}catch{if(alive&&before===generation.current){setState(empty);setConnecting(false);if(session.current)stop();}}finally{busy=false;}
};
void poll();const timer=setInterval(()=>void poll(),200),heartbeat=setInterval(()=>void send(),100);
const blur=()=>pauseInput(),hidden=()=>{if(document.hidden)pauseInput();};
window.addEventListener('blur',blur);window.addEventListener('pagehide',stop);document.addEventListener('visibilitychange',hidden);
return()=>{alive=false;clearInterval(timer);clearInterval(heartbeat);window.removeEventListener('blur',blur);window.removeEventListener('pagehide',stop);document.removeEventListener('visibilitychange',hidden);stop();};
},[url,visible,stop,send,pauseInput]);
const ready=armed&&state.fresh&&state.snapshot.session_id===session.current&&['ready','driving'].includes(state.snapshot.state??'');
const canArm=state.fresh&&state.snapshot.supported===true&&!state.controlling&&!['preparing','ready','driving','stopping'].includes(state.snapshot.state??'');
return {state,armed,pending,error,connecting,canArm,arm,stop,pauseInput,setDemand,setInputGuard,ready,clearError:()=>setError(null)};
}
export type RoverController=ReturnType<typeof useRoverControl>;
@@ -18,14 +18,13 @@ export interface PlanningRunSummary {id:string;name:string;state:string;query_se
const endpoint = plannerBase+'/live-tests';
const terminal = new Set(['completed','cancelled','error','interrupted']);
const Context = createContext<{
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<void>; selected: boolean; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>; resume:()=>void;
test: PlanningLiveTest|null; history:PlanningRunSummary[]; select:(id:string)=>Promise<boolean>; busy:boolean; error:string|null;
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
}|null>(null);
/** Server owns the run; navigation and reload retain its frozen reference. */
/** Server owns the run and its frozen reference, never the workspace launch profile. */
export function PlanningTestProvider({children}:{children:ReactNode}) {
const [test,setTest]=useState<PlanningLiveTest|null>(null);
const [dismissed,setDismissed]=useState<string|null>(null);
const [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
const [pollError,setPollError]=useState<string|null>(null);
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
@@ -33,8 +32,8 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
useEffect(()=>{let disposed=false;void plannerRequest<{items:PlanningRunSummary[]}>(endpoint).then(r=>{if(!disposed)setHistory(r.items);}).catch(()=>{});return()=>{disposed=true;};},[test?.id,test?.state]);
const select=useCallback(async(id:string)=>{
generation.current+=1;setBusy(true);setError(null);
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));setDismissed(null);}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');}
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+id+'/select',{method:'POST'}));return true;}
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
finally{generation.current+=1;setBusy(false);}
},[]);
useEffect(()=>{
@@ -48,7 +47,7 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
},[]);
const begin=useCallback(async(draft:Draft)=>{
generation.current+=1;setBusy(true);setError(null);
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);setDismissed(null);sessionStorage.removeItem('planning-dismissed');return next;}
try {const next=await plannerRequest<PlanningLiveTest>(endpoint,{method:'POST',body:JSON.stringify({draft_id:draft.id,revision:draft.revision})});setTest(next);return next;}
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
finally{generation.current+=1;setBusy(false);}
},[]);
@@ -64,8 +63,12 @@ export function PlanningTestProvider({children}:{children:ReactNode}) {
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
finally{generation.current+=1;setBusy(false);}
},[test]);
const resume=useCallback(()=>{setDismissed(null);sessionStorage.removeItem('planning-dismissed');},[]);
return <Context.Provider value={{test,history,select,selected:!!test&&dismissed!==test.id,busy,error:error??pollError,begin,finish,retryInitialization,resume}}>{children}</Context.Provider>;
return <Context.Provider value={{test,history,select,busy,error:error??pollError,begin,finish,retryInitialization}}>{children}</Context.Provider>;
}
export function usePlanningTest(){const value=useContext(Context);if(!value)throw new Error('PlanningTestProvider missing');return value;}
export function planningTestTerminal(test:PlanningLiveTest){return terminal.has(test.state);}
/** A prepared consumer owns the next capture until explicitly finished or used. */
export function planningAwaitsCapture(test:PlanningLiveTest|null){
return !!test&&!planningTestTerminal(test)&&!test.query_session_id;
}
@@ -1,8 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { canSelectSession, endAtDistance, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
import { useCallback, useEffect, useMemo, useState } from "react";
import { canSelectSession, plannerBase, plannerRequest, selectedPoses, validatePlanningSource, type Direction, type Draft, type PlanningSource, type RouteCheck, type SessionOption } from "./planner";
export function useMissionPlanner() {
const initializeRoute = useRef(true);
const [sessions, setSessions] = useState<SessionOption[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [drafts, setDrafts] = useState<Draft[]>([]);
@@ -10,8 +9,6 @@ export function useMissionPlanner() {
const [name, setName] = useState("");
const [sessionId, setSessionId] = useState("");
const [source, setSource] = useState<PlanningSource | null>(null);
const [start, setStart] = useState(0);
const [end, setEnd] = useState(1);
const [direction, setDirection] = useState<Direction>("forward");
const [error, setError] = useState<string | null>(null);
const [sourceError, setSourceError] = useState<string | null>(null);
@@ -19,12 +16,13 @@ export function useMissionPlanner() {
const [catalogBusy, setCatalogBusy] = useState(true);
const [loadVersion, setLoadVersion] = useState(0);
const [sourceVersion, setSourceVersion] = useState(0);
const [pinnedGeneration, setPinnedGeneration] = useState<string | null>(null);
const [check, setCheck] = useState<RouteCheck | null>(null);
useEffect(() => {
const abort = new AbortController(); setCatalogBusy(true); setError(null);
void Promise.all([
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>("/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1", { signal: abort.signal }),
plannerRequest<{ items: Draft[] }>(`${plannerBase}/drafts`, { signal: abort.signal }),
]).then(([catalog, archive]) => { if (!abort.signal.aborted) { setSessions(catalog.items); setCursor(catalog.next_cursor); setDrafts(archive.items); } })
.catch(reason => { if (!abort.signal.aborted) setError(reason.message); })
@@ -34,26 +32,29 @@ export function useMissionPlanner() {
useEffect(() => {
const abort = new AbortController(); setSource(null); setSourceError(null);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) { const next = validatePlanningSource(data, sessionId); setSource(next); if (initializeRoute.current) { setStart(0); setEnd(endAtDistance(next, 0, 30)); initializeRoute.current = false; } } })
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}${pinnedGeneration ? `?generation=${encodeURIComponent(pinnedGeneration)}` : ""}`, { signal: abort.signal })
.then(data => { if (!abort.signal.aborted) setSource(validatePlanningSource(data, sessionId)); })
.catch(reason => { if (!abort.signal.aborted) setSourceError(reason.message); });
return () => abort.abort();
}, [sessionId, sourceVersion]);
}, [sessionId, sourceVersion, pinnedGeneration]);
const currentSource = source?.session_id === sessionId ? source : null;
// New executions always own the complete reference. No independent interval
// state can survive a source change or collapse on same-source reselection.
const start = 0, end = currentSource ? currentSource.poses.length - 1 : 0;
const sourceChanged = !!(saved && currentSource && saved.zone.session_id === sessionId && saved.zone.generation !== currentSource.generation);
const poses = useMemo(() => selectedPoses(currentSource, start, end, direction), [currentSource, start, end, direction]);
const dirty = !saved || name.trim() !== saved.name || sessionId !== saved.zone.session_id || sourceChanged
|| start !== saved.route.start_index || end !== saved.route.end_index || direction !== saved.route.direction;
const ready = !!currentSource && !sourceChanged && poses.length > 1 && !!name.trim();
const chooseSource = (id: string) => { initializeRoute.current = true; setSessionId(id); setStart(0); setEnd(1); setCheck(null); };
const chooseSource = (id: string) => { setPinnedGeneration(null); setSessionId(id); setSourceVersion(n => n + 1); setCheck(null); };
const newDraft = () => { setSaved(null); setName(""); chooseSource(""); setDirection("forward"); setError(null); };
const openDraft = async (id: string) => {
setBusy(true); setError(null);
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts/${id}`);
initializeRoute.current = false; setSaved(next); setName(next.name); setSessionId(next.zone.session_id);
setStart(next.route.start_index); setEnd(next.route.end_index); setDirection(next.route.direction); setCheck(null);
setSaved(next); setName(next.name); setSessionId(next.zone.session_id); setPinnedGeneration(next.zone.generation);
setDirection(next.route.direction); setCheck(null);
setSourceVersion(n => n + 1);
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
};
@@ -63,7 +64,7 @@ export function useMissionPlanner() {
try {
const next = await plannerRequest<Draft>(`${plannerBase}/drafts`, { method: "POST", body: JSON.stringify({
id: saved?.id ?? null, revision: saved?.revision ?? 0, name: name.trim(), session_id: sessionId,
generation: currentSource.generation, start_index: start, end_index: end, direction,
generation: currentSource.generation, whole_recording: true, direction,
}) });
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
return next;
@@ -79,11 +80,11 @@ export function useMissionPlanner() {
if (!cursor) return;
setCatalogBusy(true);
try {
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
const page = await plannerRequest<{ items: SessionOption[]; next_cursor: string | null }>(`/api/v1/observation-sessions?scope=standalone&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items => [...items, ...page.items.filter(item => !items.some(old => old.id === item.id))]); setCursor(page.next_cursor);
} catch (reason) { setError((reason as Error).message); } finally { setCatalogBusy(false); }
};
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource, start, setStart, end, setEnd,
return { sessions, drafts, saved, name, setName, sessionId, chooseSource, source: currentSource,
direction, setDirection, error, sourceError, sourceChanged, busy, catalogBusy, cursor, loadMore, dirty, ready, poses, check,
save, openDraft, newDraft, runCheck, retrySource: () => setSourceVersion(n => n + 1), refresh: useCallback(() => setLoadVersion(n => n + 1), []),
options: [{ value: "", label: "Выберите сохранённую запись" }, ...sessions.map(item => ({ value: item.id, label: item.label,
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource } from "./planner";
import { endAtDistance, plannerBase, plannerRequest, validatePlanningSource, type Draft, type PlanningSource, type SessionOption } from "./planner";
export interface RegistrationReport {
id: string; state: "queued" | "running" | "ready" | "error"; message?: string; progress_label?: string;
@@ -15,6 +15,25 @@ export function useRegistrationTest(draft: Draft | null) {
const [start, setStart] = useState(0), [end, setEnd] = useState(1);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false), [submitting, setSubmitting] = useState(false);
const [sessions,setSessions]=useState<SessionOption[]>([]), [cursor,setCursor]=useState<string|null>(null);
const [catalogBusy,setCatalogBusy]=useState(false);
// Repeat recordings belong to the planner, including its own captured passes;
// they are not restricted to the independent-reference catalog.
useEffect(()=>{
const abort=new AbortController();setCatalogBusy(true);
void plannerRequest<{items:SessionOption[];next_cursor:string|null}>("/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1",{signal:abort.signal})
.then(page=>{if(!abort.signal.aborted){setSessions(page.items);setCursor(page.next_cursor);}})
.catch(e=>{if(!abort.signal.aborted)setError(e.message);})
.finally(()=>{if(!abort.signal.aborted)setCatalogBusy(false);});
return()=>abort.abort();
},[]);
const loadMore=async()=>{
if(!cursor||catalogBusy)return;setCatalogBusy(true);
try{
const page=await plannerRequest<{items:SessionOption[];next_cursor:string|null}>(`/api/v1/observation-sessions?scope=source&limit=100&pagination=cursor-v1&cursor=${encodeURIComponent(cursor)}`);
setSessions(items=>[...items,...page.items.filter(item=>!items.some(old=>old.id===item.id))]);setCursor(page.next_cursor);
}catch(e){setError((e as Error).message);}finally{setCatalogBusy(false);}
};
useEffect(() => {
const abort = new AbortController(); setSource(null); setError(null); setLoading(!!sessionId);
if (sessionId) void plannerRequest<PlanningSource>(`${plannerBase}/sources/${encodeURIComponent(sessionId)}`, { signal: abort.signal })
@@ -35,5 +54,5 @@ export function useRegistrationTest(draft: Draft | null) {
return data;
} catch (e) { setError((e as Error).message); } finally { setSubmitting(false); }
};
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run };
return { sessionId, setSessionId, source, start, setStart, end, setEnd, error, busy, loading, length, run, sessions, cursor, catalogBusy, loadMore };
}
@@ -0,0 +1,68 @@
import type {SceneSettings} from '../../sceneSettings';
export type PointDisplaySettings = Pick<SceneSettings, 'pointDecimationPercent' | 'colorMode' | 'palette' | 'customColor'>;
export function pointDisplayKey(settings: PointDisplaySettings): string {
return JSON.stringify([settings.pointDecimationPercent ?? 0, settings.colorMode, settings.palette,
settings.palette === 'custom' || settings.colorMode === 'class' ? settings.customColor.toLowerCase() : '']);
}
/** Feed bounded HTTP chunks to the existing native receiver; no whole-cloud JS Blob. */
export async function streamRecordedPointDisplay(
blueprintUrl: string, settings: PointDisplaySettings,
identity: {applicationId: string; recordingId: string}, displayBank: string,
signal: AbortSignal, send: (chunk: Uint8Array) => void,
fetcher: typeof fetch = fetch,
sourceGeneration?: string,
): Promise<number> {
const endpoint = new URL(blueprintUrl, window.location.origin);
const percent = settings.pointDecimationPercent ?? 0;
if (endpoint.origin !== window.location.origin || endpoint.search || endpoint.hash
|| !/^\/api\/v1\/observation-sessions\/[A-Za-z0-9][A-Za-z0-9._:-]{0,127}\/blueprint\.rrd$/.test(endpoint.pathname)
|| !Number.isFinite(percent) || percent <= 0 || percent >= 100
|| !/^[a-f0-9]{32}$/.test(displayBank)) throw new Error('Некорректный запрос прореживания');
endpoint.pathname = endpoint.pathname.replace('/blueprint.rrd', '/point-display.rrd');
const response = await fetcher(endpoint.href, {method: 'POST', signal, credentials: 'same-origin',
headers: {'Content-Type': 'application/json', Accept: 'application/vnd.nodedc.point-display-stream'},
body: JSON.stringify({application_id: identity.applicationId, recording_id: identity.recordingId,
color_mode: settings.colorMode, palette: settings.palette, custom_color: settings.customColor,
point_decimation_percent: percent, display_bank: displayBank,
...(sourceGeneration ? {source_generation: sourceGeneration} : {})})});
if (!response.ok || !response.headers.get('Content-Type')?.startsWith('application/vnd.nodedc.point-display-stream') || !response.body) {
throw new Error('Не удалось подготовить прореженное облако');
}
const reader = response.body.getReader();
let bytes = 0;
let pending = new Uint8Array(0);
let admitted = false;
let complete = false;
try {
while (true) {
const {done, value} = await reader.read();
if (signal.aborted) throw new DOMException('Aborted', 'AbortError');
if (done) break;
bytes += value.byteLength;
const combined = new Uint8Array(pending.length + value.length);
combined.set(pending); combined.set(value, pending.length); pending = combined;
if (!admitted) {
if (pending.length < 4) continue;
if (String.fromCharCode(...pending.subarray(0, 4)) !== 'NPD1') throw new Error('Некорректный поток облака');
pending = pending.slice(4); admitted = true;
}
while (pending.length >= 4 && !complete) {
const length = new DataView(pending.buffer, pending.byteOffset, 4).getUint32(0, true);
if (length === 0) { pending = pending.slice(4); complete = true; break; }
if (length < 4 || length > 64 * 1024 * 1024) throw new Error('Некорректный размер пакета облака');
if (pending.length < length + 4) break;
const payload = pending.slice(4, length + 4);
if (String.fromCharCode(...payload.subarray(0, 4)) !== 'RRF2') throw new Error('Некорректный пакет облака');
send(payload); pending = pending.slice(length + 4);
}
if (complete && pending.length) throw new Error('Лишние данные после завершения облака');
}
if (!complete || pending.length) throw new Error('Поток облака не завершён');
return bytes;
} finally {
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
@@ -23,7 +23,7 @@ export type ObservationSessionStatus =
| "interrupted"
| "failed";
export type ObservationSessionScope = "all" | "source" | "laboratory";
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
export interface ObservationLabInstance {
labId: string;
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
seekable: true;
byteLength: number;
sha256: string;
mapGeneration?: string;
playback: {
speed: number;
loop: boolean;
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
"seekable",
"byte_length",
"sha256",
"map_generation",
"playback",
"media_sources",
]);
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
}
if (launch.map_generation !== undefined && (typeof launch.map_generation !== "string" || !SHA256.test(launch.map_generation))) {
throw new ObservationSessionContractError("Descriptor содержит некорректную версию исправления.");
}
const viewerSourceUrl = requireString(
launch.viewer_source_url,
"launch.viewer_source_url",
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
integer: true,
}),
sha256: launch.sha256,
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
playback: { speed, loop: launch.playback.loop },
mediaSources,
};
@@ -0,0 +1,54 @@
import {defaultSceneSettings, MAX_ACCUMULATION_SECONDS, type SceneSettings} from '../../sceneSettings';
const schema = 'missioncore.session-display-profile/v1';
const fields = {
projection: 'projection', pointSize: 'point_size', pointDecimationPercent: 'point_decimation_percent',
accumulationMaxSeconds: 'accumulation_max_seconds', accumulationSeconds: 'accumulation_seconds',
colorMode: 'color_mode', palette: 'palette', customColor: 'custom_color',
showPoints: 'show_points', showTrajectory: 'show_trajectory', showGrid: 'show_grid',
showLabels: 'show_labels', showCameraFrustums: 'show_camera_frustums',
} as const;
function endpoint(sessionId: string) {
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(sessionId)) throw new Error('Некорректная запись');
return `/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/display-profile`;
}
export function encodeSessionDisplay(settings: SceneSettings) {
return Object.fromEntries(Object.entries(fields).map(([key, wire]) =>
[wire, settings[key as keyof SceneSettings] ?? defaultSceneSettings[key as keyof SceneSettings]]));
}
export function decodeSessionDisplay(value: unknown, sessionId: string): SceneSettings {
const document = value as {schema_version?: string; session_id?: string; scene_settings?: Record<string, unknown>};
if (!document || document.schema_version !== schema || document.session_id !== sessionId || !document.scene_settings) {
throw new Error('Настройки принадлежат другой записи или имеют неизвестный формат');
}
const s = Object.fromEntries(Object.entries(fields).map(([key, wire]) => [key, document.scene_settings![wire]])) as unknown as SceneSettings;
for (const key of ['pointSize', 'accumulationSeconds', 'accumulationMaxSeconds', 'pointDecimationPercent'] as const) {
if (typeof s[key] !== 'number' || !Number.isFinite(s[key])) throw new Error('Некорректное значение настройки');
}
if (s.pointSize < 0.1 || s.pointSize > 32 || s.accumulationSeconds < 0
|| s.accumulationMaxSeconds! < 1 || s.pointDecimationPercent! < 0 || s.pointDecimationPercent! > 100
|| !['3d','2d','map'].includes(s.projection) || !['intensity','height','distance','rgb','class'].includes(s.colorMode)
|| !['turbo','viridis','plasma','grayscale','custom'].includes(s.palette) || !/^#[a-f0-9]{6}$/i.test(s.customColor)
|| ['showPoints','showTrajectory','showGrid','showLabels','showCameraFrustums'].some(key => typeof s[key as keyof SceneSettings] !== 'boolean')) {
throw new Error('Некорректные настройки отображения');
}
return {...s, accumulationMaxSeconds: Math.max(s.accumulationSeconds, s.accumulationMaxSeconds!)};
}
export async function loadSessionDisplay(sessionId: string, signal?: AbortSignal): Promise<SceneSettings> {
const response = await fetch(endpoint(sessionId), {signal, cache: 'no-store'});
if (!response.ok) throw new Error('Не удалось прочитать настройки записи');
const document = await response.json();
return document === null ? {...defaultSceneSettings, accumulationMaxSeconds: MAX_ACCUMULATION_SECONDS}
: decodeSessionDisplay(document, sessionId);
}
export async function saveSessionDisplay(sessionId: string, settings: SceneSettings): Promise<void> {
const response = await fetch(endpoint(sessionId), {method: 'PUT', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({scene_settings: encodeSessionDisplay(settings)})});
if (!response.ok) throw new Error('Настройки записи не сохранены. Текущий вид оставлен без изменений.');
decodeSessionDisplay(await response.json(), sessionId);
}
@@ -1,5 +1,14 @@
export type OverviewViewMode = "3d" | "top";
export interface OverviewSpatialMetadata { height_min_m: number | null; height_max_m: number | null; sample_points: number; }
export type OverviewRepresentation = "original" | "corrected";
export interface OverviewComparison {
generation: string; map_generation: string; sample_points: number; source_points: number;
original_path_m: number; corrected_path_m: number; height_min_m: number; height_max_m: number;
}
export interface OverviewSpatialMetadata {
default_representation?: OverviewRepresentation;
height_min_m: number | null; height_max_m: number | null; sample_points: number;
comparison?: OverviewComparison;
}
function endpoint(source: string) {
const url = new URL(source, window.location.origin);
@@ -12,12 +21,15 @@ export async function fetchOverviewSpatial(source: string, signal: AbortSignal):
if (!response.ok) throw new Error("Параметры среза недоступны.");
return response.json();
}
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal) {
export async function updateOverviewSpatial(source: string, ceiling: number | null, mode: OverviewViewMode | null, aspect: number, signal: AbortSignal,
comparisonGeneration: string | null = null, representation: OverviewRepresentation = "original") {
if (representation === "corrected" && !comparisonGeneration) throw new Error("Исправленная версия недоступна.");
const url = endpoint(source);
const generation = url.searchParams.get("generation");
url.search = "";
const response = await fetch(url, { method: "POST", signal, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect }) });
body: JSON.stringify({ generation, ceiling_m: ceiling, mode, aspect,
comparison_generation: comparisonGeneration, representation }) });
if (!response.ok) throw new Error("Не удалось обновить вид облака.");
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > 32 * 1024 * 1024) throw new Error("Обзор превышает допустимый размер.");
@@ -0,0 +1,38 @@
import {useCallback, useEffect, useRef, useState} from 'react';
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
const [error, setError] = useState<string | null>(null);
const applyRef = useRef(apply); applyRef.current = apply;
const current = useRef(sessionId); current.current = sessionId;
const writeTail = useRef(Promise.resolve());
const revision = useRef(0);
useEffect(() => {
setError(null);
if (!sessionId) return;
const abort = new AbortController();
const readRevision = revision.current;
applyRef.current({...defaultSceneSettings});
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
if (abort.signal.aborted || current.current !== sessionId) return;
if (revision.current === readRevision) applyRef.current(settings);
}).catch(reason => {
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
});
return () => abort.abort();
}, [sessionId]);
const edited = useCallback(() => { revision.current += 1; }, []);
const save = useCallback((settings: SceneSettings) => {
const id = current.current;
if (!id) return;
const snapshot = {...settings};
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
if (current.current === id) setError(null);
}).catch(reason => {
if (current.current === id) setError(String(reason.message ?? reason));
});
}, []);
return {save, edited, error, dismissError: () => setError(null)};
}
@@ -0,0 +1,10 @@
/** Entry intent belongs to application navigation, not a retained server job. */
export type WorkspaceLaunchProfile = 'direct' | 'planning';
export function workspaceLaunchProfile(
current: WorkspaceLaunchProfile,
kind: string,
requested: WorkspaceLaunchProfile = 'direct',
): WorkspaceLaunchProfile {
return kind === 'device' || kind === 'spatial' ? requested : current;
}
@@ -87,6 +87,10 @@ export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModu
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
return decodeAIModuleCatalog(body);
}
export function decodeAIModuleCatalog(body: unknown): AIModuleCatalog {
const root = record(body, "каталог AI-модулей");
exactKeys(root, ["authority", "groups", "schema_version"]);
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
@@ -0,0 +1,141 @@
import { decodeAIModuleCatalog, type AIModuleCatalog, type AIGroupId } from "../observatory/aiComposition";
const API = "/api/v1/ai-polygon";
export interface AiSelection {
schema_version: "missioncore.observatory-ai-composition/v1";
selections: { group: AIGroupId; module_id: string; module_sha256: string; parameters: Readonly<Record<string, unknown>> }[];
}
export interface AiModules { catalog: AIModuleCatalog; selection: AiSelection }
export async function fetchAiModules(): Promise<AiModules> {
const value = await polygonRequest<{ catalog: unknown; selection: AiSelection }>("/ai-modules");
return { catalog: decodeAIModuleCatalog(value.catalog), selection: value.selection };
}
export interface AiWorldSettings {
meters_per_unit: number;
rotation_degrees: [number, number, number];
ground_z: number;
spawn_xy: [number, number];
heading_degrees: number;
camera_height_m: number;
max_speed_mps: number;
prepared: boolean;
route_xy?: [number, number][];
}
export interface AiSource {
name: string; author: string; license: string; source_url: string | null;
description?: string;
}
export interface AiWorld extends AiSource {
world_id: string; filename: string; byte_length: number; uploaded_bytes: number;
status: "uploading" | "available"; sha256: string | null; splat_count: number | null;
settings: AiWorldSettings;
}
export interface AiRun {
run_id: string;
world: AiWorld;
request?: { composition?: AiSelection };
state: "starting" | "ready" | "running" | "paused" | "stopping" | "completed" | "stopped" | "failed" | "disconnected";
clock?: "lockstep" | "realtime";
control?: "play" | "pause" | "stop";
control_sequence?: number;
camera?: AiCamera;
worker?: { stream?: AiStreamEndpoint | null };
telemetry?: AiTelemetry | null;
samples: number;
phase?: string;
applied_steps?: number;
last_applied?: null | {
sequence: number; simulation_time_ns: number; pose_xy: [number, number];
pose_yaw?: number | null; cycle_ms?: number | null;
};
message: string | null;
last_sample: null | {
sequence: number; simulation_time_ns: number; inference_ms: number; pose_xy: [number, number];
decision: { speed_mps: number; yaw_rate_rps: number; reason: string;
road_fraction: number; obstacle_count: number };
};
}
export type AiCamera = "follow" | "overview" | "camera";
export interface AiStreamEndpoint {
server: string; signaling_port: number; media_port: number; width: number; height: number; fps: number;
}
export interface AiTelemetry {
sequence: number; control_sequence: number; simulation_time_ns: number;
rtf: number; render_fps: number; sensor_fps: number; ai_hz: number;
inference_ms: number | null; frame_age_ms: number | null; command_age_ms: number | null;
speed_mps: number; applied_speed_mps: number; stop_reason: string;
ai_ready: boolean; stream_ready: boolean; camera: AiCamera;
decision: NonNullable<AiRun["last_sample"]>["decision"] | null;
}
export interface AiCatalog {
sources: AiSource[]; worlds: AiWorld[]; runs: AiRun[];
runtime: { available: boolean; active_run: AiRun | null };
}
export async function polygonRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API}${path}`, { ...init, headers: {
...(typeof init.body === "string" ? { "Content-Type": "application/json" } : {}),
...init.headers,
} });
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(typeof body?.detail === "string" ? body.detail : "Не удалось выполнить запрос полигона.");
}
return response.json() as Promise<T>;
}
export const runJournalUrl = (run: AiRun) => `${API}/runs/${run.run_id}/decisions`;
export const fetchAiCatalog = () => polygonRequest<AiCatalog>("/catalog");
export const configureAiWorld = (world: AiWorld, settings: AiWorldSettings) =>
polygonRequest<AiWorld>(`/worlds/${world.world_id}/settings`, {
method: "PUT", body: JSON.stringify(settings),
});
export const startAiRun = (world: AiWorld, composition?: AiSelection) => polygonRequest<AiRun>("/runs", {
method: "POST", headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ world_id: world.world_id, clock: "realtime", start_paused: true, duration_seconds: 1800, composition }),
});
export const setAiCamera = (run: AiRun, camera: AiCamera) =>
polygonRequest<AiRun>(`/runs/${run.run_id}/view`, { method: "PUT", body: JSON.stringify({ camera }) });
export const controlAiRun = (run: AiRun, command: "play" | "pause" | "step" | "stop") =>
polygonRequest<AiRun>(`/runs/${run.run_id}/${command}`, { method: "POST" });
export async function importAiWorld(file: File, source: AiSource, progress: (value: number) => void,
signal: AbortSignal, resume?: AiWorld, onCreated?: (world: AiWorld) => void): Promise<AiWorld> {
if (!file.name.toLowerCase().endsWith(".ply")) throw new Error("Выберите Gaussian PLY из экспорта сцены.");
if (resume && (file.name !== resume.filename || file.size !== resume.byte_length)) {
throw new Error("Для продолжения выберите тот же файл.");
}
let world = resume ? await polygonRequest<AiWorld>(`/worlds/${resume.world_id}`, { signal })
: await polygonRequest<AiWorld>("/worlds", { method: "POST", signal,
body: JSON.stringify({ ...source, description: undefined, filename: file.name,
byte_length: file.size, source_url: source.source_url || null }) });
onCreated?.(world);
if (world.uploaded_bytes > 0) {
const prefix = await polygonRequest<{ uploaded_bytes: number; chunks: { byte_length: number; sha256: string }[] }>(
`/worlds/${world.world_id}/upload-prefix`, { signal });
let offset = 0;
for (const chunk of prefix.chunks) {
signal.throwIfAborted();
const bytes = await file.slice(offset, offset + chunk.byte_length).arrayBuffer();
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)))
.map((value) => value.toString(16).padStart(2, "0")).join("");
if (digest !== chunk.sha256) throw new Error("Этот файл отличается от начатой загрузки. Выберите исходный файл.");
offset += chunk.byte_length;
}
if (offset !== prefix.uploaded_bytes || offset !== world.uploaded_bytes) {
throw new Error("Загрузка изменилась в другом окне. Повторите продолжение импорта.");
}
}
const chunkSize = 4 * 1024 ** 2;
progress(world.uploaded_bytes / file.size);
while (world.uploaded_bytes < file.size) {
const offset = world.uploaded_bytes;
world = await polygonRequest<AiWorld>(`/worlds/${world.world_id}/source`, {
method: "PATCH", signal, headers: { "Upload-Offset": String(offset) },
body: file.slice(offset, Math.min(file.size, offset + chunkSize)),
});
progress(world.uploaded_bytes / file.size);
}
return polygonRequest<AiWorld>(`/worlds/${world.world_id}/complete`, { method: "POST", signal });
}
+1
View File
@@ -155,6 +155,7 @@ export const workspaces: WorkspaceDefinition[] = [
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
icon: "clipboard",
kind: "lab-archive",
internalOnly: true,
groups: [],
},
{
+1
View File
@@ -11,6 +11,7 @@
@import "./styles/l34-annotation.css";
@import "./styles/laboratory-reporting.css";
@import "./styles/simulation.css";
@import "./styles/ai-polygon.css";
@import "./styles/laboratory-evidence-report.css";
@import "./styles/e34-temporal-layer.css";
@import "./styles/m4-replay-threat.css";
@@ -0,0 +1,29 @@
.simulation-profiles { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; height: 100%; min-height: 0; }
.simulation-profiles > .nodedc-select-anchor { justify-self: start; }
.ai-polygon { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; min-width: 0; min-height: 0; overflow: hidden; }
.ai-polygon__content { min-height: 0; overflow: auto; overscroll-behavior: contain; padding-bottom: 1rem; }
.ai-polygon__head, .ai-polygon__row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.ai-polygon__actions, .ai-polygon__facts { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.ai-polygon__telemetry-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.ai-polygon__telemetry-row > .ai-polygon__facts { flex: 1; min-width: 0; }
.ai-polygon__telemetry-row > :last-child { flex-shrink: 0; }
.ai-polygon__section, .ai-polygon__run, .ai-polygon__form { display: grid; gap: .75rem; }
.ai-polygon h2, .ai-polygon h3, .ai-polygon p { margin: 0; }
.ai-polygon h2 { font-size: var(--nodedc-font-size-title); }
.ai-polygon h3, .ai-polygon strong { font-size: var(--nodedc-font-size-md); }
.ai-polygon p, .ai-polygon span, .ai-polygon a { font-size: var(--nodedc-font-size-sm); }
.ai-polygon p { color: var(--nodedc-text-muted); line-height: 1.5; }
.ai-polygon__setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .75rem; }
.ai-polygon__title { display: flex; align-items: center; gap: 1rem; }
.ai-polygon__stage { display: flex; flex-direction: column; gap: .75rem; min-width: 0; }
.ai-polygon__viewer { display: flex; flex-direction: column; min-height: 24rem; height: min(65vh, 48rem); overflow: hidden; border-radius: var(--nodedc-radius-panel); background: var(--nodedc-canvas); }
.ai-polygon__viewer-toolbar { display: flex; justify-content: space-between; align-items: flex-start; padding: .5rem; gap: .5rem; }
.ai-polygon__viewport { flex: 1; position: relative; min-height: 0; overflow: hidden; }
.ai-polygon__viewport[hidden] { display: none; }
.ai-polygon__viewport .nodedc-loading-region { height: 100%; }
.ai-polygon__video { width: 100%; height: 100%; pointer-events: none; }
.ai-polygon__video video { display: block; width: 100%; height: 100%; object-fit: contain; }
.ai-polygon__preview-error { position: absolute; inset: 0; display: grid; place-content: center; gap: .75rem; background: var(--nodedc-canvas); }
.ai-polygon__stage:fullscreen { height: 100%; box-sizing: border-box; padding: .75rem; overflow: auto; background: var(--nodedc-canvas); }
.ai-polygon__stage:fullscreen .ai-polygon__viewer { flex: 1; height: auto; min-height: 12rem; }
@@ -1,4 +1,3 @@
import { PlanningConnectionWindow } from "../components/missions/PlanningConnectionWindow";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import { useDevicePluginHost } from "../core/device-plugins/DevicePluginHost";
@@ -41,7 +40,7 @@ function ModelCard({
);
}
function DeviceConnectionBody({
export function DeviceWorkspace({
onOpenSpatialScene,
onActivateAutomaticSpatialSource,
}: {
@@ -129,7 +128,3 @@ function DeviceConnectionBody({
</div>
);
}
export function DeviceWorkspace(props: {onOpenSpatialScene:()=>void;onActivateAutomaticSpatialSource:()=>void}) {
return <PlanningConnectionWindow><DeviceConnectionBody {...props}/></PlanningConnectionWindow>;
}
@@ -1,5 +1,4 @@
import {SpatialWorkspace} from "./spatial/SpatialWorkspace";
import { usePlanningTest } from "../core/missions/PlanningTestContext";
import { PlanningSpatialWorkspace } from "./missions/PlanningSpatialWorkspace";
import { MissionPlannerWorkspace } from "./missions/MissionPlannerWorkspace";
import { VehiclesWorkspace } from "./fleet/VehiclesWorkspace";
@@ -416,10 +415,9 @@ function RecordingsWorkspace(props: WorkspaceRendererProps) {
}
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
const planning = usePlanningTest();
switch (props.definition.kind) {
case "spatial":
return planning.selected ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
return props.launchProfile === 'planning' ? <PlanningSpatialWorkspace {...props}/> : <SpatialWorkspace {...props} />;
case "recordings":
return <RecordingsWorkspace {...props} />;
case "cameras":
@@ -433,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
case "missions":
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
case "vehicles":
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} />;
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} headerToolsHost={props.headerToolsHost} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "contour-health":
@@ -14,9 +14,10 @@ import type {
} from "../core/runtime/contracts";
import type { WorkspaceDefinition } from "../productModel";
import type { SceneSettings } from "../sceneSettings";
import type { WorkspaceLaunchProfile } from "../core/observation/workspaceLaunch";
export interface WorkspaceNavigation {
openView: (viewId: string) => void;
openView: (viewId: string, profile?: WorkspaceLaunchProfile) => void;
openSource: () => void;
openDisplay: () => void;
openLayers: () => void;
@@ -35,6 +36,7 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
launchProfile?: WorkspaceLaunchProfile;
fleetCreateRequest?: number;
headerToolsHost?: HTMLElement | null;
definition: WorkspaceDefinition;
@@ -1,11 +1,13 @@
import {useMemo} from 'react';
import {useMemo,type ReactNode} from 'react';
import {boardLayout} from '../../core/fleet/boardLayout';
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost';
import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import {createFleetSensorTransport} from '../../core/fleet/sensorTransport';
export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){
export function VehicleSensors({vehicleID,enabled,computer,description}:{vehicleID:string;enabled:boolean;computer:ReactNode;description:string}){
const {registry}=useDevicePluginHost();
const sensorContributions=registry.sensorContributions;
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} onDetailChange={onDetailChange}/>;
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} board={{layout,computer,description}}/>;
}
@@ -10,7 +10,7 @@ const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "
const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value;
function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; }
export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) {
export function VehiclesWorkspace({ createRequest = 0, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) {
const fleet = useFleet();
const [adding, setAdding] = useState(false);
const [code, setCode] = useState("");
@@ -20,7 +20,6 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
const [selected, setSelected] = useState<string | null>(null);
const [sensorOpen, setSensorOpen] = useState(false);
const [monitorOpen,setMonitorOpen]=useState(false);
const [observationOpen,setObservationOpen]=useState(false);
const [revoking, setRevoking] = useState<Vehicle | null>(null);
@@ -44,25 +43,24 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe
finally { setPending(false); }
}
const detail = fleet.items?.find(item => item.id === selected);
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSensorOpen(false);}}/>;
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>;
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
return <div className="fleet-workspace">
{fleet.error && <p role="alert">{fleet.error}</p>}
{!adding && error && <p role="alert">{error}</p>}
{detail ? <>
<div><Button onClick={() => {setSelected(null);setSensorOpen(false);}}>К списку аппаратов</Button></div>
{!sensorOpen && <SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
<div><Button onClick={() => {setSelected(null);}}>К списку аппаратов</Button></div>
<VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} description={detail.name} computer={<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
{detail.notice && <p role="status">{detail.notice}</p>}
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
</dl>
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения</Button>
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
</SettingsCard>}
<SettingsCard title="Устройства аппарата"><VehicleSensors onDetailChange={setSensorOpen} vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} /></SettingsCard>
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
</SettingsCard>}/>
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
@@ -11,8 +11,11 @@ import {ObservationDeck,ObservationMount} from './ObservationDeck';
import {ObservationMap} from './ObservationMap';
import {useObservationInventory} from './useObservationInventory';
import './observation.css';
import {RoverView} from '../../../components/rover/RoverView';
import {RoverTelemetry} from '../../../components/rover/RoverTelemetry';
import {useRoverControl} from '../../../core/fleet/useRoverControl';
export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:string;name:string;enabled:boolean;back:()=>void}){
export function BoardObservationCenter({vehicleID,name,enabled,back,configure,headerToolsHost}:{vehicleID:string;name:string;enabled:boolean;back:()=>void;configure:()=>void;headerToolsHost?:HTMLElement|null}){
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
@@ -27,17 +30,20 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
});
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
sources.push({key:'map',view:{id:'map',label:'Карта'}});
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
const effectiveFull=full&&visible.includes(full)?full:null;
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
const active=sources.find(source=>source.key===focused);
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
return <><div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения · ${name}`}>
<header className="observation-toolbar"><div><h2>Центр наблюдения</h2><span>{name}</span></div><div className="observation-actions"><Button onClick={back}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
@@ -51,7 +57,7 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
<div className="observation-actions">
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
{source.key!=='map'&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
</div>
@@ -63,6 +69,8 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID:
</DragDropRoot>
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)}
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
@@ -11,6 +11,8 @@ export function ObservationMount({element,className='observation-mount'}:{elemen
export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map<string,HTMLElement>;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){
if(!ids.length)return null;
if(ids.length===1)return <ObservationMount element={mounts.get(ids[0])!}/>;
const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id));
const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length;
const mid=defaultColumns&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
}
@@ -3,7 +3,6 @@ import { createPortal } from 'react-dom';
import { Button, ConfirmationModal, GlassSurface, Icon, IconButton, LoadingRegion, SegmentedControl, StatusBadge, WorkspaceWindow, type WorkspaceWindowRect } from '@nodedc/ui-react';
import { usePlanningTest } from '../../core/missions/PlanningTestContext';
import { useMissionPlanner } from '../../core/missions/useMissionPlanner';
import { useRegistrationTest } from '../../core/missions/useRegistrationTest';
import { usePlanningProjects } from '../../core/missions/usePlanningProjects';
import { planningProjectPending, planningProjectStatus } from '../../core/missions/planningProjects';
import { MissionZonePreview } from '../../components/missions/MissionZonePreview';
@@ -15,11 +14,9 @@ import { PlanningProjectSelect } from '../../components/missions/PlanningProject
import '../../styles/session-overview.css';
import '../../styles/mission-planner.css';
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string)=>void;headerToolsHost?:HTMLElement|null}) {
export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id:string,profile?:'planning')=>void;headerToolsHost?:HTMLElement|null}) {
const live=usePlanningTest(), p=useMissionPlanner(), projects=usePlanningProjects();
const t=useRegistrationTest(p.saved);
const [creating,setCreating]=useState(false), [settingsOpen,setSettingsOpen]=useState(false);
const [mode,setMode]=useState<'scanner'|'recording'>('scanner');
const [view,setView]=useState<'cloud'|'route'>('cloud');
const [starting,setStarting]=useState(false);
const [pending,setPending]=useState<(()=>void)|null>(null);
@@ -39,7 +36,7 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
};
const choose=(key:string)=>replace(()=>{setCreating(false);setSettingsOpen(false);projects.select(key);});
const newProject=()=>replace(()=>{
projects.select('');p.newDraft();t.setSessionId('');setMode('scanner');setView('cloud');
projects.select('');p.newDraft();setView('cloud');
setCreating(true);setSettingsOpen(true);setMaximized(false);loadedDraft.current='';
});
const start=async()=>{
@@ -47,13 +44,8 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
try {
const draft=p.saved&&!p.dirty?p.saved:await p.save();
if(!draft)return;
if(mode==='scanner') {
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device');}
} else {
const result=await t.run(draft);
if(result){setCreating(false);projects.select('recorded:'+result.id);setSettingsOpen(true);}
}
const next=await live.begin(draft);
if(next){projects.select('live:'+next.id);openView('local-device','planning');}
projects.refresh();
} finally {setStarting(false);}
};
@@ -81,13 +73,13 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
{!p.sessionId?<div className="session-overview__empty">Выберите сохранённую запись эталона в настройках.</div>
:p.sourceError||p.sourceChanged?<div className="session-overview__empty" role="alert"><span>{p.sourceError||'Исходная запись изменилась.'}</span><Button onClick={p.retrySource}>Повторить загрузку</Button></div>
:!p.source?<LoadingRegion loading label="Подготовка эталона" className="mission-planner__zone-loading"/>
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
:view==='cloud'?<MissionZonePreview sessionId={p.sessionId} generation={p.source.generation} toolbar={zoneControls}/>:<MissionRoutePreview source={p.source} poses={p.poses}/>}
</>:projects.error?<div className="session-overview__empty" role="alert"><span>{projects.error}</span><Button onClick={projects.refresh}>Повторить</Button></div>
:project?<>
<header className="planning-project__result-header"><span>{project.name}</span><StatusBadge tone={project.result_status==='rejected'?'warning':'neutral'}>{planningProjectStatus(project)}</StatusBadge></header>
{project.scene_url?<RegistrationScene sourceUrl={project.scene_url}/>
:planningProjectPending(project)?<LoadingRegion loading label="Подготовка результата совмещения" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись остаётся в разделе «Данные».</small></div>}
:<div className="session-overview__empty"><span>В этом проекте нет сохранённого совмещения.</span><small>Исходная запись прохода сохранена отдельно от результата.</small></div>}
{project.scene_url&&<small className="planning-project__legend">{project.result?.correspondence_colors?'Серый — эталон · цветной — повторный проход · зелёный — точки в пределах 0,5 м':'Серый — эталон · зелёный — повторный проход (ранний формат записи)'}</small>}
</>:projects.loading||projects.key?<LoadingRegion loading label="Загрузка проекта" className="mission-planner__zone-loading"/>
:<div className="session-overview__empty">Выберите совмещённый маршрут или создайте проект кнопкой «+».</div>}
@@ -96,9 +88,9 @@ export function MissionPlannerWorkspace({openView,headerToolsHost}:{openView:(id
title="Настройки" minWidth={320} minHeight={260} active zIndex={100} onClose={()=>setSettingsOpen(false)} closeLabel="Закрыть настройки"
moveLabel="Переместить настройки" resizeLabel="Изменить размер настроек" maximizeLabel="Развернуть настройки" restoreLabel="Восстановить настройки"
className="planning-project__inspector">
{editing?<PlanningProjectSettings p={p} t={t} mode={mode} setMode={setMode} onStart={()=>void start()} starting={starting}/>
{editing?<PlanningProjectSettings p={p} onStart={()=>void start()} starting={starting}/>
:project?<PlanningProjectResult project={project}/>:<p>Выберите проект или создайте новый кнопкой «+».</p>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{await live.select(project.id);live.resume();openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene');}}>Открыть текущий проход</Button>}
{project?.kind==='live'&&planningProjectPending(project)&&<Button disabled={live.busy} onClick={async()=>{if(await live.select(project.id))openView(project.state==='preparing'||project.state==='waiting'?'local-device':'spatial-scene','planning');}}>Открыть текущий проход</Button>}
{live.error&&editing&&<p role="alert">{live.error}</p>}
</WorkspaceWindow>}
</div>
@@ -46,11 +46,11 @@ export function SessionOverviewWorkspace({ sessionId }: { sessionId: string }) {
separatorLabel="Высота графика интервалов" className="session-overview__split"
primary={<SplitPane primarySize={left} onPrimarySizeChange={setLeft} minPrimarySize={35} minSecondarySize={25} separatorLabel="Ширина облака и сводки" className="session-overview__split"
primary={<GlassSurface className="session-overview__panel" radius="panel">
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
{m?.spatial_available && data?.scene_url ? <SessionOverviewScene sourceUrl={data.scene_url} compareVersions /> : <><h2>Облако и траектория</h2><div className="session-overview__empty">Пространственный обзор для этой записи недоступен.</div></>}
{!m?.spatial_available && <span className="session-overview__note">Доступны сведения из каталога записи.</span>}
</GlassSurface>}
secondary={<GlassSurface className="session-overview__panel" radius="panel">
<h2>Сведения о записи</h2><div className="session-overview__facts">
<h2>Сведения об исходной записи</h2><div className="session-overview__facts">
<dl>{facts.map(([name, value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>
<p>Наблюдения точек включают повторные измерения. Длина пути и положения получены из записи и не являются независимой проверкой точности.</p>
</div>
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, GlassSurface, Icon, IconButton, LoadingRegion, StatusBadge, ToastStack } from "@nodedc/ui-react";
import { AiWorldImport } from "../../components/simulation/AiWorldImport";
import { AiWorldStream } from "../../components/simulation/AiWorldStream";
import { AiWorldSetup } from "../../components/simulation/AiWorldSetup";
import { AiRunView } from "../../components/simulation/AiRunView";
import { AiRunActions } from "../../components/simulation/AiRunActions";
import { AiCompositionFields } from "../../components/simulation/AiCompositionFields";
import { fetchAiCatalog, fetchAiModules, startAiRun, type AiCatalog, type AiSource, type AiWorld, type AiRun, type AiModules, type AiSelection } from "../../core/simulation/aiPolygon";
export function AiPolygonWorkspace() {
const [catalog, setCatalog] = useState<AiCatalog | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [runId, setRunId] = useState<string | null>(null);
const [importing, setImporting] = useState<{ source: AiSource | null; resume?: AiWorld } | null>(null);
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [actionsHost, setActionsHost] = useState<HTMLDivElement | null>(null);
const [modules, setModules] = useState<AiModules | null>(null);
const [selection, setSelection] = useState<AiSelection | undefined>();
const fast = useRef(false);
const load = useCallback(async () => { const result = await fetchAiCatalog(); setCatalog(result); }, []);
useEffect(() => {
let active = true;
void fetchAiModules().then((value) => { if (active) { setModules(value); setSelection(value.selection); } })
.catch((caught: Error) => { if (active) setError(caught.message); });
return () => { active = false; };
}, []);
useEffect(() => {
let active = true; let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
try { const result = await fetchAiCatalog(); if (active) {
setCatalog(result); fast.current = !!result.runtime.active_run;
} }
catch (caught) { if (active) setError(caught instanceof Error ? caught.message : "Локации недоступны."); }
if (active) timer = setTimeout(() => void poll(), fast.current ? 500 : 1500);
};
void poll(); return () => { active = false; clearTimeout(timer); };
}, []);
const selected = catalog?.worlds.find((world) => world.world_id === selectedId);
const activeRun = catalog?.runtime.active_run;
useEffect(() => {
if (activeRun?.world.world_id === selectedId) setRunId(activeRun.run_id);
}, [activeRun?.run_id, activeRun?.world.world_id, selectedId]);
const run = selected ? catalog?.runtime.active_run?.world.world_id === selected.world_id
? catalog.runtime.active_run : catalog?.runs.find((item) => item.run_id === runId && item.world.world_id === selected.world_id)
: undefined;
const acceptRun = (value: AiRun) => {
setSettingsOpen(false);
setRunId(value.run_id); fast.current = !["completed", "stopped", "failed"].includes(value.state);
setCatalog((prior) => prior ? { ...prior, runs: [value, ...prior.runs.filter((r) => r.run_id !== value.run_id)],
runtime: { ...prior.runtime, active_run: fast.current ? value : null } } : prior);
void load().catch(() => undefined);
};
const acceptWorld = (value: AiWorld) => {
setSelectedId(value.world_id); setImporting(null); setSettingsOpen(false); void load().catch(() => undefined);
};
const openWorld = (world: AiWorld) => {
if (world.status === "uploading") setImporting({ source: world, resume: world });
else {
setSelectedId(world.world_id); setRunId(null); setSettingsOpen(!world.settings.prepared);
}
};
const returnToLocations = () => { setSelectedId(null); setRunId(null); };
const connection = <StatusBadge tone={catalog?.runtime.available ? "success" : "neutral"}>
{catalog?.runtime.available ? "Симулятор подключён" : "Симулятор не подключён"}
</StatusBadge>;
const sceneActions = selected && catalog ? <>
<Button onClick={() => setSettingsOpen(!settingsOpen)}>Старт и масштаб</Button>
{!run || ["completed", "stopped", "failed"].includes(run.state) ? <Button variant="primary"
icon={<Icon name="play" />} loading={starting}
disabled={!catalog.runtime.available || !selected.settings.prepared || !!catalog.runtime.active_run}
onClick={() => { setStarting(true); void startAiRun(selected, selection).then(acceptRun)
.catch((caught: Error) => setError(caught.message)).finally(() => setStarting(false)); }}>Открыть симуляцию</Button> : null}
{run ? <AiRunActions run={run} onChange={acceptRun} /> : null}
</> : null;
return <div className="ai-polygon">
<header className="ai-polygon__head">
<div className="ai-polygon__title">
{selected ? <IconButton label="Назад к локациям" onClick={returnToLocations}>
<Icon name="chevron-left" />
</IconButton> : null}
<h2>{selected?.name ?? "Доступные локации"}</h2>
</div>
<div className="ai-polygon__actions" ref={setActionsHost}>
{selected ? !run ? sceneActions : null : <>{connection}
<Button icon={<Icon name="upload" />} onClick={() => setImporting({ source: null })}>Импорт локации</Button></>}
</div>
</header>
<LoadingRegion className="ai-polygon__content" loading={!catalog && !error} label="Загрузка локаций">
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Не удалось выполнить действие", description: error }]} onDismiss={() => setError(null)} /> : null}
{catalog ? !selected ? <section aria-label="Доступные локации" className="ai-polygon__section">
{catalog.worlds.map((world) => <GlassSurface key={world.world_id} padding="md" className="ai-polygon__row">
<div><strong>{world.name}</strong><p>{world.author} · {world.license}</p></div>
{catalog.runtime.active_run?.world.world_id === world.world_id ? <StatusBadge tone="neutral">
{catalog.runtime.active_run.state === "running" && catalog.runtime.active_run.telemetry?.ai_ready
? "AI запущен" : catalog.runtime.active_run.state === "paused" ? "На паузе" : "Сцена открыта"}
</StatusBadge> : null}
<Button onClick={() => openWorld(world)}>{world.status === "uploading" ? "Продолжить импорт" : "Открыть"}</Button>
</GlassSurface>)}
</section> : <section className="ai-polygon__section" aria-label={`Симуляция ${selected.name}`}>
<LoadingRegion loading={starting} label="Открываем симуляцию на Worker">
{run ? <AiWorldStream world={selected} run={run} onBack={returnToLocations} onChange={acceptRun}
actionsHost={actionsHost} headerActions={sceneActions}
controls={<AiRunView run={run} connection={connection} />} /> : <div className="ai-polygon__telemetry-row">{connection}</div>}
</LoadingRegion>
{settingsOpen ? <AiWorldSetup key={selected.world_id} world={selected}
disabled={!!catalog.runtime.active_run} onSaved={acceptWorld} /> : null}
{settingsOpen && modules && selection ? <AiCompositionFields modules={modules}
selection={activeRun?.world.world_id === selectedId ? activeRun.request?.composition ?? selection : selection}
disabled={!!catalog.runtime.active_run} onChange={setSelection} /> : null}
</section> : null}
</LoadingRegion>
{importing ? <AiWorldImport source={importing.source} resume={importing.resume}
onClose={() => setImporting(null)} onImported={acceptWorld} /> : null}
</div>;
}
@@ -13,9 +13,11 @@ import {
GlassSurface,
Icon,
StatusBadge,
Select,
} from "@nodedc/ui-react";
import { SimulationCatalog } from "../../components/simulation/SimulationCatalog";
import { AiPolygonWorkspace } from "./AiPolygonWorkspace";
import { SimulationProjectWindow } from "../../components/simulation/SimulationProjectWindow";
import { SimulationViewport } from "../../components/simulation/SimulationViewport";
import {
@@ -42,6 +44,21 @@ interface BrowserUpload {
}
export function SimulationWorkspace() {
const [profile, setProfile] = useState<"lcc" | "ai">(() => {
try { return localStorage.getItem("missioncore.simulation-profile") === "ai" ? "ai" : "lcc"; }
catch { return "lcc"; }
});
return <div className="simulation-profiles" data-simulation-profile={profile}>
<Select label="Профиль симуляции" value={profile} options={[
{ value: "lcc", label: "LCC Gaussian Mirror" }, { value: "ai", label: "AI-полигон" },
]} onChange={(value) => { setProfile(value);
try { localStorage.setItem("missioncore.simulation-profile", value); } catch { /* Private browsing. */ }
}} />
{profile === "lcc" ? <LccGaussianWorkspace /> : <AiPolygonWorkspace />}
</div>;
}
function LccGaussianWorkspace() {
const [projects, setProjects] = useState<SimulationProject[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -39,6 +39,7 @@ export interface SpatialWorkspaceProfile {
activity?: import("../../core/device-plugins/contracts").SpatialActivityPresentation;
}
export function SpatialWorkspace({
launchProfile = 'direct',
state,
sourceUrl,
requestedPlaybackSeconds,
@@ -441,7 +442,7 @@ export function SpatialWorkspace({
model={spatialControls.model}
spatialActivity={visualProfile?.activity}
host={{
openSpatialScene: () => navigation.openView("spatial-scene"),
openSpatialScene: () => navigation.openView("spatial-scene", launchProfile),
activateAutomaticSpatialSource: navigation.activateAutomaticSpatialSource,
}}
/>):null}
@@ -553,7 +554,7 @@ export function SpatialWorkspace({
) : null}
</>}
navigationReady={visualProfile ? false : presentedViewerStatus==='ready'} timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
timeline={visualProfile ? null : <> {!pointCloudFocused && !floatingSourceMaximized && recordedPlaybackReady ? (
<ObservationTimeline
active={presentedViewerStatus === "ready"}
sourceCount={Math.max(1, (unifiedPerception ? 2 : 1) + presentedMediaSourceCount)}
@@ -571,6 +572,7 @@ export function SpatialWorkspace({
onPlayingChange={playbackController?.setPlaying}
onJumpToEnd={playbackController?.jumpToEnd}
accumulationSeconds={accumulationSeconds}
accumulationMaxSeconds={sceneSettings.accumulationMaxSeconds}
onAccumulationChange={onAccumulationChange}
onAccumulationCommit={onAccumulationCommit}
className="scene-timeline"
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import ts from 'typescript';
const code=ts.transpileModule(readFileSync(new URL('../../../packages/sensor-ui/src/boardLayout.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText;
const {createBoardLayoutStore,defaultBoardLayout}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const tick=()=>new Promise(resolve=>setImmediate(resolve));
function server(){
let value=structuredClone(defaultBoardLayout);const calls=[];
return {calls,read:async()=>structuredClone(value),patch:async(section,open)=>{
calls.push({section,open});await tick();
value={...value,revision:value.revision+1,open_sections:defaultBoardLayout.open_sections.filter(id=>id===section?open:value.open_sections.includes(id))};
return structuredClone(value);
}};
}
test('rapid toggles persist after navigation and preserve all-closed state',async()=>{
const api=server();const store=createBoardLayoutStore(api);await store.load();
const unsubscribe=store.subscribe(()=>{});
store.change(['settings','devices']);store.change(['devices']);store.change([]);
unsubscribe();
while(store.getSnapshot().saving)await tick();
const reopened=createBoardLayoutStore(api);await reopened.load();
assert.deepEqual(reopened.getSnapshot().value.open_sections,[]);
assert.equal(api.calls.length,3);
});
test('a later toggle wins while an older patch is still in flight',async()=>{
const api=server();const store=createBoardLayoutStore(api);await store.load();
store.change(['settings','devices']);store.change(['computer','settings','devices']);
while(store.getSnapshot().saving)await tick();
assert.deepEqual((await api.read()).open_sections,defaultBoardLayout.open_sections);
});
test('different vehicles and simultaneous section changes do not clobber one another',async()=>{
const api=server(),other=server();const a=createBoardLayoutStore(api),b=createBoardLayoutStore(api),c=createBoardLayoutStore(other);
await Promise.all([a.load(),b.load(),c.load()]);
a.change(['settings','devices']);b.change(['computer','devices']);
while(a.getSnapshot().saving||b.getSnapshot().saving)await tick();
assert.deepEqual((await api.read()).open_sections,['devices']);
assert.deepEqual(c.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
});
test('failed save reports failure, rolls back, and explicit reload recovers',async()=>{
const api=server();let fail=true;
const store=createBoardLayoutStore({...api,patch:(...args)=>fail?Promise.reject(new Error('offline')):api.patch(...args)});
await store.load();store.change([]);
while(store.getSnapshot().saving)await tick();
assert.ok(store.getSnapshot().error);
assert.deepEqual(store.getSnapshot().value.open_sections,defaultBoardLayout.open_sections);
fail=false;await store.load();store.change(['devices']);
while(store.getSnapshot().saving)await tick();
assert.equal(store.getSnapshot().error,null);
assert.deepEqual((await api.read()).open_sections,['devices']);
});
test('invalid layout cannot overwrite stored preferences',async()=>{
let writes=0;
const store=createBoardLayoutStore({read:async()=>({...defaultBoardLayout,open_sections:['motor-start']}),patch:async()=>{writes++;return defaultBoardLayout;}});
await store.load();store.change([]);
assert.equal(store.getSnapshot().ready,false);assert.ok(store.getSnapshot().error);assert.equal(writes,0);
});
@@ -130,6 +130,15 @@ test("decoder fails closed on an unknown environment schema", () => {
);
});
test("retired laboratory quick actions resolve to AI Inference", () => {
const payload = serverDocument();
payload.pages.polygon.primary_workspace_id = "lab-archive";
const decoded = environment.decodeEnvironmentSettings(payload);
assert.equal(decoded.pages.polygon.primaryWorkspaceId, "observatory");
assert.equal(decoded.revision, payload.revision);
assert.deepEqual(decoded.pages.polygon.background.items, []);
});
test("editing a cloned page cannot mutate accepted settings", () => {
const accepted = environment.decodeEnvironmentSettings(serverDocument());
const draft = environment.cloneEnvironmentSettings(accepted);
@@ -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",
@@ -27,7 +27,7 @@ async function read(relativePath) {
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
["simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import {before,after,test} from 'node:test';
import React from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {createServer} from 'vite';
import {readFile} from 'node:fs/promises';
let server,usePlanner,Settings;
before(async()=>{
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
({useMissionPlanner:usePlanner}=await server.ssrLoadModule('/src/core/missions/useMissionPlanner.ts'));
({PlanningProjectSettings:Settings}=await server.ssrLoadModule('/src/components/missions/PlanningProjectSettings.tsx'));
});
after(async()=>{await server?.close();});
// Bounded hook dispatcher: execute the real state/effect transitions, without
// mounting another browser or replacing the production hook with a fake.
function harness(){
const slots=[];let index=0,pending=[];
const hooks={
useState(initial){const i=index++;if(!slots[i])slots[i]={value:initial};return [slots[i].value,v=>{slots[i].value=typeof v==='function'?v(slots[i].value):v;}];},
useEffect(fn,deps){const i=index++,old=slots[i];if(!old||deps.some((d,n)=>!Object.is(d,old.deps[n])))pending.push(()=>{old?.cleanup?.();slots[i]={deps,cleanup:fn()};});},
useMemo(fn){return fn();},useCallback(fn){return fn;},
};
const render=()=>{index=0;const internal=React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,old=internal.H;internal.H=hooks;try{return usePlanner();}finally{internal.H=old;}};
return {render,async settle(){let state;for(let n=0;n<5;n++){state=render();const effects=pending;pending=[];effects.forEach(fn=>fn());await new Promise(resolve=>setImmediate(resolve));}return render();},close(){slots.forEach(s=>s.cleanup?.());}};
}
const source={schema_version:'missioncore.planning-source/v1',session_id:'ring',generation:'a'.repeat(64),label:'Ring',units:'m',path_m:580,
poses:Array.from({length:60},(_,index)=>({index,position:[index*10,0,0],distance_m:index*10}))};
test('same recording reselection cannot collapse the reference or disable a named project',async()=>{
const oldFetch=globalThis.fetch,requests=[];const h=harness();
globalThis.fetch=async(url,options={})=>{
requests.push({url,body:options.body&&JSON.parse(options.body)});
if(options.method==='POST')return {ok:true,json:async()=>({id:'draft',revision:1,name:'Seam',zone:{session_id:'ring',generation:source.generation},route:{start_index:0,end_index:59,direction:'forward'}})};
return {ok:true,json:async()=>url.includes('/sources/')?source:{items:[],next_cursor:null}};
};
try{
let p=await h.settle();p.chooseSource('ring');p.setName('Seam');p=await h.settle();
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
for(let i=0;i<4;i++){p.chooseSource('ring');p=await h.settle();assert.equal(p.poses.length,60);assert.equal(p.ready,true);}
await p.save();const body=requests.find(r=>r.body)?.body;
assert.equal(body.whole_recording,true);assert.equal(body.start_index,undefined);assert.equal(body.end_index,undefined);
assert.ok(requests.some(r=>r.url.includes('scope=standalone')));
p=h.render();p.newDraft();p=await h.settle();p.chooseSource('ring');p.setName('Next');p=await h.settle();
assert.equal(p.poses.length,60);assert.equal(p.ready,true);
assert.equal(p.setStart,undefined);assert.equal(p.setEnd,undefined);
}finally{h.close();globalThis.fetch=oldFetch;}
});
test('product removes reference crop controls and Data requests independent captures',async()=>{
const settings=await readFile(new URL('../src/components/missions/PlanningProjectSettings.tsx',import.meta.url),'utf8');
assert.doesNotMatch(settings,/Участок эталона|Начало участка|Конец участка|30 м от начала участка|p\.set(Start|End)/);
assert.match(settings,/Вся запись/);
const selector=await readFile(new URL('../src/components/ObservationSessionSelect.tsx',import.meta.url),'utf8');
assert.match(selector,/scope: "standalone"/);
});
test('planner offers one direct live start without a repeated-pass mode or catalogue',async()=>{
const p={name:'Ring',poses:source.poses,source,sessionId:'ring',options:[{value:'ring',label:'Ring'}],direction:'forward',ready:true};
const render=(overrides={},starting=false)=>renderToStaticMarkup(React.createElement(Settings,{p:{...p,...overrides},starting,onStart:()=>{}}));
const ready=render();
assert.doesNotMatch(ready,/Повторный проход|Из записи|Источник повторного прохода|Повторная запись|После запуска/);
const startButton=html=>[...html.matchAll(/<button\b[^>]*>[\s\S]*?<\/button>/g)].map(m=>m[0]).find(button=>button.includes('Начать новый проход'));
assert.ok(startButton(ready));
assert.doesNotMatch(startButton(ready),/disabled=/);
for(const state of [{ready:false},{busy:true},{poses:source.poses.slice(0,1)}])assert.match(startButton(render(state)),/disabled=/);
assert.match(startButton(render({},true)),/disabled=/);
let started=0;
const tree=Settings({p,starting:false,onStart:()=>{started++;}});
tree.props.children[1].props.children[1].props.onClick();
assert.equal(started,1);
const workspace=await readFile(new URL('../src/workspaces/missions/MissionPlannerWorkspace.tsx',import.meta.url),'utf8');
assert.doesNotMatch(workspace,/useRegistrationTest|setMode|t\.run\(/);
assert.match(workspace,/live\.begin\(draft\)/);
});
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
["simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => {
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
["simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);
@@ -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,157 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
async function moduleAt(path) {
const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'});
return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
}
const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts');
const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts');
const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX});
const profile={...base,deadband:0};
const arcade={...profile,mode:'arcade'};
const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']};
const motors=['a','b','c','d'];
function frame(now,values=axes(),extra={}) {
return {now,link:{state:'live',at:now},
axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])),
drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra};
}
function ready(p=base) {
const model=new AuthorityModel(p,motors,policy,'boot-A');
for(let t=0;t<=200;t+=50)model.step(frame(t));
return model;
}
function core() {
const model=ready();
const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.ok(token);return {model,token};
}
const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}});
test('tank keeps the two motor sides independent, including reversal',()=>{
assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4});
assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4});
});
test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{
for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]])
assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right});
assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0});
});
test('selected stick defines the axes, not the number or position of motors',()=>{
assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0});
});
test('deadband is continuous and rescaled; response and output scale are separate',()=>{
assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0});
assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5});
assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002);
});
test('every point in the input square stays bounded and changes sign symmetrically',()=>{
for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){
const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20));
const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20));
assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12);
assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12);
}
});
test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{
assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base);
for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad));
for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value}));
});
test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{
for(const n of [2,4,10]){
const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:i<n/2?'left':'right',forwardSign:i%2?1:-1}));
const result=motorDemands({left:.3,right:-.6},bindings);
assert.equal(Object.keys(result).length,n);
assert.deepEqual(result,motorDemands({left:.3,right:-.6},bindings.toReversed()));
}
const b={uuid:'0'.repeat(24),side:'left',forwardSign:1};
assert.throws(()=>motorDemands({left:1,right:1},[b]));
assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}]));
});
test('boot with held stick never permits motion; stable stopped neutral required',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});}
for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold');
assert.equal(model.step(frame(1200)).state,'rc-ready');
assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual');
});
test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{
const {model,token}=core();
assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core');
const first=model.step(frame(350,axes(.5),{command:command(token,350,1)}));
assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks);
for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0});
for(let t=1500;t<=1700;t+=50)model.step(frame(t));
const second=model.step(frame(1750,axes(.5)));
assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0);
assert.equal(model.step(frame(1800)).state,'rc-manual');
assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual');
});
test('neutral of one channel or a single moving member never completes rearming',()=>{
for(const kind of ['axis','drive']){
const {model}=core();model.step(frame(300,axes(1)));
for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');}
}
});
test('loss of any of four controllers stops all sides and invalidates Core',()=>{
for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});}
});
test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{
for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);}
});
test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{
for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){
const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid');
}
});
test('one repeated neutral sample cannot qualify stable neutral',()=>{
const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot');
model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0};
assert.equal(model.step(f).state,'hold');
});
test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{
const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}}));
for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold');
for(let t=1000;t<=1200;t+=50)model.step(frame(t));
assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual');
});
test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{
for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){
const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid');
}
const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)}));
assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid');
});
test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{
for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);}
});
test('premature or replayed Core request does not acquire authority after neutral',()=>{
const model=new AuthorityModel(base,motors,policy,'boot');
for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');}
assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core');
});
test('reboot cannot restore authority from serialized profile or old grant',()=>{
const {token}=core(), model=ready();
assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready');
const next=new AuthorityModel(base,motors,policy,'boot-B');
for(let t=0;t<=200;t+=50)next.step(frame(t));
const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.notEqual(grant.token,token);
assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold');
});
test('profile limits do not mask RC takeover intent at zero output scale',()=>{
const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}}));
assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
});
test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{
const model=ready({...base,mode:'arcade',stick:'right'});
model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}}));
assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover');
for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold');
const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid');
assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot'));
});
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
}}]});
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const flush=()=>new Promise(resolve=>setImmediate(resolve));
async function fixture(run){
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
let focus=true,hidden=false;const calls=[],timers=[];globalThis.roverEffects=[];
globalThis.window={addEventListener(){},removeEventListener(){}};
globalThis.document={get hidden(){return hidden;},hasFocus:()=>focus,addEventListener(){},removeEventListener(){}};
globalThis.setInterval=(f,ms)=>{const timer={f,ms};timers.push(timer);return timer;};globalThis.clearInterval=()=>{};
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});return {ok:true,json:async()=>url.endsWith('/arm')?{session_id:'fixture-session'}:{fresh:true,controlling:true,snapshot:{state:'ready'}}};};
const c=useRoverControl('fixture',true);const cleanup=globalThis.roverEffects.map(f=>f());
try{await flush();await c.arm(30,2000);await flush();await run({c,calls,timers,blur:()=>{focus=false;},focus:()=>{focus=true;},hide:()=>{hidden=true;}});}
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
}
test('command heartbeat sends neutral on lost blur, keeps session, and accepts new focused input',async()=>fixture(async f=>{
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();
assert.equal(f.calls.at(-1).body.left,1);f.blur();f.timers.find(t=>t.ms===100).f();await flush();
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,0);
f.focus();f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.left,0);
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
}));
test('expired physical input prevents heartbeat from renewing remembered demand',async()=>fixture(async f=>{
let valid=true;f.c.setInputGuard(()=>valid);f.c.setDemand({left:1,right:-1});await flush();valid=false;
f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.stop,false);
}));
test('no registered input scope cannot issue motion',async()=>fixture(async f=>{
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.stop,false);
assert.equal(f.calls.some(x=>x.body?.left===1),false);
}));
test('hidden document sends only neutral without a visibility event',async()=>fixture(async f=>{
f.c.setInputGuard(()=>true);f.hide();f.c.setDemand({left:1,right:1});await flush();
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.some(x=>x.body?.left===1),false);
}));
test('explicit pause clears demand without revoking session; explicit stop still revokes it',async()=>fixture(async f=>{
f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();f.c.pauseInput();await flush();
assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0);
f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1);
f.c.stop();await flush();assert.equal(f.calls.at(-1).body.stop,true);
const count=f.calls.length;f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.length,count);
}));
@@ -0,0 +1,49 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){
b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'}));
b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`}));
}}]});
const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const flush=()=>new Promise(resolve=>setImmediate(resolve));
const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};};
const response=value=>({ok:true,json:async()=>value});
const idle={fresh:true,controlling:false,snapshot:{supported:true,state:'observing'}};
async function fixture(run){
const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]]));
const calls=[],timers=[];globalThis.roverEffects=[];
globalThis.window={addEventListener(){},removeEventListener(){}};globalThis.document={hidden:false,hasFocus:()=>true,addEventListener(){},removeEventListener(){}};
globalThis.setInterval=(f,ms)=>{timers.push({f,ms});return timers.length;};globalThis.clearInterval=()=>{};
let armResponse=null,nextPoll=null;
globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});
if(url.endsWith('/arm'))return armResponse?armResponse.promise:response({session_id:'new-session'});
if(!body)return nextPoll?nextPoll.promise:response(idle);
return response({accepted_sequence:body.sequence});
};
const c=useRoverControl('fixture',true),cleanup=globalThis.roverEffects.map(f=>f());
try{await flush();await run({c,calls,poll:()=>timers.find(t=>t.ms===200).f(),heartbeat:()=>timers.find(t=>t.ms===100).f(),delayArm:()=>armResponse=deferred(),delayPoll:()=>nextPoll=deferred()});}
finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}}
}
test('idle poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
p.resolve(response(idle));await flush();f.heartbeat();await flush();
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old idle poll revoked the new session');
assert.equal(f.calls.at(-1).body.session_id,'new-session');
}));
test('failed poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{
const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll();
a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush();
p.resolve({ok:false,json:async()=>({detail:'stale read failure'})});await flush();
assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old failed poll revoked the new session');
}));
test('a current poll still revokes control when server reports lease lost',async()=>fixture(async f=>{
assert.equal(await f.c.arm(30,2000),true);await flush();f.poll();await flush();
assert.equal(f.calls.at(-1).body.stop,true);
}));
test('duplicate arm clicks while request pending create exactly one lease request',async()=>fixture(async f=>{
const a=f.delayArm();const first=f.c.arm(30,2000),second=f.c.arm(30,2000);
a.resolve(response({session_id:'new-session'}));await Promise.all([first,second]);await flush();
assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1);
}));
@@ -0,0 +1,85 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
const result=await build({entryPoints:[new URL('../src/core/fleet/roverHoldInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
const {bindRoverHoldInput}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
class Events {
listeners=new Map();timers=new Map();next=0;
addEventListener(type,fn){if(!this.listeners.has(type))this.listeners.set(type,new Set());this.listeners.get(type).add(fn);}
removeEventListener(type,fn){this.listeners.get(type)?.delete(fn);}
emit(type,detail={}){const e={target:this,isTrusted:true,preventDefault(){},...detail};for(const fn of [...(this.listeners.get(type)??[])])fn(e);}
setInterval(fn){const id=++this.next;this.timers.set(id,fn);return id;}
clearInterval(id){this.timers.delete(id);}
tick(){for(const fn of this.timers.values())fn();}
}
function fixture(mode='arcade'){
let time=0,focus=true;const win=new Events(),doc=new Events();doc.defaultView=win;doc.hidden=false;doc.hasFocus=()=>focus;
const scope={ownerDocument:doc,closest:()=>null},child={closest:()=>null};scope.contains=x=>x===scope||x===child;doc.activeElement=scope;
const states=[],demands=[];let stops=0,pauses=0;
const binding=bindRoverHoldInput(scope,mode,{held:k=>states.push([...k]),demand:d=>demands.push(d),stop:()=>stops++,pause:()=>{pauses++;demands.push({left:0,right:0});}},()=>time);
const key=(code,repeat=false)=>win.emit('keydown',{code,repeat,target:scope});
return {win,doc,scope,child,binding,states,demands,key,stops:()=>stops,pauses:()=>pauses,advance:ms=>{time+=ms;win.tick();},loseFocus:()=>{focus=false;},restoreFocus:()=>{focus=true;},up:code=>win.emit('keyup',{code,target:scope})};
}
test('blur clears D, ignores a late repeat, but accepts a fresh press without rearming',()=>{
const f=fixture();f.key('KeyD');assert.deepEqual(f.demands.at(-1),{left:1,right:-1});
f.win.emit('blur');assert.equal(f.pauses(),1);assert.equal(f.stops(),0);assert.deepEqual(f.states.at(-1),[]);
assert.deepEqual(f.demands.at(-1),{left:0,right:0});const count=f.demands.length;
f.key('KeyD',true);assert.equal(f.demands.length,count);
f.key('KeyW');assert.deepEqual(f.demands.at(-1),{left:1,right:1});assert.equal(f.binding.valid(),true);f.binding.dispose();
});
test('heartbeat validity catches missing blur event using document focus',()=>{
const f=fixture();f.key('KeyD');f.loseFocus();assert.equal(f.binding.valid(),false);assert.equal(f.pauses(),1);
f.restoreFocus();assert.equal(f.binding.valid(),true);f.binding.dispose();
});
test('focus polling catches missing event or moving to another control',()=>{
const f=fixture();f.key('KeyW');f.doc.activeElement={};f.advance(50);assert.equal(f.pauses(),1);f.binding.dispose();
});
test('lost keyup AND missing focus notifications still expire repeated D',()=>{
const f=fixture();f.key('KeyD');f.advance(500);f.key('KeyD',true);f.advance(299);assert.equal(f.pauses(),0);
f.advance(1);assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.key('KeyD',true);assert.equal(f.pauses(),1);f.binding.dispose();
});
test('first press allows OS repeat delay but never an indefinite hold',()=>{
const f=fixture();f.key('KeyW');f.advance(999);assert.equal(f.pauses(),0);f.advance(1);assert.equal(f.pauses(),1);f.binding.dispose();
});
test('continuous repeat renews hold; keyup immediately sends neutral',()=>{
const f=fixture();f.key('KeyW');f.advance(600);
for(let i=0;i<30;i++){f.key('KeyW',true);f.advance(100);}
assert.equal(f.pauses(),0);f.up('KeyW');assert.deepEqual(f.demands.at(-1),{left:0,right:0});f.advance(1200);assert.equal(f.pauses(),0);f.binding.dispose();
});
test('diagonal input survives single-key OS repeat; release recomputes demand',()=>{
const f=fixture();f.key('KeyW');f.key('KeyA');
for(let i=0;i<20;i++){f.key('KeyA',true);f.advance(100);}
assert.equal(f.pauses(),0);assert.deepEqual(f.demands.at(-1),{left:0,right:1});
f.up('KeyA');assert.deepEqual(f.demands.at(-1),{left:1,right:1});f.up('KeyW');f.binding.dispose();
});
test('repeat without a fresh press and untrusted events never command motion',()=>{
const f=fixture();f.key('KeyD',true);f.win.emit('keydown',{code:'KeyW',isTrusted:false,target:f.scope});assert.equal(f.demands.length,0);f.binding.dispose();
});
test('focus inside the view is allowed; leaving or hiding it stops',()=>{
for(const event of ['focusout','visibilitychange','pagehide','outside']){
const f=fixture();f.key('KeyW');f.doc.emit('focusout',{target:f.scope,relatedTarget:f.child});assert.equal(f.pauses(),0);
if(event==='focusout')f.doc.emit(event,{target:f.scope,relatedTarget:null});
else if(event==='visibilitychange'){f.doc.hidden=true;f.doc.emit(event);}
else if(event==='pagehide')f.win.emit(event);
else f.doc.emit('pointerdown',{target:{}});
assert.equal(event==='pagehide'?f.stops():f.pauses(),1,event);f.binding.dispose();
}
});
test('pointer capture lost or cancelled releases every input',()=>{
for(const mode of ['arcade','tank']){
const f=fixture(mode);f.binding.pointerDown(1,mode==='arcade'?'KeyW':'KeyQ');f.binding.pointerCancel(1);
assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.binding.dispose();
}
});
test('document pointerup releases even if button handler does not receive it',()=>{
const f=fixture();f.binding.pointerDown(1,'KeyD');f.doc.emit('pointerup',{pointerId:1});assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.pauses(),0);f.binding.dispose();
});
test('modifier shortcuts pause; cleanup removes listeners, timer and held demand',()=>{
const f=fixture();f.key('KeyD');f.win.emit('keydown',{code:'Tab',metaKey:true,target:f.scope});assert.equal(f.pauses(),1);f.binding.dispose();
assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.win.timers.size,0);
assert.equal([...f.win.listeners.values(),...f.doc.listeners.values()].reduce((sum,x)=>sum+x.size,0),0);
});
test('Space and Escape remain explicit session stops',()=>{
for(const code of ['Space','Escape']){const f=fixture();f.key('KeyW');f.key(code);assert.equal(f.stops(),1);assert.equal(f.binding.valid(),false);f.binding.dispose();}
});
@@ -0,0 +1,23 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {build} from 'esbuild';
const result=await build({entryPoints:[new URL('../src/core/fleet/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
const keys=(...k)=>new Set(k.map(v=>'Key'+v));
test('arcade mixes directions and opposite keys cancel',()=>{
assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1});
assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1});
assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0});
});
test('tank sides and backwards remain independent',()=>{
assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1});
assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1});
assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0});
});
test('invalid stored settings never grant motion or invalid limits',()=>{
assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30);
assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000);
assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,'');
assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0});
});
@@ -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:/);
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import ts from 'typescript';
const source=readFileSync(new URL('../../../plugins/vesc/frontend/src/model.ts',import.meta.url),'utf8');
const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText;
const {vescLabel,testInput,speedInput,rotationResult,driveTestIds}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64'));
const controller={online:true,prepared:true,verified:true,vesc_status:{identity:{uuid:'synthetic'},readable:true}};
test('speed mode requires board capability and reports measured rotation time',()=>{
assert.ok(speedInput('1200',undefined).error);
const bounds={min_erpm:300,max_erpm:3000,duration_basis:'measured_speed'};
assert.equal(speedInput('1200',bounds).error,null);
for(const value of ['', 'NaN','299','3001'])assert.ok(speedInput(value,bounds).error);
const text=rotationResult({outcome:'stopped',rotation_s:2.5,release_confirmed:true,limits_restored:true});
assert.match(text,/2,5 с/);
assert.match(text,/Проверка остановлена/);
assert.doesNotMatch(text,/время вращения набрано/);
});
test('USB discovery and model preparation never claim a verified VESC',()=>{
assert.equal(vescLabel({...controller,prepared:false},true).label,'Требуется подготовка');
assert.equal(vescLabel({...controller,vesc_status:{identity:null,readable:false}},true).tone,'warning');
});
test('fresh read capability is distinct from unsupported firmware and offline state',()=>{
assert.equal(vescLabel(controller,true).label,'Готов к чтению');
assert.equal(vescLabel(controller,false).label,'Нет связи');
assert.equal(vescLabel({...controller,online:false},true).label,'Нет связи');
assert.equal(vescLabel({...controller,vesc_status:{identity:{uuid:'synthetic'},readable:false}},true).label,'Прошивка не поддерживается');
});
test('test fields accept entered values only inside the board capability bounds',()=>{
const bounds={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
assert.equal(testInput('3.7','7.2',bounds).valid,true);
assert.equal(testInput('5','10',bounds).valid,true);
for(const [amps,seconds] of [['','5'],['5',''],['60','10'],['5','11'],['NaN','1'],['Infinity','1']])assert.equal(testInput(amps,seconds,bounds).valid,false);
assert.equal(testInput('2','1.5',undefined).valid,false);
const overlong=testInput('5','15',bounds);
assert.equal(overlong.valid,false);
assert.equal(overlong.currentError,null);
assert.equal(overlong.durationError,'Длительность должна быть от 0,5 до 10 с.');
assert.equal(testInput('5','10',bounds).durationError,null);
});
// Capabilities come from the installed board: a newer Core must retain old bounds.
test('extended board accepts 30 A / 30 s without changing older board bounds',()=>{
const old={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10};
const expanded={...old,max_current_a:30,max_duration_s:30,continuous_current:true};
assert.equal(testInput('30','30',expanded).valid,true);
assert.equal(testInput('30.1','30',expanded).valid,false);
assert.equal(testInput('30','30.1',expanded).valid,false);
assert.equal(testInput('30','30',old).valid,false);
});
test('group spin requires a complete unique profile containing the selected controller',()=>{
const profile={layout:'1x1',revision:3,bindings:{'left.1':{device_id:'left',uuid:'a'},'right.1':{device_id:'right',uuid:'b'}}};
assert.deepEqual(driveTestIds(profile,'left'),['left','right']);
assert.deepEqual(driveTestIds(profile,'unassigned'),[]);
assert.deepEqual(driveTestIds({...profile,bindings:{'left.1':profile.bindings['left.1']}},'left'),[]);
assert.deepEqual(driveTestIds({...profile,bindings:{...profile.bindings,'right.1':profile.bindings['left.1']}},'left'),[]);
assert.deepEqual(driveTestIds(undefined,'left'),[]);
});
test('four-wheel profile preserves front and rear membership for common testing',()=>{
const ids=['lf','lr','rf','rr'];
const bindings=Object.fromEntries(['left.1','left.2','right.1','right.2'].map((slot,i)=>[slot,{device_id:ids[i],uuid:ids[i]}]));
assert.deepEqual(driveTestIds({layout:'2x2',revision:4,bindings},'rf'),ids);
});
@@ -0,0 +1,106 @@
import assert from 'node:assert/strict';
import React, {createElement} from 'react';
import {renderToStaticMarkup} from 'react-dom/server';
import {readFileSync} from 'node:fs';
import {before, after, test} from 'node:test';
import {createServer} from 'vite';
let server, resolve, awaitsCapture, Guard, Provider, Device, Host;
before(async () => {
server = await createServer({appType:'custom', logLevel:'silent', server:{middlewareMode:true}});
({workspaceLaunchProfile:resolve} = await server.ssrLoadModule('/src/core/observation/workspaceLaunch.ts'));
({planningAwaitsCapture:awaitsCapture, PlanningTestProvider:Provider} = await server.ssrLoadModule('/src/core/missions/PlanningTestContext.tsx'));
({PlanningCaptureGuard:Guard} = await server.ssrLoadModule('/src/components/missions/PlanningCaptureGuard.tsx'));
({DeviceWorkspace:Device} = await server.ssrLoadModule('/src/workspaces/DeviceWorkspace.tsx'));
({DevicePluginHostProvider:Host} = await server.ssrLoadModule('/src/core/device-plugins/DevicePluginHost.tsx'));
});
after(async () => { await server?.close(); });
test('direct entry overrides an earlier planning profile for both shared surfaces', () => {
for (const kind of ['device','spatial']) {
assert.equal(resolve('planning',kind),'direct');
assert.equal(resolve('direct',kind),'direct');
assert.equal(resolve('direct',kind,'planning'),'planning');
assert.equal(resolve('planning',kind,'planning'),'planning');
}
assert.equal(resolve('planning','missions'),'planning');
assert.equal(resolve('direct','missions'),'direct');
});
test('a prepared consumer cannot silently claim a direct capture; old/bound runs do not block it', () => {
assert.equal(awaitsCapture(null),false);
for (const state of ['completed','cancelled','interrupted','error']) {
for (const query_session_id of [null,'recording']) assert.equal(awaitsCapture({state,query_session_id}),false);
}
for (const state of ['preparing','waiting','running']) {
assert.equal(awaitsCapture({state,query_session_id:null}),true);
assert.equal(awaitsCapture({state,query_session_id:'recording'}),false);
}
});
function withHooks(hooks, fn) {
// Same bounded pre-effect harness used by observatoryHooks.test.mjs.
const internals = React.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;
const previous = internals.H;
internals.H = hooks;
try { return fn(); } finally { internals.H = previous; }
}
test('retained completed run renders the ordinary connection, without issuing any commands', () => {
let calls = 0;
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>({test:{state:'completed',query_session_id:'old'},finish:()=>{calls++;}})},
() => Guard({enabled:true,onResume:()=>{calls++;},children:child}));
assert.match(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(calls,0);
});
test('pending run handoff is explicit and only finishes the research, not the scanner', () => {
let stopped = 0, resumed = 0;
const context = {test:{state:'waiting',query_session_id:null,draft:{name:'PENDING'}},busy:false,error:null,finish:()=>{stopped++;}};
const child = createElement('div',null,'ORDINARY DEVICE');
const tree = withHooks({useContext:()=>context}, () => Guard({enabled:true,onResume:()=>{resumed++;},children:child}));
assert.doesNotMatch(renderToStaticMarkup(tree),/ORDINARY DEVICE/);
assert.equal(stopped,0);
const actions = tree.props.children.at(-1).props.children;
actions[0].props.onClick(); assert.equal(stopped,1); assert.equal(resumed,0);
actions[1].props.onClick(); assert.equal(resumed,1);
const planningTree = withHooks({useContext:()=>context}, () => Guard({enabled:false,onResume:()=>{},children:child}));
assert.match(renderToStaticMarkup(planningTree),/ORDINARY DEVICE/);
});
test('generic device catalog renders without any PlanningTestProvider', () => {
const html = renderToStaticMarkup(createElement(Host,{plugins:[]},
createElement(Device,{onOpenSpatialScene:()=>{},onActivateAutomaticSpatialSource:()=>{}})));
assert.match(html,/Выберите модель устройства/);
assert.doesNotMatch(html,/Профиль · Планирование|Подключение сканера · Планирование/);
});
test('failed planner selection does not grant a successful launch', async () => {
const writes = [];
const context = withHooks({
useState:initial=>[typeof initial==='function'?initial():initial,value=>writes.push(value)],
useRef:value=>({current:value}),useCallback:fn=>fn,useEffect:()=>{},
}, () => Provider({children:null}).props.value);
const originalFetch = globalThis.fetch;
try {
globalThis.fetch = async () => new Response(JSON.stringify({detail:'Выбранный проход недоступен'}),{status:409});
assert.equal(await context.select('missing'),false);
assert.ok(writes.includes('Выбранный проход недоступен'));
assert.equal('selected' in context,false);
assert.equal('resume' in context,false);
} finally { globalThis.fetch = originalFetch; }
});
test('composition owns explicit profile propagation; server polling cannot restore it', () => {
const source = path => readFileSync(new URL('../src/'+path,import.meta.url),'utf8');
assert.doesNotMatch(source('workspaces/DeviceWorkspace.tsx'),/Planning|missions\//);
assert.doesNotMatch(source('core/missions/PlanningTestContext.tsx'),/dismissed|sessionStorage|launchProfile/);
assert.match(source('App.tsx'),/useState<WorkspaceLaunchProfile>\('direct'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'planning'\)/);
assert.match(source('App.tsx'),/openView\("spatial-scene", 'direct'\)/);
assert.match(source('workspaces/spatial/SpatialWorkspace.tsx'),/openView\("spatial-scene", launchProfile\)/);
assert.match(source('workspaces/Workspaces.tsx'),/props\.launchProfile === 'planning'/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/openView\('local-device','planning'\)/);
assert.match(source('workspaces/missions/MissionPlannerWorkspace.tsx'),/if\(await live.select\(project.id\)\)openView/);
});
@@ -0,0 +1,18 @@
import {build} from 'esbuild';
import {mkdir,writeFile} from 'node:fs/promises';
import {fileURLToPath} from 'node:url';
import {resolve,join} from 'node:path';
const root=fileURLToPath(new URL('.',import.meta.url));
const destination=process.argv[2];
if(!destination)throw Error('Pass an artifact output directory; no server is started.');
const result=await build({entryPoints:[join(root,'main.tsx')],bundle:true,write:false,minify:true,
format:'iife',platform:'browser',jsx:'automatic',outfile:'preview.js',define:{'process.env.NODE_ENV':'"production"'},
alias:{'@nodedc/ui-react':resolve(root,'../../node_modules/@nodedc/ui-react/dist/index.js')},
nodePaths:[resolve(root,'../../node_modules')],logLevel:'warning'});
const js=result.outputFiles.find(f=>f.path.endsWith('.js')).text.replaceAll('</script','<\\/script');
const css=result.outputFiles.find(f=>f.path.endsWith('.css')).text.replaceAll('</style','<\\/style');
await mkdir(destination,{recursive:true});
const html=`<!doctype html><html lang="ru" data-nodedc-theme="dark"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; connect-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; font-src data:"><title>Mission Core · Профили управления · Прототип</title><style>${css}</style></head><body data-nodedc-ui><div id="root"></div><script>${js}</script></body></html>`;
await writeFile(join(destination,'rover-control-preview.html'),html);
console.log(JSON.stringify({artifact:join(destination,'rover-control-preview.html'),bytes:Buffer.byteLength(html),network:'disabled by CSP',hardware:'no adapter'}));
@@ -0,0 +1,69 @@
import {useState} from 'react';
import {createRoot} from 'react-dom/client';
import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react';
import '@nodedc/ui-core/styles.css';
import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile';
import {trace} from './trace';
import './preview.css';
const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0};
const percent=(n:number)=>`${Math.round(n*100)}%`;
const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'};
const storageKey='missioncore.rover-control-preview.v1';
function load():ControlProfile {
try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};}
catch{return {...defaultProfile};}
}
function App(){
const [profile,setProfile]=useState(load);
const [axes,setAxes]=useState<Axes>(emptyAxes);
const [open,setOpen]=useState(['settings','preview']);
const [saved,setSaved]=useState('');
const [light,setLight]=useState(false);
const change=(patch:Partial<ControlProfile>)=>{
setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved('');
};
const result=mix(profile,axes);
const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY';
const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX';
const field=(key:keyof Axes,label:string)=><RangeControl label={label} value={axes[key]} min={-1} max={1} step={.01} exactValueBounds={{min:-1,max:1}} formatValue={percent} onChange={value=>setAxes({...axes,[key]:value})}/>;
function save(){
try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');}
catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');}
}
function download(){
const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'}));
const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click();
setTimeout(()=>URL.revokeObjectURL(url),1000);
}
return <main data-nodedc-ui>
<header><div><p>MISSION CORE · ПРОТОТИП</p><h1>Настройки управления ровером</h1></div><Button onClick={()=>{setLight(!light);document.documentElement.dataset.nodedcTheme=light?'dark':'light';}}>Тема</Button></header>
<p className="notice">Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.</p>
<Inspector variant="panel" openSections={open} onOpenSectionsChange={setOpen} sections={[
{id:'settings',label:'Профиль управления',content:<SettingsCard title="Рычаги и отклик" description="Схема 1×1 / 2×2 и назначения моторов остаются отдельными настройками привода.">
<InspectorSelectField label="Управление" value={profile.mode} options={[{value:'tank',label:'Два рычага · Tank'},{value:'arcade',label:'Один рычаг · Arcade'}]} onChange={mode=>change({mode})}/>
{profile.mode==='arcade'&&<InspectorSelectField label="Управляющий рычаг" value={profile.stick} options={[{value:'right',label:'Правый'},{value:'left',label:'Левый'}]} onChange={stick=>change({stick})}/>}
<p>{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}</p>
<InspectorSelectField label="Отклик рычага" value={profile.response} options={[{value:'linear',label:'Линейный'},{value:'squared',label:'Плавный у центра'}]} onChange={response=>change({response})}/>
<RangeControl label="Зона нейтрали" value={profile.deadband} min={0} max={.5} step={.01} exactValueBounds={{min:0,max:.5}} formatValue={percent} onChange={deadband=>change({deadband})}/>
<RangeControl label="Масштаб команды" value={profile.outputScale} min={0} max={1} step={.01} exactValueBounds={{min:0,max:1}} formatValue={percent} onChange={outputScale=>change({outputScale})}/>
<p>Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.</p>
<div className="actions"><Button onClick={save}>Сохранить черновик</Button><Button onClick={download}>Скачать JSON</Button></div>
{saved&&<p role="status">{saved}</p>}
</SettingsCard>},
{id:'preview',label:'Проверка профиля на экране',content:<SettingsCard title="Команды сторонам" description="Нормализованная команда от −100% до +100%. Плюс — вперёд по корпусу, минус — назад.">
{field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')}
{field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
<div className="actions"><Button onClick={()=>setAxes(emptyAxes)}>Нейтраль</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:1,[x]:profile.mode==='tank'?1:0})}>Вперёд</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:-1,[x]:profile.mode==='tank'?-1:0})}>Назад</Button><Button onClick={()=>setAxes({...emptyAxes,[y]:profile.mode==='tank'?-1:0,[x]:profile.mode==='tank'?1:-1})}>Разворот влево</Button></div>
<div className="demands" aria-live="polite">{(['left','right'] as const).map(side=><div key={side}><span>{side==='left'?'Левая сторона':'Правая сторона'}</span><strong>{percent(result[side])}</strong><span>{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}</span></div>)}</div>
<p>Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.</p>
</SettingsCard>},
{id:'takeover',label:'Перехват пультом · проверка сценария',content:<SettingsCard title="Стоп → нейтраль → ручное управление" description="Расчётная модель. Существующая прошивка и проводка пока не обеспечивают этот сценарий при отказе Mini.">
<div className="trace">{trace(profile).map(({label,decision},i)=><div className="trace-row" key={label}><span>{i+1}. {label}</span><StatusBadge tone={decision.stopAll?'warning':'neutral'}>{states[decision.state]}</StatusBadge><span>{percent(decision.demand.left)} / {percent(decision.demand.right)}</span></div>)}</div>
<p>Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.</p>
</SettingsCard>},
]}/>
</main>;
}
document.documentElement.dataset.nodedcTheme='dark';
createRoot(document.getElementById('root')!).render(<App/>);
@@ -0,0 +1,16 @@
/* Domain composition only; all controls come from the Design Guideline. */
body { margin:0; background:var(--nodedc-canvas); color:var(--nodedc-text-primary); font-family:var(--nodedc-font-family); font-size:var(--nodedc-font-size-md); }
main { max-width:960px; margin:0 auto; padding:32px 24px 64px; }
main>header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; }
h1 { font-size:var(--nodedc-font-size-title); font-weight:600; margin:8px 0; }
p { font-size:var(--nodedc-font-size-sm); line-height:1.6; color:var(--nodedc-text-secondary); }
main>header p { font-size:var(--nodedc-font-size-xs); }
.notice { margin-bottom:24px; }
.actions { display:flex; flex-wrap:wrap; gap:12px; margin:16px 0; }
.demands { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin:24px 0; }
.demands>div { display:flex; flex-direction:column; gap:8px; }
.demands strong { font-size:var(--nodedc-font-size-lg); font-variant-numeric:tabular-nums; }
.demands span { font-size:var(--nodedc-font-size-sm); }
.trace { display:grid; gap:20px; }
.trace-row { display:grid; grid-template-columns:minmax(0,1fr) 250px 100px; align-items:center; gap:16px; font-size:var(--nodedc-font-size-sm); }
@media (max-width:720px) { main {padding:20px 12px 40px;} .trace-row {grid-template-columns:1fr;} }
@@ -0,0 +1,38 @@
import {AuthorityModel, type Decision} from '../../../../packages/rover-control/src/authority';
import {type ControlProfile} from '../../../../packages/rover-control/src/profile';
/** Synthetic event replay, with no wall clock, I/O, timers or hardware adapter. */
export function trace(profile: ControlProfile): {label: string; decision: Decision}[] {
const model = new AuthorityModel(profile, ['left', 'right'], {
maxAgeMs:100, neutralMs:200, maxCommandMs:100, monitoredAxes:['leftY','rightY','leftX','rightX'],
}, 'preview');
const rows: {label:string;decision:Decision}[] = [];
let now = -50, token = '';
function step(value=0, link: 'live'|'lost'='live', core=false, request=false) {
now+=50;
const axes = {leftY:0,rightY:0,leftX:0,rightX:0};
axes[profile.mode==='tank'?'leftY':profile.stick==='left'?'leftY':'rightY']=value;
return model.step({now, link:{state:link,at:now},
axes:Object.fromEntries(Object.entries(axes).map(([key,v])=>[key,{value:v,at:now,sequence:now}])),
drives:{left:{healthy:true,stopped:value===0,at:now},right:{healthy:true,stopped:value===0,at:now}},
requestCore:request?{owner:'autonomy',at:now,sequence:1}:undefined,
command:core?{token,at:now,expires:now+75,sequence:now,demand:{left:.4,right:.4}}:undefined,
});
}
const add=(label:string,decision:Decision)=>rows.push({label,decision});
for(let i=0;i<5;i++)step();
const grant=step(0,'live',false,true);token=grant.token??'';
add('Mission Core получил явный допуск',grant);
add('Автономная команда движения',step(0,'live',true));
add('Первое отклонение рычага',step(.8,'live',true));
for(let i=0;i<20;i++)step(.8);
add('Тот же рычаг удерживается',step(.8));
add('Остановка подтверждена, оба рычага отпущены',step());
for(let i=0;i<3;i++)step();
add('Непрерывная нейтраль подтверждена',step());
add('Второе отклонение — ручное движение',step(.8));
add('Запоздавшая команда прежней задачи',step(.8,'live',true));
add('Радиосвязь потеряна',step(0,'lost'));
add('Связь вернулась с отклонённым рычагом',step(.8));
return rows;
}
@@ -0,0 +1,5 @@
{
"extends":"../../tsconfig.app.json",
"compilerOptions":{"incremental":false,"tsBuildInfoFile":null},
"include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"]
}
@@ -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.

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