28 Commits
Author SHA1 Message Date
DCCONSTRUCTIONS fb33a6d45c fix(ui): label connectivity checks as Проверка состояния 2026-09-25 23:26:51 +03:00
DCCONSTRUCTIONS 0fb117fe7b docs: record worker recovery acceptance and deployment boundaries 2026-09-25 23:25:02 +03:00
DCCONSTRUCTIONS e2cf3078b6 fix(ui): unify connectivity labels and move contour refresh to header 2026-09-25 23:24:45 +03:00
DCCONSTRUCTIONS f3640026cf fix(telemetry): recover worker agent and Tailscale transport automatically 2026-09-25 23:24:27 +03:00
DCCONSTRUCTIONS bd5873ef34 fix(fleet): derive contour status from registered nodes and remove obsolete tabs 2026-09-25 22:28:42 +03:00
DCCONSTRUCTIONS aecae4a986 feat(fleet): edit apparatus name independently of onboard availability 2026-09-25 22:28:22 +03:00
DCCONSTRUCTIONS ee7576b7b4 refactor(fleet-ui): consolidate apparatus header and computer status 2026-09-25 21:58:57 +03:00
DCCONSTRUCTIONS e2b0d839eb feat(fleet-ui): organize apparatus equipment beneath its computer 2026-09-25 21:38:25 +03:00
DCCONSTRUCTIONS d2ec9e95b1 feat(fleet): replace onboard computer without replacing apparatus 2026-09-25 21:38:13 +03:00
DCCONSTRUCTIONS 8283f9ba85 feat(observation): expose offline sources with transparent viewport headers 2026-09-25 19:14:21 +03:00
DCCONSTRUCTIONS db3b1d7b67 fix(navigation): return repeated section selections to their entry view 2026-09-25 19:14:21 +03:00
DCCONSTRUCTIONS a993eda6b8 feat(rover): add live scene and material editing with environment migration 2026-09-25 19:13:52 +03:00
DCCONSTRUCTIONS 9d3329f608 docs: record rover integration closeout and verification 2026-09-25 16:49:52 +03:00
DCCONSTRUCTIONS 399af98841 merge: integrate final rover control with operator simulation workspace 2026-09-25 16:48:42 +03:00
DCCONSTRUCTIONS 71a648fec8 feat(fleet): preserve operator VESC integration before final driver merge 2026-09-25 16:40:46 +03:00
DCCONSTRUCTIONS dad11b47d7 docs(simulation): record polygon experiments and bounded route acceptance 2026-09-25 16:40:46 +03:00
DCCONSTRUCTIONS e627500295 feat(control-station): add AI polygon setup streaming and mission controls 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS f01bd39037 feat(simulation): add Worker AI polygon runtime and terrain navigation 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS a7c64e009d fix(core): keep launchd service logs outside protected Downloads 2026-09-25 16:40:45 +03:00
DCCONSTRUCTIONS 6bbbd5bf7d docs(rover): record calibration control acceptance and operating boundaries 2026-09-25 16:39:19 +03:00
DCCONSTRUCTIONS bc55901d4d fix(core): serialize planning reports outside the control event loop 2026-09-25 16:37:57 +03:00
DCCONSTRUCTIONS 6d772b29fe feat(control-station): add rover scene telemetry and guarded keyboard control 2026-09-25 16:37:57 +03:00
DCCONSTRUCTIONS 63cbb08ea0 feat(rover): add drive profiles and leased remote command channel 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 53818230f9 feat(node): ship managed environment startup and USB recovery 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 45fb14b206 feat(vesc): integrate native calibration diagnostics and configuration archives 2026-09-25 16:37:56 +03:00
DCCONSTRUCTIONS 24bbaefb00 chore(repo): keep private evidence local and version rover assets with LFS 2026-09-25 16:37:20 +03:00
DCCONSTRUCTIONS 2e5d52521f feat: finalize corrected-route planning and Rerun recording review 2026-09-22 10:10:03 +03:00
DCCONSTRUCTIONS c804d89b18 fix(lab): separate direct device launches from planning profiles 2026-09-21 09:19:14 +03:00
506 changed files with 61658 additions and 1661 deletions
+2
View File
@@ -10,3 +10,5 @@
*.las binary
*.lcc binary
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/public/rover-scene/*.glb filter=lfs diff=lfs merge=lfs -text
+7
View File
@@ -49,3 +49,10 @@ mqtt.summary.json
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
!tests/fixtures/**/*.pcap
!tests/fixtures/**/*.pcapng
# Dependency caches may be links into an existing local checkout.
.venv
node_modules
# Private experiment evidence and generated build archives.
/outputs/
+18
View File
@@ -62,6 +62,24 @@ and the boundary between Mission Core and vendor-specific integration code.
- Synthetic or explicitly redacted fixtures may be committed under
`tests/fixtures/`.
## Onboard environment ownership — owner requirement, 2026-09-24
- Every onboard OS change belongs to the shipped, versioned installer or the
application's environment/device preparation workflow from the first test.
The operator installs the build and configures it in the application; never
require hand-written service files, USB rules, permission fixes or commands.
- This applies to all Node features, including GUI/service autostart and USB
recovery, not just individual camera drivers. Read-only SSH inspection and
bounded artifact-owned build staging remain allowed.
- Detect the distribution, architecture and required capabilities. Maintain
explicit supported environment profiles; do not claim arbitrary Linux
compatibility from a qualified Ubuntu build. Preserve foreign configuration,
report unsupported features, make preparation repeatable, and ship rollback.
- Do not add USB administration controls or port resets to inventory refresh.
Automatic startup recovery may retry terminal enumeration failures only;
preserve enumerated devices and active companion ports. Device identity and
assignments must survive changes of USB port and tty number.
## Insta360 and clean-host installation — owner requirement, 2026-09-08
- The current X4 starting point is USB enumeration only. SDK installation,
+1
View File
@@ -0,0 +1 @@
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
+24
View File
@@ -7,6 +7,7 @@
"": {
"name": "@nodedc/mission-core-control-station",
"version": "0.1.0",
"hasInstallScript": true,
"dependencies": {
"@noble/hashes": "^2.2.0",
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
@@ -14,6 +15,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
@@ -898,6 +900,19 @@
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"link": true
},
"node_modules/@nvidia/ov-web-rtc": {
"version": "6.7.0",
"resolved": "https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/@nvidia/ov-web-rtc/-/@nvidia/ov-web-rtc-6.7.0.tgz",
"integrity": "sha512-Knt393yvuI1tFmQgJM7VuMlVKDh5GyuKq8fAIarNLyY7Ucxw+lSY/72ceWR+3HyCnNtL39J0DZ27dn9riqpGWw==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/@rerun-io/web-viewer": {
"version": "0.36.3",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
@@ -1614,6 +1629,15 @@
"node": ">=6.9.0"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+3
View File
@@ -5,6 +5,8 @@
"type": "module",
"scripts": {
"dev": "vite",
"postinstall": "node scripts/install-rerun-navigation.mjs",
"prebuild": "node scripts/install-rerun-navigation.mjs",
"build": "tsc -b && vite build",
"preview": "vite preview",
"test:unit": "node --test test/*.test.mjs",
@@ -17,6 +19,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
Binary file not shown.
File diff suppressed because it is too large Load Diff
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).");
+80 -16
View File
@@ -14,6 +14,7 @@ import {
Inspector,
StatusBadge,
TextField,
ToastStack,
UserProfileMenu,
Window,
WindowFooterActions,
@@ -49,6 +50,7 @@ import type {
} from "./core/observation/sessionArchive";
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
import {
@@ -69,6 +71,9 @@ import {
type SceneSettings,
} from "./sceneSettings";
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
import { PlanningConnectionWindow } from "./components/missions/PlanningConnectionWindow";
import { PlanningCaptureGuard } from "./components/missions/PlanningCaptureGuard";
import { workspaceLaunchProfile, type WorkspaceLaunchProfile } from "./core/observation/workspaceLaunch";
import { WorkspaceRenderer } from "./workspaces/Workspaces";
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
import "./styles/scene-windows.css";
@@ -147,12 +152,15 @@ export default function App() {
contentExpanded: true,
});
const [navigationRevision, setNavigationRevision] = useState(0);
const [activeRoot, setActiveRoot] = useState<RootId | null>(
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 [workspaceHeaderTitleHost, setWorkspaceHeaderTitleHost] = useState<HTMLSpanElement | null>(null);
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
const [replayTransitioning, setReplayTransitioning] = useState(false);
@@ -177,6 +185,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 +233,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 +322,7 @@ export default function App() {
const remote = runtime.state?.viewerSettings;
if (
!remote ||
replayActiveRef.current ||
workspaceLayoutProfile.profile ||
sceneSettingsCommitterRef.current?.isBusy()
) return;
@@ -328,7 +345,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 +416,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,13 +451,25 @@ 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);
};
// Explicit menu navigation always returns to that workspace's entry screen.
const openNavigationView = (viewId: string) => {
if (!workspaceById(viewId)) return;
setNavigationRevision(value => value + 1);
sessionOverview.close();
setSourceWindowOpen(false);
setDisplayWindowOpen(false);
setLayerInspectorOpen(false);
openView(viewId);
};
const openSource = () => {
if (!sceneWorkspaceActive) return;
setSourceDraft(sourceUrl);
@@ -459,14 +491,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 +516,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;
@@ -611,11 +658,8 @@ export default function App() {
return () => window.clearTimeout(timer);
}, [layoutSaveNotice]);
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
const contentActions = useApplicationPanelActions({
onAddVehicle,
definition: activeDefinition,
refreshRuntime: runtime.refresh,
resetConnectionScenario: runtime.resetConnectionScenario,
@@ -695,6 +739,7 @@ export default function App() {
<SystemNavigationPanel
title={currentRoot.title}
onAdd={computeContourSettings.openCreate}
onNavigateRoot={() => openNavigationView("modules")}
onClose={workspace.closeNavigation}
/>
) : currentRoot ? (
@@ -721,7 +766,7 @@ export default function App() {
icon: <Icon name={item.icon} />,
}))}
activeId={workspace.activeView ?? undefined}
onItemChange={openView}
onItemChange={openNavigationView}
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
@@ -731,7 +776,8 @@ export default function App() {
{rootWorkspaces.length}{" "}
{rootWorkspaces.length === 1
? "рабочая поверхность"
: "рабочих поверхностей"}
: rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4
? "рабочие поверхности" : "рабочих поверхностей"}
</span>
</>
}
@@ -742,7 +788,9 @@ export default function App() {
key={activeDefinition.id}
eyebrow={activeDefinition.eyebrow}
title={
activeDefinition.kind === "recordings"
activeDefinition.kind === "vehicles"
? <>{activeDefinition.title}<span className="fleet-header-title" ref={setWorkspaceHeaderTitleHost}/></>
: activeDefinition.kind === "recordings"
&& replayPresented
&& recordedReplayLabel
? `${activeDefinition.title}: ${recordedReplayLabel}`
@@ -788,16 +836,18 @@ export default function App() {
/>
) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
) : activeDefinition.kind === "vehicles" ? <div className="fleet-header-tools" ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
) : activeDefinition.kind === "observatory" ? (
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
) : activeDefinition.kind === "contour-health" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.root === "system" ? (
<SystemWorkspaceSelector
value={activeDefinition.id}
onChange={openView}
onChange={openNavigationView}
/>
) : (
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
@@ -805,19 +855,29 @@ export default function App() {
}
utilityActions={contentActions}
onClose={workspace.closeView}
>
<PlanningCaptureGuard key={`${activeDefinition.id}:${navigationRevision}`}
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
onResume={() => openView('local-device', 'planning')}
>
{activeDefinition.kind === "device" ? (
launchProfile === 'planning' ? <PlanningConnectionWindow>
<DeviceWorkspace
onOpenSpatialScene={() => openView("spatial-scene")}
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}
headerTitleHost={workspaceHeaderTitleHost}
state={activeRuntimeState}
backendStatus={runtime.backendStatus}
sourceUrl={effectiveSourceUrl}
@@ -858,6 +918,7 @@ export default function App() {
}}
/>
)}
</PlanningCaptureGuard>
</ApplicationPanel>
) : null}
/>
@@ -1020,8 +1081,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 (
@@ -0,0 +1,8 @@
import {createPortal} from 'react-dom';
import {Icon,IconButton} from '@nodedc/ui-react';
/** A nested workspace returns through the shared panel header. */
export function WorkspaceBackButton({label,onClick,host}:{label:string;onClick:()=>void;host?:HTMLElement|null}) {
const button=<IconButton label={label} onClick={onClick}><Icon name="chevron-left"/></IconButton>;
return host?createPortal(button,host):button;
}
@@ -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;
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");
}
},
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);
},
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,71 @@
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('./editor/RoverScene'));
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 [editing,setEditing]=useState(false);
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||editing||!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,editing,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
const showSettings=()=>{setEditing(false);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={editing?"Завершить редактирование сцены":"Редактировать сцену"} aria-pressed={editing} onClick={()=>{c.pauseInput();setHeld(new Set());setEditing(v=>!v);}}><Icon name="edit"/></IconButton><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 key={vehicleID} vehicleID={vehicleID} editing={editing&&active} 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={editing?'neutral':tone}>{editing?'Редактирование сцены':status}</StatusBadge></div>
{c.ready&&!editing&&<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>}
{!editing&&<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,23 @@
import {LutField} from './LutField';
import {useRef} from 'react';
import {Button,ColorField,ControlRow,Inspector,InspectorSelectField,RangeControl,Switch,TextField} from '@nodedc/ui-react';
import {environmentFields,fieldValue,fieldDisabled,type SceneDocument,type SceneField} from '../../../core/roverScene/sceneDocument';
const labels:Record<string,string>={'Камера':'Камера',Lighting:'Освещение',Rendering:'Рендеринг',Grid:'Сетка',SSAO:'Контактное затенение · SSAO',Bloom:'Свечение · Bloom','Chromatic Aberration':'Хроматическая аберрация',TAA:'Временное сглаживание · TAA',Grading:'Цветокоррекция',LUT:'Цветовая таблица · LUT',Vignette:'Виньетка','Environment Atlas':'Карта окружения','Skybox':'Небо','Directional Light':'Направленный свет','Shadow Catcher':'Тень на поверхности'};
export function EnvironmentInspector({document,onChange,onResetCamera,onError}:{document:SceneDocument;onChange:(key:string,value:unknown)=>void;onResetCamera:()=>void;onError:(error:string)=>void}){
const groups=[...new Set(environmentFields.map(f=>f.group))];
return <Inspector defaultOpen={['Камера','PostProcess/Lighting']} sections={groups.map(group=>({id:group,label:labels[group.split('/').at(-1)!]??group,content:<div className="rover-scene-fields">{environmentFields.filter(f=>f.group===group).map(f=><EnvironmentField key={f.key} field={f} value={fieldValue(document,f.key)} disabled={fieldDisabled(document,f)} onChange={v=>onChange(f.key,v)} onResetCamera={onResetCamera} onError={onError}/>)}</div>}))}/>;
}
function EnvironmentField({field:f,value,disabled,onChange,onResetCamera,onError}:{field:SceneField;value:unknown;disabled:boolean;onChange:(v:unknown)=>void;onResetCamera:()=>void;onError:(error:string)=>void}){
if(f.type==='texture')return <LutField value={value as string|null} onChange={onChange} onError={onError}/>;
if(f.type==='action')return <Button onClick={onResetCamera}>Сбросить камеру</Button>;
if(f.type==='toggle')return <Switch label={f.label} checked={!!value} disabled={disabled} onChange={onChange}/>;
if(f.type==='color')return <ControlRow label={f.label}><ColorField label={f.label} value={String(value)} disabled={disabled} onChange={onChange}/></ControlRow>;
if(f.type==='select')return <InspectorSelectField label={f.label} value={String(value)} options={f.options??[]} disabled={disabled} onChange={onChange}/>;
if(f.type==='doubleSlider'){const range=value as number[];return <>{['Начало тумана, м','Конец тумана, м'].map((label,i)=><RangeControl key={i} label={label} min={f.min??0} max={f.max??100} step={f.step??1} exactValueBounds={{min:f.min,max:f.max}} value={range[i]} disabled={disabled} onChange={v=>onChange(i?[range[0],v]:[v,range[1]])}/>)}</>;}
if(f.type==='number')return <TextField type="number" label={f.label} min={f.min} max={f.max} step={f.step??.01} value={Number(value)} disabled={disabled} onChange={e=>onChange(Number(e.target.value))}/>;
return <RangeControl label={f.label} value={Number(value)} min={f.min??0} max={f.max??100} step={f.step??.01} exactValueBounds={{min:f.min,max:f.max}} disabled={disabled} onChange={onChange}/>;
}
export function SceneFileActions({onImport,onExport,onReset}:{onImport:(file:File)=>void;onExport:()=>void;onReset:()=>void}){
const input=useRef<HTMLInputElement>(null);
return <div className="rover-scene-actions"><Button size="compact" onClick={onExport}>Экспорт JSON</Button><Button size="compact" onClick={()=>input.current?.click()}>Импорт JSON</Button><Button size="compact" onClick={onReset}>Окружение NodeDC</Button><input ref={input} hidden type="file" accept=".json,application/json" onChange={e=>{const f=e.currentTarget.files?.[0];if(f)onImport(f);e.currentTarget.value='';}}/></div>;
}
@@ -0,0 +1,7 @@
import {useRef,useState} from 'react';
import {Button,Icon,IconButton} from '@nodedc/ui-react';
import {textureFromFile} from '../../../core/roverScene/sceneStorage';
export function LutField({value,onChange,onError}:{value:string|null|undefined;onChange:(value:string|null)=>void;onError:(error:string)=>void}){
const input=useRef<HTMLInputElement>(null),[loading,setLoading]=useState(false);
return <div className="rover-scene-fields"><p className="rover-scene-note">PNG 256 × 16 · горизонтальная таблица 16³</p><div className="rover-scene-actions"><Button loading={loading} onClick={()=>input.current?.click()}>{value?'Заменить LUT':'Загрузить LUT'}</Button>{value&&<IconButton label="Удалить LUT" onClick={()=>onChange(null)}><Icon name="trash"/></IconButton>}</div><input hidden ref={input} type="file" accept="image/png" onChange={async e=>{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setLoading(true);try{const bitmap=await createImageBitmap(file);const valid=bitmap.width===256&&bitmap.height===16;bitmap.close();if(!valid||file.type!=='image/png')throw new Error('Нужна PNG-таблица LUT размером 256 × 16.');onChange((await textureFromFile(file)).data);}catch(error){onError(error instanceof Error?error.message:'Не удалось прочитать LUT.');}finally{setLoading(false);}}}/></div>;
}
@@ -0,0 +1,17 @@
import {useRef,useState} from 'react';
import {Button,ColorField,ControlRow,Icon,IconButton,Inspector,RangeControl,Switch,TextField} from '@nodedc/ui-react';
import {textureSlots,type TextureSlot,type MaterialSpec} from '../../../core/roverScene/sceneDocument';
import {textureFromFile} from '../../../core/roverScene/sceneStorage';
const slotLabels:Record<TextureSlot,string>={diffuse:'Базовый цвет',normal:'Нормали',roughness:'Шероховатость',metalness:'Металличность',opacity:'Прозрачность',emissive:'Свечение',ao:'Окклюзия · AO'};
export function MaterialInspector({material,onChange,onReset,onError}:{material:MaterialSpec;onChange:(patch:Partial<MaterialSpec>)=>void;onReset:()=>void;onError:(message:string)=>void}){
const range=(key:keyof MaterialSpec,label:string,max=1)=><RangeControl label={label} value={Number(material[key])} min={0} max={max} step={.01} exactValueBounds={{min:0,max}} onChange={v=>onChange({[key]:v})}/>;
return <><TextField label="Название материала" value={material.name} maxLength={120} onChange={e=>onChange({name:e.target.value})}/><p className="rover-scene-note">Изменения применяются ко всем деталям с этим материалом.</p><Inspector defaultOpen={['surface']} sections={[
{id:'surface',label:'Поверхность',content:<div className="rover-scene-fields"><ControlRow label="Базовый цвет"><ColorField label="Базовый цвет" value={material.color} onChange={color=>onChange({color})}/></ControlRow>{range('metalness','Металличность')}{range('roughness','Шероховатость')}{range('reflectivity','Отражение')}{range('opacity','Непрозрачность')}<Switch label="Двусторонняя поверхность" checked={material.doubleSided} onChange={doubleSided=>onChange({doubleSided})}/></div>},
{id:'coat',label:'Лак и свечение',content:<div className="rover-scene-fields">{range('clearCoat','Слой лака')}{range('clearCoatRoughness','Шероховатость лака')}<ControlRow label="Цвет свечения"><ColorField label="Цвет свечения" value={material.emissive} onChange={emissive=>onChange({emissive})}/></ControlRow>{range('emissiveIntensity','Сила свечения',10)}</div>},
{id:'textures',label:'Текстуры',description:'PNG / JPEG / WebP · до 4096 px',content:<div className="rover-scene-fields">{textureSlots.map(slot=><TextureField key={slot} slot={slot} value={material.textures[slot]} onChange={texture=>onChange({textures:{...material.textures,[slot]:texture}})} onError={onError}/>)}{range('normalStrength','Сила нормалей',2)}<RangeControl label="Повторение текстуры" min={.01} max={20} step={.01} exactValueBounds={{min:.01,max:20}} value={material.tiling} onChange={tiling=>onChange({tiling})}/></div>},
]}/><Button onClick={onReset}>Исходный материал</Button></>;
}
function TextureField({slot,value,onChange,onError}:{slot:TextureSlot;value:MaterialSpec['textures'][TextureSlot];onChange:(value:MaterialSpec['textures'][TextureSlot])=>void;onError:(message:string)=>void}){
const input=useRef<HTMLInputElement>(null),[pending,setPending]=useState(false);
return <div className="rover-scene-texture"><ControlRow label={slotLabels[slot]} layout="stack"><div className="rover-scene-texture__row">{value&&<img src={value.data} alt=""/>}<Button size="compact" loading={pending} onClick={()=>input.current?.click()}>{value?.name??'Загрузить текстуру'}</Button>{value&&<IconButton label={`Удалить текстуру: ${slotLabels[slot]}`} onClick={()=>onChange(undefined)}><Icon name="trash"/></IconButton>}</div></ControlRow><input hidden ref={input} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setPending(true);try{onChange(await textureFromFile(file));}catch(error){onError(error instanceof Error?error.message:'Не удалось загрузить текстуру.');}finally{setPending(false);}}}/></div>;
}
@@ -0,0 +1,38 @@
import {useCallback,useEffect,useState,type CSSProperties} from 'react';
import {Button,GlassSurface,Icon,IconButton,LoadingRegion,StatusBadge,ToastStack,Window} from '@nodedc/ui-react';
import {assignMaterial,createMaterial,changeField,defaultScene,textureBytes,type MaterialSpec} from '../../../core/roverScene/sceneDocument';
import {useSceneDocument} from '../../../core/roverScene/useSceneDocument';
import PlayCanvasViewer from '../playcanvas/PlayCanvasViewer';
import {EnvironmentInspector,SceneFileActions} from './EnvironmentInspector';
import {MaterialInspector} from './MaterialInspector';
import './sceneEditor.css';
export default function RoverScene({vehicleID,editing,onModelState}:{vehicleID:string;editing:boolean;onModelState:(state:'loading'|'ready'|'error')=>void}){
const scene=useSceneDocument(vehicleID),[palette,setPalette]=useState(false),[environment,setEnvironment]=useState(false),[selected,setSelected]=useState<string|null>(null),[materialOpen,setMaterialOpen]=useState(false),[cameraReset,setCameraReset]=useState(0),[textureState,setTextureState]=useState<'loading'|'ready'|'error'>('ready');
const [selectedGroup,setSelectedGroup]=useState<string|null>(null);
const pick=useCallback((id:string,group?:string)=>{setSelected(id);if(group)setSelectedGroup(group);setMaterialOpen(true);},[]);
const addMaterial=()=>{if(Object.keys(scene.document.materials).length>=128){scene.setError('В сцене может быть не больше 128 материалов.');return;}const id=`custom/${crypto.randomUUID()}`;scene.update(doc=>createMaterial(doc,id));pick(id);};
const patchMaterial=(id:string,patch:Partial<MaterialSpec>)=>{const next={...scene.document,materials:{...scene.document.materials,[id]:{...scene.document.materials[id],...patch}}};if(textureBytes(next)>28_000_000){scene.setError('Суммарный объём текстур превышает 20 МБ. Удалите ненужную текстуру.');return;}scene.update(doc=>({...doc,materials:{...doc.materials,[id]:{...doc.materials[id],...patch}}}));};
useEffect(()=>{if(textureState==='error')scene.setError('Не удалось применить текстуру к сцене. Проверьте файл и загрузите его заново.');},[textureState,scene.setError]);
const saved=scene.saveState==='saved',status=textureState==='loading'?'Загрузка текстур':scene.saveState==='loading'?'Загрузка настроек сцены':scene.saveState==='saving'?'Сохранение сцены':scene.saveState==='error'?'Изменения не сохранены':'Сохранено на этом компьютере';
const material=selected?scene.document.materials[selected]:null;
return <>
<LoadingRegion className="rover-view__scene" style={{bottom:editing&&palette?150:0}} loading={!scene.ready} label="Загрузка настроек сцены">
{scene.ready&&<PlayCanvasViewer modelUrl="/rover-scene/dcd006-v020.glb?materials-v2" postFx={scene.document.postFx} cameraAutoRotate={scene.document.cameraAutoRotate} cameraZoomStrength={scene.document.cameraZoomStrength} cameraResetToken={cameraReset} materials={scene.document.materials} assignments={scene.document.assignments} editing={editing} onMaterialPick={pick} onModelState={onModelState} onTextureState={setTextureState}/>}
</LoadingRegion>
{editing&&<>
<div className="rover-scene-tools"><GlassSurface className="rover-scene-tools__surface"><IconButton label="Материалы сцены" aria-pressed={palette} onClick={()=>setPalette(v=>!v)}><Icon name="layers"/></IconButton><IconButton label="Окружение сцены" aria-pressed={environment} onClick={()=>setEnvironment(v=>!v)}><Icon name="globe"/></IconButton></GlassSurface></div>
<div className="rover-scene-save"><StatusBadge tone={saved&&textureState!=='loading'?'neutral':'warning'}>{status}</StatusBadge></div>
{palette&&<GlassSurface className="rover-material-palette"><div className="rover-material-palette__header"><span>Материалы · выберите в палитре или на модели</span><IconButton label="Скрыть палитру материалов" onClick={()=>setPalette(false)}><Icon name="close"/></IconButton></div><div className="rover-material-palette__items">{Object.entries(scene.document.materials).map(([id,m])=><Button key={id} variant={selected===id?'primary':'secondary'} tone="neutral" aria-pressed={selected===id} title={m.name} onClick={()=>pick(id)}><span className="rover-material-tile"><span aria-hidden="true" className="rover-material-swatch" style={{'--material-color':m.color,'--material-gloss':1-m.roughness} as CSSProperties}/><span>{m.name}</span></span></Button>)}<Button aria-label="Добавить материал" title="Добавить материал" onClick={addMaterial}><span className="rover-material-tile"><Icon name="plus"/><span>Новый материал</span></span></Button></div></GlassSurface>}
</>}
<Window open={editing&&environment} title="Окружение сцены" placement="end" draggable onClose={()=>setEnvironment(false)} footer={<SceneFileActions onImport={scene.importDocument} onExport={scene.exportDocument} onReset={()=>scene.update(doc=>({...doc,postFx:defaultScene().postFx}))}/>}>
<EnvironmentInspector onError={scene.setError} document={scene.document} onChange={(key,value)=>scene.update(doc=>changeField(doc,key,value))} onResetCamera={()=>setCameraReset(v=>v+1)}/>
</Window>
<Window open={editing&&materialOpen&&!!material} title={material?.name||'Материал'} subtitle="Материал сцены" placement="end" draggable style={{position:'fixed',left:'var(--nodedc-space-4)',top:'6rem',right:'auto'}} onClose={()=>setMaterialOpen(false)}>
{material&&selected&&<>
{selectedGroup?<div className="rover-scene-fields"><p className="rover-scene-note">Выбранная группа: {defaultScene().materials[selectedGroup]?.name}</p><Button disabled={(scene.document.assignments[selectedGroup]??selectedGroup)===selected} onClick={()=>scene.update(doc=>assignMaterial(doc,selectedGroup,selected))}>Назначить выбранной группе</Button></div>:<p className="rover-scene-note">Нажмите на деталь в сцене, затем выберите материал в палитре, чтобы назначить его группе.</p>}
<MaterialInspector key={selected} material={material} onChange={patch=>patchMaterial(selected,patch)} onReset={()=>{const base=defaultScene().materials[selected]??createMaterial(defaultScene(),selected).materials[selected];if(base)patchMaterial(selected,{...base,name:material.name});}} onError={scene.setError}/>
</>}
</Window>
<ToastStack items={scene.error?[{id:'rover-scene-error',tone:'error',title:'Настройки сцены',description:scene.error}]:[]} onDismiss={()=>scene.setError('')}/>
</>;
}
@@ -0,0 +1,17 @@
.rover-scene-tools {position:absolute;right:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3));}
.rover-scene-tools__surface,.rover-scene-actions {display:flex;align-items:center;gap:var(--nodedc-space-2);flex-wrap:wrap;}
.rover-scene-tools__surface {padding:var(--nodedc-space-1);}
.rover-scene-save {position:absolute;left:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + 3rem);pointer-events:none;}
.rover-material-palette {position:absolute;bottom:var(--nodedc-space-3);left:var(--nodedc-space-3);right:var(--nodedc-space-3);padding:var(--nodedc-space-2);}
.rover-material-palette__header {display:flex;align-items:center;justify-content:space-between;gap:var(--nodedc-space-2);font-size:var(--nodedc-font-size-xs);color:var(--nodedc-text-secondary);}
.rover-material-palette__items {display:flex;gap:var(--nodedc-space-2);overflow:auto;}
.rover-material-palette__items > * {flex:0 0 auto;}
.rover-material-tile {display:grid;justify-items:center;gap:var(--nodedc-space-1);width:100px;white-space:normal;font-size:var(--nodedc-font-size-xs);}
/* Domain material thumbnail: a swatch, not a replacement control. */
.rover-material-swatch {display:block;width:38px;height:38px;border-radius:50%;background:radial-gradient(ellipse at 35% 25%,#ffffffb0,transparent 45%),radial-gradient(ellipse at 60% 75%,transparent,var(--material-color) 70%),var(--material-color);box-shadow:inset -5px -5px 12px #0008;}
.rover-scene-fields {display:flex;flex-direction:column;gap:var(--nodedc-space-3);}
.rover-scene-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);}
.rover-scene-texture__row {display:flex;gap:var(--nodedc-space-2);align-items:center;min-width:0;}
.rover-scene-texture__row img {width:36px;height:36px;object-fit:cover;}
.rover-scene-texture__row .nodedc-button {min-width:0;overflow:hidden;}
@container(max-width:460px){.rover-scene-save{top:calc(var(--observation-overlay-header-space, 0px) + 4.5rem)}.rover-material-tile{width:82px}.rover-material-palette__header>span{max-width:210px}}
@@ -0,0 +1,23 @@
{
"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",
"Scene editing uses canonical modeless Design Guideline inspectors; all 109 donor camera/post-FX fields retained as a versioned field catalog",
"Original NodeDC environment preset restored by owner request; editor reset changes environment only",
"Semantic material groups, UV texture editing, GPU surface picking and bounded texture disposal",
"Live grid step/diameter and ambient fill added; wireframe application and LUT loading corrected",
"Responsive scene framing and palette space without drive authority"
],
"inspector_source_sha256": "8f1df16bd8c12fb1b8481480690ff4ed0e6b2645cb3fba527888c0f9acb59192",
"model_revision": "dcd006-v020-materials-v2"
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,2 @@
// Shared serializable contract; donor defaults remain available.
export * from '../../../core/roverScene/postFx';
@@ -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,17 @@
import {useEffect,type RefObject} from 'react';
import * as pc from 'playcanvas';
export function useLutTexture(appRef:RefObject<pc.Application|null>,frameRef:RefObject<any>,textureRef:RefObject<pc.Texture|null>,url:string|null|undefined,ready:boolean,onError?:()=>void){
useEffect(()=>{
const app=appRef.current;let cancelled=false;
const clear=()=>{if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=null;frameRef.current.update?.();}textureRef.current?.destroy();textureRef.current=null;};
clear();if(!app||!ready||!url)return;
const img=new Image();img.onload=()=>{
if(cancelled)return;
if(img.width!==256||img.height!==16){onError?.();return;}
const texture=new pc.Texture(app.graphicsDevice,{name:'Rover color LUT',format:pc.PIXELFORMAT_SRGBA8,mipmaps:false,minFilter:pc.FILTER_LINEAR,magFilter:pc.FILTER_LINEAR,addressU:pc.ADDRESS_CLAMP_TO_EDGE,addressV:pc.ADDRESS_CLAMP_TO_EDGE});
texture.setSource(img);textureRef.current=texture;
if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=texture;frameRef.current.update?.();}
};img.onerror=()=>{if(!cancelled)onError?.();};img.src=url;
return()=>{cancelled=true;clear();};
},[appRef,frameRef,textureRef,url,ready,onError]);
}
@@ -0,0 +1,83 @@
import {useEffect,useRef,type RefObject} from 'react';
import * as pc from 'playcanvas';
import type {MaterialSpec,TextureSlot} from '../../../core/roverScene/sceneDocument';
export type SceneMaterialsProps={materials?:Record<string,MaterialSpec>;assignments?:Record<string,string>;editing?:boolean;onMaterialPick?:(id:string,group:string)=>void;onTextureState?:(state:'loading'|'ready'|'error')=>void};
const maps:Record<TextureSlot,string>={diffuse:'diffuseMap',normal:'normalMap',metalness:'metalnessMap',roughness:'glossMap',opacity:'opacityMap',emissive:'emissiveMap',ao:'aoMap'};
export function useSceneMaterials(appRef:RefObject<pc.Application|null>,cameraRef:RefObject<pc.Entity|null>,canvasRef:RefObject<HTMLCanvasElement|null>,modelVersion:number,{materials,assignments,editing,onMaterialPick,onTextureState}:SceneMaterialsProps){
const latest=useRef({materials,onMaterialPick,onTextureState});latest.current={materials,onMaterialPick,onTextureState};
const textures=useRef(new Map<string,{texture?:pc.Texture;promise:Promise<pc.Texture>}>());
const generation=useRef(0);
const originalGroups=useRef(new WeakMap<pc.MeshInstance,{id:string;material:pc.StandardMaterial}>());
const customMaterials=useRef(new Map<string,pc.StandardMaterial>());
useEffect(()=>{
const app=appRef.current;if(!app||!modelVersion||!materials)return;
const current=++generation.current;let pending=0,failed=false;
const map=new Map<string,pc.StandardMaterial>();
for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){
if(!(mi.material instanceof pc.StandardMaterial))continue;
if(!originalGroups.current.has(mi))originalGroups.current.set(mi,{id:mi.material.name,material:mi.material});
const source=originalGroups.current.get(mi)!;map.set(source.id,source.material);
}
for(const id of Object.keys(materials))if(id.startsWith('custom/')){
if(!customMaterials.current.has(id)){const m=new pc.StandardMaterial();m.name=id;customMaterials.current.set(id,m);}
map.set(id,customMaterials.current.get(id)!);
}
for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){
const source=originalGroups.current.get(mi);if(source)mi.material=map.get(assignments?.[source.id]??source.id)??source.material;
}
for(const [id,m] of customMaterials.current)if(!Object.hasOwn(materials,id)){m.destroy();customMaterials.current.delete(id);}
const applyTexture=(m:pc.StandardMaterial,slot:TextureSlot,texture:pc.Texture|null,spec:MaterialSpec)=>{
(m as any)[maps[slot]]=texture;
const prefix=maps[slot];(m as any)[prefix+'Tiling']=new pc.Vec2(spec.tiling,spec.tiling);
if(['metalness','roughness','opacity','ao'].includes(slot))(m as any)[prefix+'Channel']='r';
m.update();
};
const used=new Set<string>();
for(const [id,spec] of Object.entries(materials)){
const m=map.get(id);if(!m)continue;
m.diffuse.fromString(spec.color);m.useMetalness=true;m.metalness=spec.metalness;
m.glossInvert=true;m.gloss=spec.roughness;m.opacity=spec.opacity;
// Specular factor is independent from the donor's global IBL multiplier.
m.specularityFactor=spec.reflectivity;m.clearCoat=spec.clearCoat;
m.clearCoatGlossInvert=true;m.clearCoatGloss=spec.clearCoatRoughness;
m.emissive.fromString(spec.emissive);m.emissiveIntensity=spec.emissiveIntensity;
m.bumpiness=spec.normalStrength;m.cull=spec.doubleSided?pc.CULLFACE_NONE:pc.CULLFACE_BACK;
m.blendType=spec.opacity<1||spec.textures.opacity?pc.BLEND_NORMAL:pc.BLEND_NONE;
m.depthWrite=m.blendType===pc.BLEND_NONE;
for(const slot of Object.keys(maps) as TextureSlot[]){
const data=spec.textures[slot]?.data;if(!data){applyTexture(m,slot,null,spec);continue;}
const srgb=slot==='diffuse'||slot==='emissive';const key=`${srgb}:${data}`;used.add(key);
let item=textures.current.get(key);
if(!item){
const promise=new Promise<pc.Texture>((resolve,reject)=>{const img=new Image();img.onload=()=>{if(generation.current<0){reject(new Error('Scene disposed'));return;}const tex=new pc.Texture(app.graphicsDevice,{name:spec.textures[slot]?.name,format:srgb?pc.PIXELFORMAT_SRGBA8:pc.PIXELFORMAT_RGBA8,mipmaps:true,addressU:pc.ADDRESS_REPEAT,addressV:pc.ADDRESS_REPEAT});tex.setSource(img);resolve(tex);};img.onerror=()=>reject(new Error('Texture decode failed'));img.src=data;});
item={promise};textures.current.set(key,item);const cached=item;
promise.then(tex=>{cached.texture=tex;if(textures.current.get(key)!==cached){tex.destroy();}}).catch(()=>{if(textures.current.get(key)===cached)textures.current.delete(key);});
}
if(item.texture)applyTexture(m,slot,item.texture,spec);
else{
pending++;item.promise.then(tex=>{if(generation.current===current)applyTexture(m,slot,tex,spec);}).catch(()=>{failed=true;}).finally(()=>{pending--;if(generation.current===current&&!pending)latest.current.onTextureState?.(failed?'error':'ready');});
}
}
m.update();
}
for(const [key,item] of textures.current)if(!used.has(key)){item.texture?.destroy();textures.current.delete(key);}
onTextureState?.(pending?'loading':'ready');
return()=>{generation.current++;};
},[materials,assignments,modelVersion,appRef,onTextureState]);
useEffect(()=>()=>{generation.current=-1;for(const item of textures.current.values())item.texture?.destroy();textures.current.clear();for(const m of customMaterials.current.values())m.destroy();customMaterials.current.clear();},[]);
useEffect(()=>{
const app=appRef.current,canvas=canvasRef.current,camera=cameraRef.current;
if(!editing||!app||!canvas||!camera?.camera||!modelVersion)return;
const picker=new pc.Picker(app,1,1);let start:{x:number;y:number}|null=null;
const down=(e:PointerEvent)=>{start=e.button===0?{x:e.clientX,y:e.clientY}:null;};
const up=(e:PointerEvent)=>{
const p=start;start=null;if(!p||Math.hypot(p.x-e.clientX,p.y-e.clientY)>4)return;
const rect=canvas.getBoundingClientRect();picker.resize(Math.max(1,Math.floor(rect.width)),Math.max(1,Math.floor(rect.height)));
picker.prepare(camera.camera!,app.scene);
const hit=picker.getSelection(e.clientX-rect.left,e.clientY-rect.top).find(mi=>!!latest.current.materials?.[mi.material.name]);
if(hit instanceof pc.MeshInstance){const source=originalGroups.current.get(hit);if(source)latest.current.onMaterialPick?.(hit.material.name,source.id);}
};
canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointerup',up);
return()=>{canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointerup',up);picker.destroy();};
},[editing,modelVersion,appRef,cameraRef,canvasRef]);
}
@@ -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:calc(var(--observation-overlay-header-space, 0px) + 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:has(.rover-view__controls) .rover-view__authority { top:calc(var(--observation-overlay-header-space, 0px) + 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 };
}
@@ -9,12 +9,14 @@ import { useComputeContours } from "../../core/system/ComputeContourContext";
interface SystemNavigationPanelProps {
title: string;
onAdd: () => void;
onNavigateRoot: () => void;
onClose: () => void;
}
export function SystemNavigationPanel({
title,
onAdd,
onNavigateRoot,
onClose,
}: SystemNavigationPanelProps) {
const { contours, selectedContour, selectContour } = useComputeContours();
@@ -40,7 +42,7 @@ export function SystemNavigationPanel({
description: contour.expected_node_id,
icon: <Icon name="apps" />,
active: selectedContour?.contour_id === contour.contour_id,
onSelect: () => selectContour(contour.contour_id),
onSelect: () => { selectContour(contour.contour_id); onNavigateRoot(); },
}))}
items={[]}
onItemChange={() => undefined}
@@ -5,7 +5,6 @@ import type { WorkspaceDefinition } from "../productModel";
interface ApplicationPanelActionsOptions {
definition: WorkspaceDefinition | null;
onAddVehicle?: () => void;
refreshRuntime: () => void;
resetConnectionScenario?: () => Promise<boolean>;
connectionScenarioResetting: boolean;
@@ -45,7 +44,6 @@ export function deviceRuntimeUtilityAction({
export function useApplicationPanelActions({
definition,
onAddVehicle,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
@@ -56,7 +54,6 @@ export function useApplicationPanelActions({
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
return useMemo(() => {
const actions: ApplicationPanelUtilityAction[] = [];
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
if (definition?.kind === "device") {
actions.push(deviceRuntimeUtilityAction({
refreshRuntime,
@@ -81,7 +78,6 @@ export function useApplicationPanelActions({
return actions;
}, [
definition,
onAddVehicle,
refreshRuntime,
resetConnectionScenario,
connectionScenarioResetting,
@@ -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;
}
@@ -1,12 +1,13 @@
export interface ObservationLayout {
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
catalogVisible?:string[]; splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
}
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
export function decodeObservationLayout(value:unknown):ObservationLayout {
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
if(source.catalogVisible!==undefined)result.catalogVisible=ids(source.catalogVisible);
result.order=ids(source.order);result.hidden=ids(source.hidden);
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
@@ -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();},
@@ -57,5 +57,8 @@ export function useFleet() {
events.onerror = unavailable;
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
}, []);
return { items, error, refresh };
const acceptVehicle = useCallback((vehicle: Vehicle) => {
setItems(current => current?.map(item => item.id === vehicle.id ? vehicle : item) ?? null);
}, []);
return { items, error, refresh, acceptVehicle };
}
@@ -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)};
}
@@ -37,5 +37,5 @@ export function useSessionOverviewMode(enabled: boolean) {
window.addEventListener("keydown", close, true);
return () => window.removeEventListener("keydown", close, true);
}, [open]);
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
return { open: enabled && open, close: useCallback(() => setOpen(false), []), toggle: useCallback(() => setOpen(v => !v), []) };
}
@@ -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-модулей изменилась.");
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,399 @@
export type PostFxSettings = {
lighting: {
ambientIntensity: number
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: {
step: number
diameter: number
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: {
ambientIntensity: 0,
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: {
step: 1,
diameter: 1000,
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,85 @@
import {mergePostFxDefaults,type PostFxSettings} from './postFx';
import donorFields from './environmentFields.json';
export type SceneField={key:string;type:string;label:string;group:string;default?:unknown;min?:number;max?:number;step?:number;options?:{label:string;value:string}[];disabledIf?:string};
export const environmentFields:SceneField[]=[...donorFields,
{key:'postFx.lut.textureUrl',type:'texture',label:'Таблица LUT',group:'PostProcess/LUT'},
{key:'postFx.lighting.ambientIntensity',type:'sliderNumber',label:'Заполняющий свет',group:'PostProcess/Lighting',min:0,max:1,step:.01},
{key:'postFx.grid.step',type:'sliderNumber',label:'Шаг основной сетки, м',group:'PostProcess/Grid',min:.01,max:10,step:.01},
{key:'postFx.grid.diameter',type:'sliderNumber',label:'Диаметр сетки, м',group:'PostProcess/Grid',min:1,max:1000,step:1},
];
export const textureSlots=['diffuse','normal','metalness','roughness','opacity','emissive','ao'] as const;
export type TextureSlot=typeof textureSlots[number];
export type MaterialSpec={name:string;color:string;metalness:number;roughness:number;opacity:number;reflectivity:number;clearCoat:number;clearCoatRoughness:number;emissive:string;emissiveIntensity:number;normalStrength:number;doubleSided:boolean;tiling:number;textures:Partial<Record<TextureSlot,{name:string;data:string}>>};
export type SceneDocument={schema:'missioncore.rover-scene/v1';model:'dcd006-v020-materials-v2';environmentVersion:2;postFx:PostFxSettings;cameraZoomStrength:number;cameraAutoRotate:boolean;materials:Record<string,MaterialSpec>;assignments:Record<string,string>};
const material=(name:string,color:string,metalness:number,roughness:number):MaterialSpec=>({name,color,metalness,roughness,opacity:1,reflectivity:1,clearCoat:0,clearCoatRoughness:.3,emissive:'#000000',emissiveIntensity:0,normalStrength:1,doubleSided:false,tiling:1,textures:{}});
export function defaultScene():SceneDocument{
const postFx=mergePostFxDefaults();
return {schema:'missioncore.rover-scene/v1',model:'dcd006-v020-materials-v2',environmentVersion:2,postFx,cameraZoomStrength:.04,cameraAutoRotate:false,assignments:{},materials:{
'rover/body':material('Корпус и подвеска','#2c2f31',.12,.53),
'rover/rubber':material('Резина гусениц и катков','#202328',0,.72),
'rover/track-steel':material('Нержавеющие проставки','#c4c8cd',1,.17),
'rover/box':material('Бортовой ящик','#34373a',.08,.4),
'rover/molle':material('MOLLE панель','#52575b',.3,.44),
'rover/motor':material('Алюминий моторов','#95999c',.68,.38),
'rover/hardware':material('Чёрный крепёж','#24272b',.45,.38),
'rover/polymer':material('Пластик и кожухи','#24292d',0,.44),
'rover/drive':material('Ведущие звёзды','#81652f',0,.46),
}};
}
export function createMaterial(doc:SceneDocument,id:string):SceneDocument{
if(!/^custom\/[a-zA-Z0-9-]{1,80}$/.test(id)||Object.hasOwn(doc.materials,id)||Object.keys(doc.materials).length>=128)return doc;
const used=new Set(Object.values(doc.materials).map(m=>m.name));let n=1;while(used.has(`Материал ${n}`))n++;
return {...doc,materials:{...doc.materials,[id]:material(`Материал ${n}`,'#808080',0,.5)}};
}
export function assignMaterial(doc:SceneDocument,group:string,id:string):SceneDocument{
if(!Object.hasOwn(defaultScene().materials,group)||!Object.hasOwn(doc.materials,id))return doc;
return {...doc,assignments:{...doc.assignments,[group]:id}};
}
export function fieldValue(doc:SceneDocument,key:string):unknown{return key.split('.').reduce<unknown>((v,k)=>v&&typeof v==='object'?(v as Record<string,unknown>)[k]:undefined,doc);}
export function fieldDisabled(doc:SceneDocument,f:SceneField):boolean{
const rule=f.disabledIf;if(!rule)return false;
if(rule.startsWith('!'))return !fieldValue(doc,rule.slice(1));
const atlas=doc.postFx.envAtlas.enabled,sky=doc.postFx.skybox.enabled;
return ({ssaoDisabled:doc.postFx.ssao.type==='none',fogDisabled:doc.postFx.rendering.fog==='none',envAtlasBlocked:sky,skyboxBlocked:atlas,envAtlasFieldsDisabled:sky||!atlas,skyboxFieldsDisabled:atlas||!sky,gridDotsDisabled:!doc.postFx.grid.dotsEnabled,gridCrossDisabled:!doc.postFx.grid.crossEnabled} as Record<string,boolean>)[rule]??false;
}
export function changeField(doc:SceneDocument,key:string,value:unknown):SceneDocument{
const f=environmentFields.find(x=>x.key===key);if(!f||f.type==='action')return doc;
if(f.type==='texture'&&value!==null&&(typeof value!=='string'||value.length>1_000_000||!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(value)))return doc;
const old=fieldValue(doc,key);
if(typeof old==='number'){
value=Number(value);if(!Number.isFinite(value))return doc;
if(f.min!==undefined)value=Math.max(f.min,value as number);if(f.max!==undefined)value=Math.min(f.max,value as number);
}else if(typeof old==='boolean'){if(typeof value!=='boolean')return doc;}
else if(f.type==='color'){if(typeof value!=='string'||!/^#[0-9a-f]{6}$/i.test(value))return doc;}
if(f.options&&!f.options.some(o=>o.value===String(value)))return doc;
if(f.type==='doubleSlider'){
if(!Array.isArray(value)||value.length!==2||!value.every(Number.isFinite))return doc;
value=[Math.max(0,Math.min(100,value[0])),Math.max(0,Math.min(100,value[1]))].sort((a,b)=>a-b);
}
const result={...doc,postFx:structuredClone(doc.postFx)};const parts=key.split('.');const tail=parts.pop()!;
const target=parts.reduce((v,k)=>v[k],result as any);target[tail]=value;
if(key==='postFx.rendering.fogRange'){[result.postFx.rendering.fogStart,result.postFx.rendering.fogEnd]=value as [number,number];}
if(key==='postFx.envAtlas.enabled'&&value)result.postFx.skybox.enabled=false;
if(key==='postFx.skybox.enabled'&&value)result.postFx.envAtlas.enabled=false;
return result;
}
export const materialRanges:Record<string,[number,number]>={metalness:[0,1],roughness:[0,1],opacity:[0,1],reflectivity:[0,1],clearCoat:[0,1],clearCoatRoughness:[0,1],emissiveIntensity:[0,10],normalStrength:[0,2],tiling:[.01,20]};
export function textureBytes(doc:SceneDocument):number{return Object.values(doc.materials).reduce((sum,m)=>sum+Object.values(m.textures).reduce((n,t)=>n+(t?.data.length??0),0),0);}
export function parseScene(raw:unknown):SceneDocument{
const r=raw as SceneDocument;if(!r||r.schema!=='missioncore.rover-scene/v1'||r.model!=='dcd006-v020-materials-v2')throw new Error('Файл сцены не подходит к этой модели.');
let doc=defaultScene();
for(const f of environmentFields){const v=fieldValue(r,f.key);if(v!==undefined)doc=changeField(doc,f.key,v);}
if(Object.keys(r.materials??{}).length>128)throw new Error('В сцене может быть не больше 128 материалов.');
for(const id of Object.keys(r.materials??{}))if(id.startsWith('custom/'))doc=createMaterial(doc,id);
for(const [id,base] of Object.entries(doc.materials)){
const m=r.materials?.[id];if(!m)continue;
if(typeof m.name==='string')base.name=m.name.trim().slice(0,120)||base.name;
for(const key of ['color','emissive'] as const)if(/^#[0-9a-f]{6}$/i.test(m[key]))base[key]=m[key];
for(const [key,[lo,hi]] of Object.entries(materialRanges)){const v=m[key as keyof MaterialSpec];if(typeof v==='number'&&Number.isFinite(v))(base as any)[key]=Math.max(lo,Math.min(hi,v));}
if(typeof m.doubleSided==='boolean')base.doubleSided=m.doubleSided;
for(const slot of textureSlots){const t=m.textures?.[slot];if(!t)continue;if(typeof t.name!=='string'||typeof t.data!=='string'||t.data.length>12_000_000||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(t.data))throw new Error('Некорректная текстура в файле сцены.');base.textures[slot]={name:t.name.slice(0,200),data:t.data};}
}
for(const [group,id] of Object.entries(r.assignments??{}))if(typeof id==='string')doc=assignMaterial(doc,group,id);
if(textureBytes(doc)>28_000_000)throw new Error('Суммарный объём текстур превышает 20 МБ.');
return doc;
}
@@ -0,0 +1,21 @@
import {defaultScene,parseScene,type SceneDocument} from './sceneDocument';
// Fingerprint of the first editor's withdrawn neutral preset. Match the entire
// environment: independently edited environments must never be reset on load.
function withdrawnEnvironment(){
const fx=defaultScene().postFx;
Object.assign(fx.lighting,{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22});
Object.assign(fx.envAtlas,{reflectionIntensity:1,brightness:1.2});
Object.assign(fx.rendering,{backgroundColor:'#202227',fog:'none',toneMapping:4});
Object.assign(fx.grid,{colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12});
fx.chromaticAberration.enabled=false;fx.vignette.enabled=false;fx.grading.enabled=false;fx.bloom.enabled=false;
Object.assign(fx.directionalLight,{intensity:1.8,azimuth:135,elevation:45});
Object.assign(fx.shadowCatcher,{size:4,lightIntensity:.5,shadowIntensity:.25});
return fx;
}
export function loadStoredScene(raw:unknown):{document:SceneDocument;updated:boolean;restoredEnvironment:boolean}{
const doc=parseScene(raw);
const updated=(raw as Partial<SceneDocument>).environmentVersion!==2;
const restoredEnvironment=updated&&JSON.stringify(doc.postFx)===JSON.stringify(withdrawnEnvironment());
return {document:restoredEnvironment?{...doc,postFx:defaultScene().postFx}:doc,updated,restoredEnvironment};
}
@@ -0,0 +1,23 @@
import type {SceneDocument} from './sceneDocument';
import {loadStoredScene} from './sceneMigration';
const key=(vehicle:string)=>`${vehicle}:dcd006-v020-materials-v2`;
function database():Promise<IDBDatabase>{return new Promise((resolve,reject)=>{const r=indexedDB.open('missioncore-rover-scenes',1);r.onupgradeneeded=()=>r.result.createObjectStore('scenes');r.onerror=()=>reject(r.error);r.onblocked=()=>reject(new Error('Хранилище сцены занято другим окном.'));r.onsuccess=()=>resolve(r.result);});}
export async function readScene(vehicle:string):Promise<SceneDocument|null>{
const db=await database();try{
const raw=await new Promise<unknown>((resolve,reject)=>{const r=db.transaction('scenes').objectStore('scenes').get(key(vehicle));r.onerror=()=>reject(r.error);r.onsuccess=()=>resolve(r.result);});
if(!raw)return null;
const result=loadStoredScene(raw);
if(result.updated)await new Promise<void>((resolve,reject)=>{
const tx=db.transaction('scenes','readwrite'),store=tx.objectStore('scenes');
if(result.restoredEnvironment)store.put(raw,`${key(vehicle)}:before-environment-v2`);
store.put(result.document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);
});
return result.document;
}finally{db.close();}
}
export async function saveScene(vehicle:string,document:SceneDocument):Promise<void>{const db=await database();try{await new Promise<void>((resolve,reject)=>{const tx=db.transaction('scenes','readwrite');tx.objectStore('scenes').put(document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);});}finally{db.close();}}
export async function textureFromFile(file:File):Promise<{name:string;data:string}>{
if(!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>8_000_000)throw new Error('Выберите PNG, JPEG или WebP до 8 МБ.');
const bitmap=await createImageBitmap(file);try{if(bitmap.width>4096||bitmap.height>4096)throw new Error('Максимальный размер текстуры — 4096 × 4096.');}finally{bitmap.close();}
return new Promise((resolve,reject)=>{const r=new FileReader();r.onerror=()=>reject(r.error);r.onload=()=>resolve({name:file.name,data:String(r.result)});r.readAsDataURL(file);});
}
@@ -0,0 +1,22 @@
import {useCallback,useEffect,useRef,useState} from 'react';
import {defaultScene,parseScene,type SceneDocument} from './sceneDocument';
import {readScene,saveScene} from './sceneStorage';
let writes=Promise.resolve();
const enqueue=(vehicle:string,doc:SceneDocument)=>{const next=writes.catch(()=>{}).then(()=>saveScene(vehicle,doc));writes=next;return next;};
export function useSceneDocument(vehicle:string){
const [document,setDocument]=useState(defaultScene),[ready,setReady]=useState(false),[revision,setRevision]=useState(0);
const [saveState,setSaveState]=useState<'loading'|'saved'|'saving'|'error'>('loading'),[error,setError]=useState('');
const latest=useRef({document,revision,vehicle});latest.current={document,revision,vehicle};
const savedRevision=useRef(0);
useEffect(()=>{let cancelled=false;readScene(vehicle).then(value=>{if(!cancelled){setDocument(value??defaultScene());setSaveState('saved');}}).catch(()=>{if(!cancelled){setError('Не удалось прочитать сохранённую сцену. Изменения можно экспортировать в JSON.');setSaveState('error');}}).finally(()=>{if(!cancelled)setReady(true);});return()=>{cancelled=true;};},[vehicle]);
const update=useCallback((fn:(doc:SceneDocument)=>SceneDocument)=>{setDocument(d=>fn(d));setRevision(v=>v+1);setSaveState('saving');},[]);
useEffect(()=>{
if(!ready||revision===0)return;
let cancelled=false;const timer=setTimeout(()=>{enqueue(vehicle,document).then(()=>{savedRevision.current=revision;if(!cancelled)setSaveState('saved');}).catch(()=>{if(!cancelled){setSaveState('error');setError('Не удалось сохранить сцену на этом компьютере. Экспортируйте JSON, чтобы сохранить изменения.');}});},400);
return()=>{cancelled=true;clearTimeout(timer);};
},[ready,revision,vehicle,document]);
useEffect(()=>()=>{const v=latest.current;if(v.revision>savedRevision.current)void enqueue(v.vehicle,v.document).catch(()=>{});},[]);
const importDocument=async(file:File)=>{try{if(file.size>32_000_000)throw new Error('Файл сцены больше 32 МБ.');const next=parseScene(JSON.parse(await file.text()));update(()=>next);}catch(e){setError(e instanceof Error?e.message:'Не удалось загрузить сцену.');}};
const exportDocument=()=>{const blob=new Blob([JSON.stringify(document,null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=globalThis.document.createElement('a');a.href=url;a.download='rover-scene.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
return {document,ready,saveState,error,setError,update,importDocument,exportDocument};
}
@@ -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 });
}
@@ -0,0 +1,70 @@
import {fetchComputeContours,type ComputeContour} from './computeContours';
import {fetchWorkerTelemetry,type WorkerTelemetry} from './workerTelemetry';
import type {Vehicle} from '../fleet/useFleet';
import {workerConnectionStatus} from './workerConnectionStatus';
export interface CoreHealth {
ok:boolean; service:string; version:string;
plugin_runtimes:{ready:number;total:number};
components?:{recording_cache?:{status?:string}};
}
export interface ContourSnapshot {
core:CoreHealth|null; latency:number|null;
workers:{contour:ComputeContour;telemetry:WorkerTelemetry|null}[]|null;
vehicles:Vehicle[]|null; errors:string[]; observedAt:number;
}
async function json(url:string,signal:AbortSignal):Promise<unknown>{
const response=await fetch(url,{signal,cache:'no-store',headers:{Accept:'application/json'}});
if(!response.ok)throw new Error(`HTTP ${response.status}`);
return response.json();
}
export async function readContourSnapshot(signal:AbortSignal):Promise<ContourSnapshot>{
const bounded=AbortSignal.any([signal,AbortSignal.timeout(8000)]);
const errors:string[]=[];
const [coreResult,workersResult,fleetResult]=await Promise.allSettled([
(async()=>{
const start=performance.now();
const data=await json('/api/health',bounded) as CoreHealth;
if(!data||typeof data.ok!=='boolean'||data.service!=='mission-core-control-plane'||typeof data.version!=='string'||!Number.isFinite(data.plugin_runtimes?.ready)||!Number.isFinite(data.plugin_runtimes?.total))throw new Error('Invalid health');
return {data,latency:performance.now()-start};
})(),
(async()=>{
const contours=await fetchComputeContours(bounded);
return Promise.all(contours.map(async contour=>{
try{return {contour,telemetry:await fetchWorkerTelemetry(contour.contour_id,bounded)};}
catch{errors.push(`Не удалось проверить вычислитель «${contour.display_name}».`);return {contour,telemetry:null};}
}));
})(),
(async()=>{
const data=await json('/api/v1/fleet',bounded) as {items:Vehicle[]};
if(!data||!Array.isArray(data.items)||data.items.some(item=>!item||typeof item.id!=='string'||typeof item.name!=='string'||!['online','offline'].includes(item.connectivity)))throw new Error('Invalid fleet');
return data.items;
})(),
]);
if(coreResult.status==='rejected')errors.push('Не удалось проверить Mission Core.');
if(workersResult.status==='rejected')errors.push('Не удалось получить список вычислителей.');
if(fleetResult.status==='rejected')errors.push('Не удалось получить бортовые компьютеры.');
return {
core:coreResult.status==='fulfilled'?coreResult.value.data:null,
latency:coreResult.status==='fulfilled'?coreResult.value.latency:null,
workers:workersResult.status==='fulfilled'?workersResult.value:null,
vehicles:fleetResult.status==='fulfilled'?fleetResult.value:null,
errors,observedAt:Date.now(),
};
}
export function workerAvailable(telemetry:WorkerTelemetry|null):boolean{
return Boolean(telemetry?.connection.reachable&&telemetry.connection.identity_matches&&telemetry.node);
}
export function boardAvailable(vehicle:Vehicle):boolean{
return vehicle.enrollment==='paired'&&vehicle.connectivity==='online';
}
export function contourSummary(snapshot:ContourSnapshot|null){
if(!snapshot)return {tone:'warning' as const,label:'Проверка состояния',online:null,total:null};
const total=snapshot.workers!==null&&snapshot.vehicles!==null?1+snapshot.workers.length+snapshot.vehicles.length:null;
const online=Number(snapshot.core!==null)+(snapshot.workers??[]).filter(item=>workerAvailable(item.telemetry)).length+(snapshot.vehicles??[]).filter(boardAvailable).length;
if(snapshot.errors.length)return {tone:'warning' as const,label:'Проверка неполная',online,total};
if(!snapshot.core)return {tone:'danger' as const,label:'Mission Core не в сети',online,total};
if(!snapshot.core.ok)return {tone:'warning' as const,label:'Mission Core работает с ограничениями',online,total};
if(snapshot.workers?.some(item=>workerConnectionStatus(item.telemetry).state==='unknown'))return {tone:'warning' as const,label:'Состояние части узлов не подтверждено',online,total};
return {tone:online===total?'success' as const:'warning' as const,label:online===total?'Все узлы в сети':'Часть узлов не в сети',online,total};
}
@@ -0,0 +1,13 @@
import type {WorkerTelemetry} from './workerTelemetry';
// Receiver failure is not evidence that the remote host has shut down.
export function workerConnectionStatus(telemetry: WorkerTelemetry | null) {
const connection = telemetry?.connection;
if (!connection) return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Состояние вычислителя пока не подтверждено.'} as const;
if (connection.reachable && !connection.identity_matches) return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Ответивший узел не совпадает с выбранным вычислителем.'} as const;
if (connection.reachable && connection.identity_matches && telemetry.node) return {state:'online', tone:'success', label:'В сети', detail:null} as const;
if (['telemetry-receiver-unavailable','telemetry-agent-unavailable','invalid-telemetry-response'].includes(connection.error_code ?? '')) {
return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Mission Core не получает данные через службу телеметрии. Состояние самого вычислителя не подтверждено.'} as const;
}
return {state:'unknown', tone:'warning', label:'Не в сети', detail:'Последние данные не подтверждают текущее состояние вычислителя.'} as const;
}
+2 -45
View File
@@ -155,6 +155,7 @@ export const workspaces: WorkspaceDefinition[] = [
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
icon: "clipboard",
kind: "lab-archive",
internalOnly: true,
groups: [],
},
{
@@ -185,7 +186,7 @@ export const workspaces: WorkspaceDefinition[] = [
label: "Состояние контура",
title: "Состояние контура",
eyebrow: "ПАРК / СОСТОЯНИЕ",
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
description: "Связь с Mission Core, вычислителями и бортовыми компьютерами аппаратов.",
icon: "shield",
kind: "contour-health",
groups: [],
@@ -212,50 +213,6 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "device",
groups: [],
},
{
id: "payload",
root: "fleet",
label: "Сенсоры и каналы",
title: "Сенсоры и каналы",
eyebrow: "ПАРК / ПОЛЕЗНАЯ НАГРУЗКА",
description: "Полезная нагрузка аппарата и каналы данных, которые она публикует.",
icon: "grid",
kind: "catalog",
groups: [
{
title: "Пространственные сенсоры",
description: "Единый контракт для разных источников.",
capabilities: [
active("Облако точек", "Доказанный декодированный поток точек текущего устройства."),
active("Поза и траектория", "Доказанный поток позы и накопление пути."),
contract("Камеры", "Изображение, видео, глубина, внутренние и внешние параметры."),
contract("Навигация", "IMU, GNSS/RTK, одометрия и система координат."),
],
},
],
},
{
id: "configurations",
root: "fleet",
label: "Компоновки",
title: "Компоновки борта",
eyebrow: "ПАРК / КОМПОНОВКИ",
description: "Версионируемые сочетания сенсоров, вычислителей, транспорта и питания.",
icon: "settings",
kind: "catalog",
groups: [
{
title: "Шаблон компоновки",
description: "Повторяемая конфигурация для одинаковых аппаратов.",
capabilities: [
contract("Состав модулей", "Сенсоры, адаптеры, обработчики и зависимости."),
contract("Калибровки", "Системы координат, преобразования, внутренние и внешние параметры."),
contract("Профиль сети", "Локальная сеть, ячеистая сеть, LTE и серверный маршрут."),
later("Развёртывание на борт", "Пакет конфигурации и контролируемое обновление."),
],
},
],
},
{
id: "spatial-scene",
root: "polygon",
+1
View File
@@ -11,6 +11,7 @@
@import "./styles/l34-annotation.css";
@import "./styles/laboratory-reporting.css";
@import "./styles/simulation.css";
@import "./styles/ai-polygon.css";
@import "./styles/laboratory-evidence-report.css";
@import "./styles/e34-temporal-layer.css";
@import "./styles/m4-replay-threat.css";
@@ -0,0 +1,29 @@
.simulation-profiles { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; height: 100%; min-height: 0; }
.simulation-profiles > .nodedc-select-anchor { justify-self: start; }
.ai-polygon { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; min-width: 0; min-height: 0; overflow: hidden; }
.ai-polygon__content { min-height: 0; overflow: auto; overscroll-behavior: contain; padding-bottom: 1rem; }
.ai-polygon__head, .ai-polygon__row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.ai-polygon__actions, .ai-polygon__facts { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.ai-polygon__telemetry-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.ai-polygon__telemetry-row > .ai-polygon__facts { flex: 1; min-width: 0; }
.ai-polygon__telemetry-row > :last-child { flex-shrink: 0; }
.ai-polygon__section, .ai-polygon__run, .ai-polygon__form { display: grid; gap: .75rem; }
.ai-polygon h2, .ai-polygon h3, .ai-polygon p { margin: 0; }
.ai-polygon h2 { font-size: var(--nodedc-font-size-title); }
.ai-polygon h3, .ai-polygon strong { font-size: var(--nodedc-font-size-md); }
.ai-polygon p, .ai-polygon span, .ai-polygon a { font-size: var(--nodedc-font-size-sm); }
.ai-polygon p { color: var(--nodedc-text-muted); line-height: 1.5; }
.ai-polygon__setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .75rem; }
.ai-polygon__title { display: flex; align-items: center; gap: 1rem; }
.ai-polygon__stage { display: flex; flex-direction: column; gap: .75rem; min-width: 0; }
.ai-polygon__viewer { display: flex; flex-direction: column; min-height: 24rem; height: min(65vh, 48rem); overflow: hidden; border-radius: var(--nodedc-radius-panel); background: var(--nodedc-canvas); }
.ai-polygon__viewer-toolbar { display: flex; justify-content: space-between; align-items: flex-start; padding: .5rem; gap: .5rem; }
.ai-polygon__viewport { flex: 1; position: relative; min-height: 0; overflow: hidden; }
.ai-polygon__viewport[hidden] { display: none; }
.ai-polygon__viewport .nodedc-loading-region { height: 100%; }
.ai-polygon__video { width: 100%; height: 100%; pointer-events: none; }
.ai-polygon__video video { display: block; width: 100%; height: 100%; object-fit: contain; }
.ai-polygon__preview-error { position: absolute; inset: 0; display: grid; place-content: center; gap: .75rem; background: var(--nodedc-canvas); }
.ai-polygon__stage:fullscreen { height: 100%; box-sizing: border-box; padding: .75rem; overflow: auto; background: var(--nodedc-canvas); }
.ai-polygon__stage:fullscreen .ai-polygon__viewer { flex: 1; height: auto; min-height: 12rem; }
@@ -2173,6 +2173,7 @@
.contour-health-summary {
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
@@ -2203,7 +2204,7 @@
.contour-health-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
gap: 0.45rem;
}
@@ -2235,7 +2236,7 @@
.contour-node-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
gap: 0.65rem;
}
@@ -2251,6 +2252,7 @@
.contour-node header {
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
@@ -2262,7 +2264,7 @@
.contour-node dl {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
margin: 0;
border-radius: 0.75rem;
background: rgb(255 255 255 / 0.022);
@@ -2277,11 +2279,10 @@
.contour-node dd {
margin: 0;
overflow: hidden;
overflow-wrap: anywhere;
color: var(--nodedc-text-secondary);
font-size: 0.62rem;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--nodedc-font-size-sm);
white-space: normal;
}
.contour-process-list {
@@ -1,41 +1,10 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
import type { MissionRuntimeState } from "../core/runtime/contracts";
import {
fetchPolygonWorkerStatus,
type PolygonWorkerStatus,
} from "../core/polygon/liveWorker";
interface ControlPlaneHealth {
ok: boolean;
status: string;
service: string;
version: string;
plugin_runtimes: {
ready: number;
total: number;
};
}
interface PluginRuntimeHealth {
runtime_instance_id: string;
plugin_id: string;
plugin_version: string;
status: string;
observed_at: string;
detail_code: string | null;
}
interface ContourSnapshot {
controlPlane: ControlPlaneHealth | null;
pluginRuntimes: PluginRuntimeHealth[];
simulationWorker: PolygonWorkerStatus | null;
controlPlaneLatencyMs: number | null;
simulationGatewayLatencyMs: number | null;
observedAt: Date;
error: string | null;
}
import {useEffect,useState} from 'react';
import {createPortal} from 'react-dom';
import {workerConnectionStatus} from '../core/system/workerConnectionStatus';
import {IconButton,Icon,StatusBadge} from '@nodedc/ui-react';
import type {MissionRuntimeState} from '../core/runtime/contracts';
import {boardAvailable,contourSummary,readContourSnapshot,type ContourSnapshot} from '../core/system/contourHealth';
import {startSequentialPolling} from '../core/system/sequentialPolling';
export function contourRuntimeAuthorityPresentation(
state: MissionRuntimeState | null,
@@ -56,310 +25,64 @@ export function contourRuntimeAuthorityPresentation(
};
}
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
if (!value || typeof value !== "object") return false;
const item = value as Partial<ControlPlaneHealth>;
return typeof item.ok === "boolean"
&& typeof item.status === "string"
&& typeof item.service === "string"
&& typeof item.version === "string"
&& Boolean(item.plugin_runtimes)
&& typeof item.plugin_runtimes?.ready === "number"
&& typeof item.plugin_runtimes?.total === "number";
function observed(value:number|string|null|undefined){
if(value===null||value===undefined)return '—';
const date=new Date(value);
return Number.isFinite(date.getTime())?date.toLocaleString('ru-RU'):'—';
}
function validPluginRuntime(value: unknown): value is PluginRuntimeHealth {
if (!value || typeof value !== "object") return false;
const item = value as Partial<PluginRuntimeHealth>;
return typeof item.runtime_instance_id === "string"
&& typeof item.plugin_id === "string"
&& typeof item.plugin_version === "string"
&& typeof item.status === "string"
&& typeof item.observed_at === "string";
}
async function timedJson(
url: string,
signal: AbortSignal,
): Promise<{ payload: unknown; latencyMs: number }> {
const started = performance.now();
const response = await fetch(url, {
method: "GET",
headers: { Accept: "application/json" },
signal,
});
const latencyMs = performance.now() - started;
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return { payload: await response.json(), latencyMs };
}
function formatLatency(value: number | null): string {
if (value === null) return "—";
return `${Math.max(1, Math.round(value))} мс`;
}
function formatObservedAt(value: Date | string | null): string {
if (!value) return "—";
const date = value instanceof Date ? value : new Date(value);
if (!Number.isFinite(date.getTime())) return "—";
return new Intl.DateTimeFormat("ru-RU", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}).format(date);
}
export function ContourHealthWorkspace({
state,
}: {
state: MissionRuntimeState | null;
}) {
const [snapshot, setSnapshot] = useState<ContourSnapshot | null>(null);
const [loading, setLoading] = useState(false);
const [generation, setGeneration] = useState(0);
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
useEffect(() => {
const controller = new AbortController();
export function ContourHealthWorkspace({state,headerToolsHost}:{state:MissionRuntimeState|null;headerToolsHost?:HTMLElement|null}){
const [snapshot,setSnapshot]=useState<ContourSnapshot|null>(null);
const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0);
useEffect(()=>{
const controller=new AbortController();
const stop=startSequentialPolling(async()=>{
setLoading(true);
void Promise.allSettled([
timedJson("/api/health", controller.signal),
timedJson("/api/v1/device-plugin-runtimes", controller.signal),
(async () => {
const started = performance.now();
const worker = await fetchPolygonWorkerStatus({ signal: controller.signal });
return { worker, latencyMs: performance.now() - started };
})(),
]).then(([healthResult, runtimesResult, workerResult]) => {
if (controller.signal.aborted) return;
const healthPayload = healthResult.status === "fulfilled"
? healthResult.value.payload
: null;
const runtimesPayload = runtimesResult.status === "fulfilled"
? runtimesResult.value.payload
: null;
const runtimeItems = runtimesPayload
&& typeof runtimesPayload === "object"
&& Array.isArray((runtimesPayload as { items?: unknown }).items)
? (runtimesPayload as { items: unknown[] }).items.filter(validPluginRuntime)
: [];
const failures = [healthResult, runtimesResult, workerResult]
.filter((result) => result.status === "rejected").length;
setSnapshot({
controlPlane: validControlPlaneHealth(healthPayload) ? healthPayload : null,
pluginRuntimes: runtimeItems,
simulationWorker: workerResult.status === "fulfilled"
? workerResult.value.worker
: null,
controlPlaneLatencyMs: healthResult.status === "fulfilled"
? healthResult.value.latencyMs
: null,
simulationGatewayLatencyMs: workerResult.status === "fulfilled"
? workerResult.value.latencyMs
: null,
observedAt: new Date(),
error: failures === 0
? null
: `Не ответили ${failures} из 3 диагностических контрактов.`,
});
}).finally(() => {
if (!controller.signal.aborted) setLoading(false);
});
return () => controller.abort();
}, [generation]);
useEffect(() => {
const timer = window.setInterval(refresh, 5_000);
return () => window.clearInterval(timer);
}, [refresh]);
const {
aiActive,
deviceControlConnectivity,
controlledDevice,
} = contourRuntimeAuthorityPresentation(state);
const simulationWorker = snapshot?.simulationWorker ?? null;
const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok);
const runtimeReady = snapshot?.pluginRuntimes.filter(
(runtime) => runtime.status === "ready",
).length ?? 0;
const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2;
const readyProcessCount = runtimeReady
+ (controlPlaneReady ? 1 : 0)
+ (simulationWorker?.available ? 1 : 0);
const nodeStatus = useMemo(() => {
if (!snapshot) return "Проверяем";
if (controlPlaneReady && snapshot.error === null) return "Контур отвечает";
if (controlPlaneReady) return "Частично доступен";
return "Нет связи";
}, [controlPlaneReady, snapshot]);
return (
<div className="contour-health-dashboard">
try{const next=await readContourSnapshot(controller.signal);if(!controller.signal.aborted)setSnapshot(next);}
finally{if(!controller.signal.aborted)setLoading(false);}
},5000);
return ()=>{stop();controller.abort();};
},[generation]);
const summary=contourSummary(snapshot);
const core=snapshot?.core;
const {controlledDevice,deviceControlConnectivity}=contourRuntimeAuthorityPresentation(state);
const refresh=<IconButton label={loading?"Проверка состояния":"Обновить состояние контура"} disabled={loading} onClick={()=>setGeneration(value=>value+1)}><Icon name="refresh" size={16}/></IconButton>;
return <div className="contour-health-dashboard">
{headerToolsHost?createPortal(refresh,headerToolsHost):refresh}
<section className="contour-health-summary">
<div>
<span className="section-eyebrow">ЖИВОЙ ДИАГНОСТИЧЕСКИЙ СРЕЗ</span>
<h2>Локальный вычислительный контур</h2>
<p>
Статусы читаются из Control Plane, plugin runtime и шлюза Simulation Worker.
Пустые значения не подменяются демонстрационными числами.
</p>
</div>
<div className="contour-health-summary__status">
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
{nodeStatus}
</StatusBadge>
<Button
size="compact"
variant="secondary"
disabled={loading}
onClick={refresh}
>
<Icon name="refresh" size={14} />
{loading ? "Проверяем" : "Обновить"}
</Button>
</div>
<div><h2>Узлы и связь</h2><p>Mission Core, зарегистрированные вычислители и бортовые компьютеры аппаратов.</p></div>
<div className="contour-health-summary__status"><StatusBadge tone={loading?'warning':summary.tone}>{loading?'Проверка состояния':summary.label}</StatusBadge></div>
</section>
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
<div>
<span>Узлы</span>
<strong>{snapshot ? 2 : "—"}</strong>
<small>локальный + внешний worker</small>
</div>
<div>
<span>Процессы готовы</span>
<strong>{snapshot ? `${readyProcessCount} / ${processCount}` : "—"}</strong>
<small>по живым health-контрактам</small>
</div>
<div>
<span>Локальный API</span>
<strong>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</strong>
<small>браузер → Control Plane</small>
</div>
<div>
<span>Последняя проверка</span>
<strong>{formatObservedAt(snapshot?.observedAt ?? null)}</strong>
<small>автообновление каждые 5 секунд</small>
</div>
<div><span>Узлы в сети</span><strong>{summary.online??'—'} / {summary.total??'—'}</strong><small>Core, вычислители и бортовые ПК</small></div>
<div><span>Вычислители</span><strong>{snapshot?.workers?.length??'—'}</strong><small>из настроек системы</small></div>
<div><span>Бортовые компьютеры</span><strong>{snapshot?.vehicles?.length??'—'}</strong><small>из реестра аппаратов</small></div>
<div><span>Последняя проверка</span><strong>{observed(snapshot?.observedAt)}</strong><small>обновление каждые 5 секунд</small></div>
</section>
<section className="contour-node-grid" aria-label="Вычислительные узлы">
<article className="contour-node" data-state={controlPlaneReady ? "online" : "offline"}>
<header>
<div>
<span className="section-eyebrow">УЗЕЛ 01 · ЛОКАЛЬНЫЙ</span>
<h3>Mission Core Control Plane</h3>
</div>
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
{controlPlaneReady ? "Доступен" : "Недоступен"}
</StatusBadge>
</header>
<dl>
<div><dt>Версия</dt><dd>{snapshot?.controlPlane?.version ?? "—"}</dd></div>
<div><dt>API latency</dt><dd>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</dd></div>
<div><dt>Plugin runtimes</dt><dd>{snapshot ? `${runtimeReady} / ${snapshot.pluginRuntimes.length}` : "—"}</dd></div>
<div><dt>Активный режим</dt><dd>{state?.sourceMode ?? "idle"}</dd></div>
</dl>
<div className="contour-process-list">
{snapshot?.pluginRuntimes.map((runtime) => (
<div key={runtime.runtime_instance_id}>
<i data-state={runtime.status === "ready" ? "online" : "offline"} />
<span>
<strong>Device runtime · {runtime.plugin_id}</strong>
<small>v{runtime.plugin_version} · {formatObservedAt(runtime.observed_at)}</small>
</span>
<em>{runtime.status}</em>
</div>
))}
<div>
<i data-state={aiActive ? "online" : "idle"} />
<span>
<strong>AI perception</strong>
<small>
{aiActive
? `${Math.round(state?.metrics?.aiFrameRateHz ?? 0)} кадр/с`
: "Нет активного задания"}
</small>
</span>
<em>{aiActive ? "active" : "idle"}</em>
</div>
</div>
</article>
<article
className="contour-node"
data-state={simulationWorker?.available ? "online" : "offline"}
>
<header>
<div>
<span className="section-eyebrow">УЗЕЛ 02 · ВНЕШНИЙ</span>
<h3>{simulationWorker?.workerId ?? "Simulation Worker"}</h3>
</div>
<StatusBadge tone={simulationWorker?.available ? "success" : "neutral"}>
{simulationWorker?.available ? "Доступен" : "Offline"}
</StatusBadge>
</header>
<dl>
<div><dt>Gateway latency</dt><dd>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</dd></div>
<div><dt>Сеть worker</dt><dd>{simulationWorker?.isolation.network ?? "unavailable"}</dd></div>
<div><dt>Политика данных</dt><dd>{simulationWorker?.isolation.artifactPolicy ?? "d-only"}</dd></div>
<div><dt>Активный прогон</dt><dd>{simulationWorker?.activeRunId ?? "нет"}</dd></div>
</dl>
<div className="contour-process-list">
<div>
<i data-state={simulationWorker?.available ? "online" : "offline"} />
<span>
<strong>Simulation orchestration</strong>
<small>
{simulationWorker?.runState
? `Прогон: ${simulationWorker.runState}`
: "Нет активного прогона"}
</small>
</span>
<em>{simulationWorker?.available ? "ready" : "offline"}</em>
</div>
</div>
<section className="contour-node-grid" aria-label="Узлы контура">
<article className="contour-node" data-state={core?(core.ok?'online':'degraded'):'unknown'}>
<header><div><span className="section-eyebrow">КОМПЬЮТЕР ОПЕРАТОРА</span><h3>Mission Core</h3></div><StatusBadge tone={core?.ok?'success':'warning'}>{core?'В сети':snapshot?'Не в сети':'Проверка состояния'}</StatusBadge></header>
<dl><div><dt>Версия</dt><dd>{core?.version??'—'}</dd></div><div><dt>Время ответа</dt><dd>{snapshot?.latency==null?'—':`${Math.max(1,Math.round(snapshot.latency))} мс`}</dd></div><div><dt>Обработчики устройств готовы</dt><dd>{core?`${core.plugin_runtimes.ready} / ${core.plugin_runtimes.total}`:'—'}</dd></div></dl>
{!core?.ok&&core?.components?.recording_cache?.status==='capacity-pressure'&&<small>Недостаточно свободного места для новых записей.</small>}
</article>
{snapshot?.workers?.map(({contour,telemetry})=>{
const status=workerConnectionStatus(telemetry);
return <article key={contour.contour_id} className="contour-node" data-state={status.state}>
<header><div><span className="section-eyebrow">ВЫЧИСЛИТЕЛЬ</span><h3>{contour.display_name}</h3></div><StatusBadge tone={status.tone}>{status.label}</StatusBadge></header>
<dl><div><dt>Узел</dt><dd>{contour.expected_node_id}</dd></div><div><dt>Телеметрия</dt><dd>{contour.telemetry_mode==='agent-mqtt'?'Агент':'Диагностика SSH'}</dd></div><div><dt>Проверено</dt><dd>{observed(telemetry?.connection.observed_at_utc)}</dd></div><div><dt>Последнее измерение</dt><dd>{observed(telemetry?.node?.observed_at_utc)}</dd></div><div><dt>Операционная система</dt><dd>{telemetry?.node?.os.caption??'—'}</dd></div></dl>
{status.detail&&<small>{status.detail}</small>}
</article>;
})}
{snapshot?.vehicles?.map(vehicle=>{
const online=boardAvailable(vehicle);
const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'В сети':'Не в сети';
return <article key={vehicle.id} className="contour-node" data-state={online?'online':'offline'}>
<header><div><span className="section-eyebrow">БОРТОВОЙ КОМПЬЮТЕР</span><h3>{vehicle.name}</h3></div><StatusBadge tone={online?'success':'neutral'}>{status}</StatusBadge></header>
<dl><div><dt>Имя БК</dt><dd>{vehicle.host?.hostname??'—'}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host?.os??'—'}</dd></div><div><dt>Последняя связь</dt><dd>{observed(vehicle.last_seen===null?null:vehicle.last_seen*1000)}</dd></div></dl>
{!online&&vehicle.host&&<small>Сведения о БК сохранены с последнего подключения.</small>}
</article>;
})}
</section>
<section className="contour-network-strip" aria-label="Состояние сети">
<div>
<span className="section-eyebrow">СЕТЬ</span>
<strong>Браузер</strong>
<small>127.0.0.1:8000</small>
</div>
<Icon name="chevron-right" />
<div>
<i data-state={controlPlaneReady ? "online" : "offline"} />
<strong>Control Plane</strong>
<small>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</small>
</div>
<Icon name="chevron-right" />
<div>
<i data-state={simulationWorker?.available ? "online" : "offline"} />
<strong>Worker gateway</strong>
<small>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</small>
</div>
</section>
{controlledDevice ? (
<section className="contour-connected-device">
<span className="section-eyebrow">ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ</span>
<strong>{controlledDevice.displayName}</strong>
<small>
{controlledDevice.endpointLabel ?? controlledDevice.modelId}
{deviceControlConnectivity === "degraded"
? " · управление подтверждено, поток данных нарушен"
: " · управление подтверждено"}
</small>
</section>
) : null}
{snapshot?.error ? (
<p className="contour-health-error" role="status">{snapshot.error}</p>
) : null}
</div>
);
{controlledDevice&&<section className="contour-connected-device"><span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО ОПЕРАТОРА</span><strong>{controlledDevice.displayName}</strong><small>{deviceControlConnectivity==='degraded'?'Поток данных нарушен':'Управляющая связь подтверждена'}</small></section>}
{snapshot?.errors.map(error=><p key={error} className="contour-health-error" role="status">{error}</p>)}
</div>;
}
@@ -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,12 +431,13 @@ 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 headerToolsHost={props.headerToolsHost} headerTitleHost={props.headerTitleHost} />;
case "catalog":
return <CatalogWorkspace {...props} />;
case "contour-health":
return (
<ContourHealthWorkspace
headerToolsHost={props.headerToolsHost}
state={props.state}
/>
);
@@ -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,8 +36,9 @@ export interface LaboratoryViewAction {
}
export interface WorkspaceRendererProps {
fleetCreateRequest?: number;
launchProfile?: WorkspaceLaunchProfile;
headerToolsHost?: HTMLElement | null;
headerTitleHost?: HTMLElement | null;
definition: WorkspaceDefinition;
state: MissionRuntimeState | null;
backendStatus: BackendStatus;
@@ -0,0 +1,21 @@
import {Button,Icon,IconButton,ResourceRow,StatusBadge,Window} from '@nodedc/ui-react';
import type {Vehicle} from '../../core/fleet/useFleet';
export const boardStatus=(item:Vehicle,available=true)=>item.enrollment==='pending'?'Подтверждаем привязку':item.enrollment==='revoked'?'Не привязан':item.enrollment==='failed'?'Привязка не завершена':item.connectivity==='online'?(available?'В сети':'Не в сети'):'Не в сети';
export function BoardComputerRow({vehicle,enabled,showingSavedDevices,onSettings,onMonitor,onAdd,open,onToggle}:{vehicle:Vehicle;enabled:boolean;showingSavedDevices:boolean;onSettings:()=>void;onMonitor:()=>void;onAdd?:()=>void;open:boolean;onToggle:()=>void}){
const label=boardStatus(vehicle,enabled);
return <ResourceRow icon={<Icon name="apps"/>} title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={`${label}${showingSavedDevices?' (показаны последние сохранённые устройства)':''}`} statusPlacement="leading"
status={<StatusBadge variant="indicator" tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'} aria-label={label} title={label}/>}
actions={<>{onAdd&&<IconButton label="Добавить устройство к БК" onClick={onAdd}><Icon name="plus"/></IconButton>}<IconButton label="Настройки бортового компьютера" onClick={onSettings}><Icon name="settings"/></IconButton><IconButton label="Мониторинг системы БК" onClick={onMonitor}><Icon name="activity"/></IconButton><IconButton label={open?'Свернуть устройства БК':'Показать устройства БК'} aria-expanded={open} onClick={onToggle}><Icon name={open?'chevron-down':'chevron-right'}/></IconButton></>}/>;
}
export function BoardComputerSettings({vehicle,enabled,onClose,onRevoke}:{vehicle:Vehicle;enabled:boolean;onClose:()=>void;onRevoke:()=>void}){
return <Window open title="Бортовой компьютер" subtitle={vehicle.host?.hostname||'Mission Core Node'} onClose={onClose}>
<div className="fleet-form"><StatusBadge tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'}>{boardStatus(vehicle,enabled)}</StatusBadge>
{vehicle.notice&&<p role="status">{vehicle.notice}</p>}
<dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{vehicle.node_id}</dd></div><div><dt>Последняя связь</dt><dd>{vehicle.last_seen?new Date(vehicle.last_seen*1000).toLocaleString('ru-RU'):'Соединение ещё не получено'}</dd></div>
{vehicle.host&&<><div><dt>Имя БК в системе</dt><dd>{vehicle.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host.os}</dd></div><div><dt>Архитектура</dt><dd>{vehicle.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{vehicle.host.cpus}</dd></div><div><dt>Память</dt><dd>{vehicle.host.memory_kib?`${(vehicle.host.memory_kib/1048576).toFixed(1)} ГиБ`:'Нет сведений'}</dd></div></>}
</dl>
{vehicle.enrollment!=='revoked'&&<Button onClick={onRevoke}>Отозвать привязку БК</Button>}
</div>
</Window>;
}
@@ -1,5 +1,6 @@
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
import {useState} from 'react';
import {ActivityIndicator,Button,Icon,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
import {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
import {useBoardMonitor,monitorSeries,monitorValue} from '../../core/fleet/boardMonitor';
import {TelemetrySeries} from '../../components/system/TelemetrySeries';
import '../../styles/system-telemetry.css';
@@ -7,7 +8,7 @@ import './board-monitor.css';
const eventLabels:Record<string,string>={'ui-error':'Ошибка интерфейса','ui-rejection':'Ошибка асинхронной операции','ui-render-error':'Ошибка представления','system-oom':'Нехватка памяти','gpu-reset':'Сброс GPU','disk-error':'Ошибка диска','usb-disconnected':'Отключение USB','usb-error':'Ошибка USB','service-failed':'Сбой службы','ui-process-exit':'Завершение процесса окна','ui-load-failed':'Ошибка загрузки окна'};
const time=(value:number)=>new Date(value*1000).toLocaleString('ru-RU');
export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string;back:()=>void}) {
export function BoardMonitorView({vehicle,name,back,headerToolsHost}:{vehicle:string;name:string;back:()=>void;headerToolsHost?:HTMLElement|null}) {
const [metric,setMetric]=useState('cpu.usage'),[group,setGroup]=useState('CPU'),[window,setWindow]=useState(900),[end,setEnd]=useState<number|null>(null),[mode,setMode]=useState<'min'|'max'|'mean'>('max');
const {value,error,dismissError}=useBoardMonitor(vehicle,metric,window,end);
const definitions=value?.definitions??{},definition=definitions[metric];
@@ -15,7 +16,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
const metrics=Object.entries(definitions).filter(([,item])=>item.group===group);
const latest=value?.latest;
return <div className="board-monitor">
<div><Button onClick={back}><Icon name="chevron-left"/>К аппарату</Button></div>
<WorkspaceBackButton label="К аппарату" onClick={back} host={headerToolsHost}/>
<SettingsCard title="Мониторинг системы БК" description={name} actions={<StatusBadge tone={value?.fresh&&!error?'success':'neutral'}>{value?.fresh&&!error?'Данные поступают':'Нет свежих данных'}</StatusBadge>}>
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
@@ -0,0 +1,40 @@
import {useId,useState,type FormEvent} from 'react';
import {Button,Select,SettingsCard,TextAreaField,TextField,StatusBadge} from '@nodedc/ui-react';
import {fleetRequest,type FleetPreview,type Vehicle} from '../../core/fleet/useFleet';
import type {SensorEnrollmentProps} from '../../../../../packages/sensor-ui/src/extensions';
export const platforms=[{value:'ugv',label:'Наземный (UGV)'},{value:'uav',label:'Воздушный (UAV)'},{value:'stationary',label:'Стационарный'},{value:'other',label:'Другой'}];
export const platformLabel=(value:string)=>platforms.find(item=>item.value===value)?.label??value;
/** The invitation admits a computer; an existing apparatus keeps its identity. */
export function NodeInvitationForm({vehicle,onClose,onComplete,renderWindow}:{vehicle?:Vehicle;onClose:()=>void;onComplete:(vehicle:Vehicle)=>void;renderWindow:SensorEnrollmentProps['renderWindow']}){
const form=useId();
const [revision]=useState(vehicle?.revision);
const [code,setCode]=useState(''),[name,setName]=useState(''),[platform,setPlatform]=useState('ugv');
const [preview,setPreview]=useState<FleetPreview|null>(null),[pending,setPending]=useState(false),[error,setError]=useState('');
async function inspect(event:FormEvent){
event.preventDefault();if(pending)return;setPending(true);setError('');
try{const value=await fleetRequest<FleetPreview>('/preview','POST',{code:code.trim()});setPreview(value);setName(current=>current||value.name);setCode('');}
catch(e){setError(e instanceof Error?e.message:'Не удалось проверить приглашение.');}
finally{setPending(false);}
}
async function attach(){
if(!preview||pending)return;setPending(true);setError('');
try{
const result=vehicle
?await fleetRequest<Vehicle>(`/${encodeURIComponent(vehicle.id)}/computer`,'POST',{preview_id:preview.preview_id,expected_revision:revision})
:await fleetRequest<Vehicle>('','POST',{preview_id:preview.preview_id,name:name.trim(),platform});
onComplete(result);
}catch(e){setError(e instanceof Error?e.message:'Не удалось подключить БК.');}
finally{setPending(false);}
}
const replacing=!!vehicle&&vehicle.enrollment!=='revoked';
return renderWindow({busy:pending,actions:<><Button disabled={pending} onClick={onClose}>Отмена</Button>{preview?<Button loading={pending} disabled={pending||(!vehicle&&!name.trim())} onClick={()=>void attach()}>{vehicle?(replacing?'Заменить БК':'Подключить БК'):'Добавить аппарат'}</Button>:<Button loading={pending} type="submit" form={form} disabled={pending||!code.trim()}>Проверить БК</Button>}</>,content:
<form id={form} className="fleet-form" onSubmit={inspect} aria-busy={pending}>
{!vehicle&&<><Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending}/><TextField label="Название аппарата" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={pending} autoComplete="off"/></>}
{vehicle&&<SettingsCard title={vehicle.name} description={replacing?'Новый БК заменит текущий. Аппарат, его название и раскладки сохранятся; прежняя привязка будет отозвана.':'Подключите БК к этому аппарату по приглашению Mission Core Node.'}/>}
{preview?<SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={()=>setPreview(null)}>Другое приглашение</Button></SettingsCard>:<TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={e=>setCode(e.target.value)}/>}
{pending&&<StatusBadge tone="warning">{preview?'Подключение БК':'Проверка приглашения БК'}</StatusBadge>}
{error&&<p role="alert">{error}</p>}
</form>});
}
@@ -0,0 +1,30 @@
import {useEffect,useSyncExternalStore} from 'react';
import {Button,Icon,IconButton,Inspector,LoadingRegion} from '@nodedc/ui-react';
import type {BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
import type {SensorBoardContent} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
import type {Vehicle} from '../../core/fleet/useFleet';
import {VehicleIdentitySettings} from './VehicleIdentitySettings';
import {BoardComputerRow} from './BoardComputer';
export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;layout:BoardLayoutStore;content:SensorBoardContent;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
useEffect(()=>{void layout.load();},[layout]);
const computerOpen=state.value.open_sections.includes('computer');
// The former computer-section preference now controls its nested device branch.
const changeSections=(ids:string[])=>layout.change([...ids,...(computerOpen?['computer']:[])]);
const toggleComputer=()=>layout.change(computerOpen?state.value.open_sections.filter(id=>id!=='computer'):[...state.value.open_sections,'computer']);
return <div className="sensor-content">
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
<Inspector variant="panel" openSections={state.value.open_sections.filter(id=>id!=='computer')} onOpenSectionsChange={changeSections} sections={[
{id:'settings',label:'Настройки аппарата',icon:<Icon name="sliders"/>,disabled:!state.ready,content:<div className="sensor-content"><VehicleIdentitySettings key={vehicle.id} vehicle={vehicle} onUpdated={onUpdated}/>{content.settings}</div>},
{id:'devices',label:'Оборудование',icon:<Icon name="apps"/>,disabled:!state.ready,content:<div className="sensor-content">
<div className="sensor-actions sensor-inventory-toolbar"><span className="sensor-note">Бортовой компьютер и подключённые к нему устройства</span><div className="sensor-actions"><IconButton label="Подключить бортовой компьютер" onClick={onAddComputer}><Icon name="plus"/></IconButton><IconButton label="Обновить оборудование" onClick={()=>void content.refresh()}><Icon name="refresh"/></IconButton></div></div>
<div><BoardComputerRow vehicle={vehicle} enabled={enabled} showingSavedDevices={content.showingSavedDevices} open={computerOpen} onToggle={toggleComputer} onSettings={onSettings} onMonitor={onMonitor} onAdd={content.canAddDevice?content.addDevice:undefined}/>
{computerOpen&&<div className="fleet-computer-devices">{content.devices}</div>}
</div>
</div>},
]}/>
</LoadingRegion>
</div>;
}
@@ -0,0 +1,27 @@
import {useEffect,useState} from 'react';
import {Button,SettingsCard,TextField} from '@nodedc/ui-react';
import {fleetRequest,type Vehicle} from '../../core/fleet/useFleet';
export function VehicleIdentitySettings({vehicle,onUpdated}:{vehicle:Vehicle;onUpdated:(vehicle:Vehicle)=>void}){
const [base,setBase]=useState(vehicle),[name,setName]=useState(vehicle.name);
const [saving,setSaving]=useState(false),[message,setMessage]=useState(''),[error,setError]=useState('');
const dirty=name!==base.name;
useEffect(()=>{if(!dirty&&!saving){setBase(vehicle);setName(vehicle.name);}},[vehicle,dirty,saving]);
const reset=()=>{setBase(vehicle);setName(vehicle.name);setError('');setMessage('');};
const save=async()=>{
if(saving||!name.trim()||!dirty)return;
setSaving(true);setError('');setMessage('');
try{
const updated=await fleetRequest<Vehicle>(`/${encodeURIComponent(vehicle.id)}`,'PATCH',{name:name.trim(),expected_revision:base.revision});
onUpdated(updated);setBase(updated);setName(updated.name);setMessage('Название сохранено');
}catch(reason){setError(reason instanceof Error?reason.message:'Не удалось сохранить название.');}
finally{setSaving(false);}
};
return <SettingsCard title="Основные параметры">
<form className="fleet-form" onSubmit={event=>{event.preventDefault();void save();}}>
<TextField label="Название аппарата" value={name} maxLength={80} autoComplete="off" disabled={saving} onChange={event=>{setName(event.target.value);setMessage('');}}/>
<div className="sensor-actions"><Button type="submit" disabled={!dirty||!name.trim()||saving}>{saving?'Сохранение…':'Сохранить'}</Button>{dirty&&<Button type="button" variant="secondary" disabled={saving} onClick={reset}>Отмена</Button>}</div>
{error&&<p role="alert">{error}</p>}{message&&<p role="status">{message}</p>}
</form>
</SettingsCard>;
}
@@ -1,11 +1,16 @@
import {useMemo} 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}){
import type {Vehicle} from '../../core/fleet/useFleet';
import {VehicleEquipment} from './VehicleEquipment';
export function VehicleSensors({vehicle,enabled,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){
const vehicleID=vehicle.id;
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}:${vehicle.node_id}`} transport={transport} enabled={enabled} readOfflineInventory renderBoard={content=><VehicleEquipment vehicle={vehicle} enabled={enabled} layout={layout} content={content} onAddComputer={onAddComputer} onSettings={onSettings} onMonitor={onMonitor} onUpdated={onUpdated}/>}/>;
}
@@ -1,77 +1,41 @@
import {useState} from 'react';
import {createPortal} from 'react-dom';
import {LoadingRegion,ConfirmationModal,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
import {fleetRequest,useFleet,type Vehicle} from '../../core/fleet/useFleet';
import {BoardObservationCenter} from './observation/BoardObservationCenter';
import {BoardMonitorView} from './BoardMonitorView';
import { useEffect, useRef, useState } from "react";
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
import "./fleet.css";
import { VehicleSensors } from "./VehicleSensors";
import {VehicleSensors} from './VehicleSensors';
import {BoardComputerSettings,boardStatus} from './BoardComputer';
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
import './fleet.css';
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
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 }) {
const fleet = useFleet();
const [adding, setAdding] = useState(false);
const [code, setCode] = useState("");
const [name, setName] = useState("");
const [platform, setPlatform] = useState("ugv");
const [preview, setPreview] = useState<FleetPreview | null>(null);
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);
const lastCreateRequest = useRef(createRequest);
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
async function inspect(event: React.FormEvent) {
event.preventDefault(); if (pending) return;
setPending(true); setError("");
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
finally { setPending(false); }
}
async function add() {
if (!preview || pending) return;
setPending(true); setError("");
try {
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
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&&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>}>
{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>
{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>}
<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} />
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
{error && <p role="alert">{error}</p>}
</form>
</Window>
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
export function VehiclesWorkspace({headerToolsHost,headerTitleHost}:{headerToolsHost?:HTMLElement|null;headerTitleHost?:HTMLElement|null}){
const fleet=useFleet();
const [selected,setSelected]=useState<string|null>(null);
const [adding,setAdding]=useState(false),[monitorOpen,setMonitorOpen]=useState(false),[observationOpen,setObservationOpen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false);
const [revoking,setRevoking]=useState<Vehicle|null>(null),[error,setError]=useState('');
const detail=fleet.items?.find(item=>item.id===selected);
const enabled=!!detail&&!fleet.error&&detail.enrollment==='paired'&&detail.connectivity==='online';
const refresh=()=>{void fleet.refresh().catch(()=>setError('Не удалось обновить аппарат. Повторите получение сведений.'));};
const complete=(item:Vehicle)=>{setSelected(item.id);setAdding(false);refresh();};
const addButton=<IconButton label="Добавить аппарат" onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>;
const headerAdd=!detail&&(headerToolsHost?createPortal(addButton,headerToolsHost):addButton);
const titleSuffix=detail?` — ${detail.name} / ${platformLabel(detail.platform)}`:'';
const observationButton=<IconButton label="Центр наблюдения и управления" onClick={()=>setObservationOpen(true)}><Icon name="eye"/></IconButton>;
const headerObservation=detail&&!observationOpen&&!monitorOpen&&(headerToolsHost?createPortal(observationButton,headerToolsHost):observationButton);
const content=detail&&observationOpen?<BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={enabled} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>
:detail&&monitorOpen?<BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)} headerToolsHost={headerToolsHost}/>
:<div className="fleet-workspace">
{detail?<>
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
<VehicleSensors onUpdated={fleet.acceptVehicle} vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
</>:!fleet.items?<LoadingRegion loading={!fleet.error} label="Получение аппаратов"/>:fleet.items.length===0?<SettingsCard title="Аппаратов пока нет" description="Создайте аппарат и подключите его бортовой компьютер по приглашению Mission Core Node."/>:<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?'Нет свежих данных':`БК: ${boardStatus(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>}
</div>;
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content}
{adding&&<NodeInvitationForm key={detail?.id??'new'} vehicle={detail} onClose={()=>setAdding(false)} onComplete={complete} renderWindow={({content,actions,busy})=><Window open title={detail?'Подключить бортовой компьютер':'Добавить аппарат'} subtitle={detail?detail.name:'Создать аппарат и подключить БК по приглашению Node'} size="md" closeOnBackdrop={false} closeOnEscape={!busy} onClose={()=>{if(!busy)setAdding(false);}} footer={<WindowFooterActions>{actions}</WindowFooterActions>}>{content}</Window>}/>}
{settingsOpen&&detail&&<BoardComputerSettings vehicle={detail} enabled={enabled} onClose={()=>setSettingsOpen(false)} onRevoke={()=>{setSettingsOpen(false);setRevoking(detail);}}/>}
<ConfirmationModal open={revoking!==null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name??''}» останется в реестре. БК потеряет доступ к Core и получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={()=>setRevoking(null)} onConfirm={async()=>{if(!revoking)return;try{await fleetRequest(`/${encodeURIComponent(revoking.id)}`,'DELETE');await fleet.refresh();setRevoking(null);}catch(e){setError(e instanceof Error?e.message:'Не удалось отозвать привязку.');throw e;}}}/>
<ToastStack items={error||fleet.error?[{id:'fleet-error',title:error||fleet.error,tone:'error',durationMs:null}]:[]} onDismiss={()=>setError('')}/>
</>;
}
@@ -1,5 +1,7 @@
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
.fleet-workspace { padding: var(--nodedc-space-4); }
.fleet-header-tools { display:flex; align-items:center; gap:var(--nodedc-space-2); }
.nodedc-application-panel__titles h1 > .fleet-header-title { font:inherit; color:inherit; letter-spacing:inherit; text-transform:none; }
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
@@ -10,4 +12,6 @@
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
.fleet-board-actions > * { flex:1; }
.fleet-computer-devices { padding-block-start:var(--nodedc-space-4); padding-inline-start:var(--nodedc-space-4); display:grid; gap:var(--nodedc-space-4); }
.fleet-form p { font-size:var(--nodedc-font-size-sm); line-height:1.5; color:var(--nodedc-text-secondary); }
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
@@ -7,66 +7,82 @@ import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedOb
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
import {unboundObservationViews} from '../../../../../../packages/sensor-ui/src/observationCatalog';
import {sensorContribution} from '../../../../../../packages/sensor-ui/src/extensions';
import {ObservationDeviceSettings} from './ObservationDeviceSettings';
import {ObservationPanelHeader} from './ObservationPanelHeader';
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();}});
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
const [settingsDevice,setSettingsDevice]=useState<string|null>(null);
const [layersOpen,setLayersOpen]=useState(false);
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
const groups=(inventory?.items??[]).flatMap(device=>{
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
const contribution=sensorContribution(registry.sensorContributions,device)?.observation;
const views=contribution?.views(device)??cameraObservationViews(device);
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:'Карта'}});
const sources:{key:string;deviceID?:string;catalog?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
sources.push(...unboundObservationViews(inventory?.items??[],registry.sensorContributions));
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 ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)&&(!sources.find(source=>source.key===id)?.catalog||layout.catalogVisible?.includes(id)));
const effectiveFull=full&&visible.includes(full)?full:null;
const active=sources.find(source=>source.key===focused);
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen&&!settingsDevice;
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
const configuredDevice=inventory?.items.find(device=>device.id===settingsDevice);
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 setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),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>
const navigationActions=<><IconButton label="Конфигуратор" onClick={configure}><Icon name="sliders"/></IconButton><IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton></>;
return <>{headerToolsHost&&!expanded&&createPortal(navigationActions,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)&&navigationActions}<Button onClick={()=>{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>
<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={()=>{setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
<DropZone id={source.key} className="observation-panel">
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
<header className="observation-panel__header">
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface observation-panel__surface--overlay">
<ObservationPanelHeader>
{handle}
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
<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={()=>setSettingsDevice(source.deviceID!)}><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>
</header>
</ObservationPanelHeader>
<ObservationMount element={media.current.get(source.key)!}/>
</GlassSurface>
}</DraggableItem>
</DropZone>,mounts.current.get(source.key)!,source.key))}
</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))!}))}/>)}
{sources.filter(source=>source.catalog&&visible.includes(source.key)).map(source=>createPortal(<div className="observation-source"><div className="observation-source__notice"><p>{source.catalog} не подключён к аппарату.</p><Button onClick={configure}>Подключить в конфигураторе</Button></div></div>,media.current.get(source.key)!,source.key))}
{configuredDevice&&<ObservationDeviceSettings key={configuredDevice.id} device={configuredDevice} transport={transport} contributions={registry.sensorContributions} enabled={enabled&&inventory?.fresh!==false&&configuredDevice.online} close={()=>setSettingsDevice(null)} configure={configure}/>}
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-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>;})}
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
{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="Доступные слои" size="md"><div className="observation-settings">
<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.map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.catalog?`${source.view.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>;})}
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
</div></Window>
</section>,portal)}</>;
@@ -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';
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}/>}/>;
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 separatorAppearance="invisible" 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}/>}/>;
}
@@ -0,0 +1,17 @@
import {useState} from 'react';
import {Button,Window} from '@nodedc/ui-react';
import {SensorDetail} from '../../../../../../packages/sensor-ui/src/SensorDetail';
import {sensorContribution,type SensorUiContribution} from '../../../../../../packages/sensor-ui/src/extensions';
import type {Sensor,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
export function ObservationDeviceSettings({device,transport,contributions,enabled,close,configure}:{device:Sensor;transport:SensorTransport;contributions:readonly SensorUiContribution[];enabled:boolean;close:()=>void;configure:()=>void}) {
const [error,setError]=useState('');
const Detail=sensorContribution(contributions,device)?.Detail??SensorDetail;
return <Window open title={`Настройки · ${device.name}`} size="lg" placement="end" draggable closeOnBackdrop={false} lockBodyScroll={false} trapFocus={false} onClose={close}>
{!enabled&&<p>Нет связи с устройством. Настройки оборудования будут доступны после подключения.</p>}
{!device.prepared&&<Button onClick={configure}>Подготовить устройство в конфигураторе</Button>}
{error&&<p role="alert">{error}</p>}
<Detail device={device} transport={transport} enabled={enabled} back={close} refresh={async()=>{await transport.inventory();}} failure={reason=>setError(reason instanceof Error?reason.message:reason?String(reason):'')} createRerunHost={createIsolatedRerunHost}/>
</Window>;
}
@@ -0,0 +1,13 @@
import {useLayoutEffect,useRef,type ReactNode} from 'react';
/** Reserve the actual overlay height for text/status content, including wrapped controls. */
export function ObservationPanelHeader({children}:{children:ReactNode}) {
const ref=useRef<HTMLElement>(null);
useLayoutEffect(()=>{
const header=ref.current!;
const measure=()=>header.parentElement?.style.setProperty('--observation-overlay-header-space',`${header.getBoundingClientRect().height}px`);
measure();const observer=new ResizeObserver(measure);observer.observe(header);
return()=>observer.disconnect();
},[]);
return <header ref={ref} className="observation-panel__header">{children}</header>;
}
@@ -9,6 +9,9 @@
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
.observation-panel__surface > .observation-mount { flex:1; }
.observation-panel__surface--overlay { position:relative; --observation-overlay-header-space:62px; }
.observation-panel__surface--overlay > .observation-panel__header { position:absolute; inset:0 0 auto; z-index:1; background:transparent; pointer-events:none; }
.observation-panel__surface--overlay > .observation-panel__header > * { pointer-events:auto; }
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
@@ -21,7 +24,7 @@
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
.observation-source__notice { position:absolute; inset:max(30%, calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3))) var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
@@ -43,3 +46,5 @@
.observation-panel__title { min-width:80px; }
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
}
.observation-panel__surface--overlay .rover-telemetry { padding-top:calc(var(--observation-overlay-header-space) + var(--nodedc-space-3)); }
@@ -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);}
}
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);

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