Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9d3329f608 | ||
|
|
399af98841 | ||
|
|
71a648fec8 | ||
|
|
dad11b47d7 | ||
|
|
e627500295 | ||
|
|
f01bd39037 | ||
|
|
a7c64e009d | ||
|
|
6bbbd5bf7d | ||
|
|
bc55901d4d | ||
|
|
6d772b29fe | ||
|
|
63cbb08ea0 | ||
|
|
53818230f9 | ||
|
|
45fb14b206 | ||
|
|
24bbaefb00 | ||
|
|
2e5d52521f | ||
|
|
c804d89b18 |
@@ -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
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
|
||||
Generated
+24
@@ -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",
|
||||
|
||||
@@ -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).");
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
|
||||
icon: "clipboard",
|
||||
kind: "lab-archive",
|
||||
internalOnly: true,
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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:/);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user