Compare commits
118
Commits
57f2537af3
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb33a6d45c | ||
|
|
0fb117fe7b | ||
|
|
e2cf3078b6 | ||
|
|
f3640026cf | ||
|
|
bd5873ef34 | ||
|
|
aecae4a986 | ||
|
|
ee7576b7b4 | ||
|
|
e2b0d839eb | ||
|
|
d2ec9e95b1 | ||
|
|
8283f9ba85 | ||
|
|
db3b1d7b67 | ||
|
|
a993eda6b8 | ||
|
|
9d3329f608 | ||
|
|
399af98841 | ||
|
|
71a648fec8 | ||
|
|
dad11b47d7 | ||
|
|
e627500295 | ||
|
|
f01bd39037 | ||
|
|
a7c64e009d | ||
|
|
6bbbd5bf7d | ||
|
|
bc55901d4d | ||
|
|
6d772b29fe | ||
|
|
63cbb08ea0 | ||
|
|
53818230f9 | ||
|
|
45fb14b206 | ||
|
|
24bbaefb00 | ||
|
|
2e5d52521f | ||
|
|
c804d89b18 | ||
|
|
76dc9f19c9 | ||
|
|
e515ab1b8c | ||
|
|
1c7dd29d8a | ||
|
|
be58d589e2 | ||
|
|
5f75afb720 | ||
|
|
99f29218d7 | ||
|
|
db14ee5435 | ||
|
|
81dd44906d | ||
|
|
d45b329de8 | ||
|
|
f164606828 | ||
|
|
556e376a62 | ||
|
|
1a2de8df65 | ||
|
|
b536f0049a | ||
|
|
3b85299fd9 | ||
|
|
505844adc3 | ||
|
|
89b0eeca3f | ||
|
|
1488a46794 | ||
|
|
e4339f4e88 | ||
|
|
7e257275ab | ||
|
|
a3c15e11e9 | ||
|
|
54a85fdf50 | ||
|
|
14f8375713 | ||
|
|
6492ebcdee | ||
|
|
e57511f975 | ||
|
|
f0802d2713 | ||
|
|
d8afb61229 | ||
|
|
421a8c515a | ||
|
|
a23c5b2005 | ||
|
|
1832943558 | ||
|
|
cc90709919 | ||
|
|
ee5d760c30 | ||
|
|
0e9faa6824 | ||
|
|
c6693d6f44 | ||
|
|
ed9a77ba73 | ||
|
|
7dd2c1c836 | ||
|
|
762d77ef95 | ||
|
|
9c4d70b0e9 | ||
|
|
eed53dbc0b | ||
|
|
4f333fc11c | ||
|
|
35a517f921 | ||
|
|
9bba44f7c4 | ||
|
|
9b6534287a | ||
|
|
9e03404f23 | ||
|
|
fa8ac760a1 | ||
|
|
309bdf759e | ||
|
|
ff00851216 | ||
|
|
ad24518b71 | ||
|
|
7ce09eabcd | ||
|
|
92b60de945 | ||
|
|
67398fef10 | ||
|
|
a93f1f1b8b | ||
|
|
102e93796b | ||
|
|
4f715e41d2 | ||
|
|
8f17a7e282 | ||
|
|
7187fd86bd | ||
|
|
e6521e855a | ||
|
|
06d216ac4a | ||
|
|
d7986cb62f | ||
|
|
a0732386f1 | ||
|
|
5c4546a2a6 | ||
|
|
ef1c1f50de | ||
|
|
2f80507bc0 | ||
|
|
d2dbfc9c85 | ||
|
|
3dffe39c89 | ||
|
|
77acb377e2 | ||
|
|
2fdf1d3cc4 | ||
|
|
d8cc5367c4 | ||
|
|
111fe3dcfb | ||
|
|
63ea2bed67 | ||
|
|
020a878915 | ||
|
|
404285419f | ||
|
|
ba0c948ca1 | ||
|
|
429b4681fe | ||
|
|
22d69107ba | ||
|
|
a8647c4d87 | ||
|
|
b1aaa40508 | ||
|
|
3616acc648 | ||
|
|
e82d012907 | ||
|
|
fc545f8440 | ||
|
|
a98ef339de | ||
|
|
9062126913 | ||
|
|
59e14c5bc0 | ||
|
|
15bb793e5e | ||
|
|
79911eb316 | ||
|
|
d696842f5d | ||
|
|
88e47fc39a | ||
|
|
cd16ea28a6 | ||
|
|
45019f8952 | ||
|
|
2e44e27967 | ||
|
|
2ccf172319 |
@@ -10,3 +10,5 @@
|
|||||||
*.las binary
|
*.las binary
|
||||||
*.lcc 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.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||||
|
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
|
||||||
|
apps/control-station/public/rover-scene/*.glb filter=lfs diff=lfs merge=lfs -text
|
||||||
|
|||||||
@@ -49,3 +49,10 @@ mqtt.summary.json
|
|||||||
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
|
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
|
||||||
!tests/fixtures/**/*.pcap
|
!tests/fixtures/**/*.pcap
|
||||||
!tests/fixtures/**/*.pcapng
|
!tests/fixtures/**/*.pcapng
|
||||||
|
|
||||||
|
# Dependency caches may be links into an existing local checkout.
|
||||||
|
.venv
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Private experiment evidence and generated build archives.
|
||||||
|
/outputs/
|
||||||
|
|||||||
@@ -62,6 +62,58 @@ and the boundary between Mission Core and vendor-specific integration code.
|
|||||||
- Synthetic or explicitly redacted fixtures may be committed under
|
- Synthetic or explicitly redacted fixtures may be committed under
|
||||||
`tests/fixtures/`.
|
`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,
|
||||||
|
camera initialization and live video have not been accepted.
|
||||||
|
- Every Ubuntu change needed for Insta360 must execute through the shipped
|
||||||
|
installer or the application's versioned device-preparation workflow from
|
||||||
|
the first board experiment. Do not repair the board with an ad-hoc apt/pip
|
||||||
|
install, copied library, chmod, environment override, service edit or root
|
||||||
|
invocation and promise to package it later. Fix the product artifact first,
|
||||||
|
then rerun that artifact. Read-only engineering inspection remains allowed.
|
||||||
|
- Document every relevant action and its before/after evidence in
|
||||||
|
`docs/node/07_INSTA360_X4_INSTALLATION_LEDGER.md`; record the artifact version,
|
||||||
|
owning installer step, dependencies, idempotency, failure behavior and rollback.
|
||||||
|
Keep private logs/identifiers/media out of Git and redact their public summary.
|
||||||
|
- A working prepared Mini is not clean-Ubuntu acceptance. Qualify dependency
|
||||||
|
closure on the declared clean OS image and actual USB operation separately;
|
||||||
|
installer, preparation and GUI acceptance must use the same shipped code.
|
||||||
|
- X4 is operator live video and camera control (settings, photo, recording
|
||||||
|
start/stop and file access), per the owner’s subsequent scope expansion.
|
||||||
|
Preview and recording are separate operations. No stitching, AI, navigation
|
||||||
|
or vehicle-control integration is admitted. Model support and physical device instances are separate: several
|
||||||
|
cameras of the same model must retain independent identity and lifecycle.
|
||||||
|
- Follow the scoped implementation plan in
|
||||||
|
`docs/node/06_INSTA360_X4_AND_MULTI_CAMERA_PLAN.md`. It is a proposed plan,
|
||||||
|
not evidence that SDK compatibility, hardware capacity or installation passed.
|
||||||
|
- Subsequent owner direction: the existing Ubuntu Mini is the available build
|
||||||
|
and qualification machine; do not depend on Worker 006 or another host for
|
||||||
|
this X4 work. Its existing GCC may compile through a versioned build artifact
|
||||||
|
in bounded temporary staging. This is a build step, not runtime installation:
|
||||||
|
no system changes, SDK execution or camera access during compilation. The
|
||||||
|
final installer must carry the compiled payload so a clean operator Ubuntu
|
||||||
|
does not inherit an undeclared compiler/build-cache prerequisite. All board
|
||||||
|
staging/build/install/test actions remain documented and artifact-owned.
|
||||||
|
|
||||||
## Implementation order
|
## Implementation order
|
||||||
|
|
||||||
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
Follow the gates in `docs/01_IMPLEMENTATION_PLAN.md`. Do not build heavy
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
|
||||||
Generated
+24
@@ -7,6 +7,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "@nodedc/mission-core-control-station",
|
"name": "@nodedc/mission-core-control-station",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
"@nodedc/map-cesium-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/map-cesium-react",
|
"@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/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
|
"@nvidia/ov-web-rtc": "6.7.0",
|
||||||
"@rerun-io/web-viewer": "0.36.3",
|
"@rerun-io/web-viewer": "0.36.3",
|
||||||
"meshoptimizer": "1.1.1",
|
"meshoptimizer": "1.1.1",
|
||||||
"playcanvas": "2.21.4",
|
"playcanvas": "2.21.4",
|
||||||
@@ -898,6 +900,19 @@
|
|||||||
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
"link": true
|
"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": {
|
"node_modules/@rerun-io/web-viewer": {
|
||||||
"version": "0.36.3",
|
"version": "0.36.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
|
||||||
@@ -1614,6 +1629,15 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
"postinstall": "node scripts/install-rerun-navigation.mjs",
|
||||||
|
"prebuild": "node scripts/install-rerun-navigation.mjs",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test:unit": "node --test test/*.test.mjs",
|
"test:unit": "node --test test/*.test.mjs",
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
|
||||||
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
|
||||||
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
|
||||||
|
"@nvidia/ov-web-rtc": "6.7.0",
|
||||||
"@rerun-io/web-viewer": "0.36.3",
|
"@rerun-io/web-viewer": "0.36.3",
|
||||||
"meshoptimizer": "1.1.1",
|
"meshoptimizer": "1.1.1",
|
||||||
"playcanvas": "2.21.4",
|
"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).");
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import {SceneDisplayControls} from "../../../packages/spatial-ui/src/SceneDisplayControls";
|
||||||
|
import {SceneLayerControls} from "../../../packages/spatial-ui/src/SceneLayerControls";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import {
|
import {
|
||||||
AdminNavigationPanel,
|
AdminNavigationPanel,
|
||||||
@@ -5,18 +7,14 @@ import {
|
|||||||
ApplicationPanel,
|
ApplicationPanel,
|
||||||
ApplicationShell,
|
ApplicationShell,
|
||||||
Button,
|
Button,
|
||||||
Checker,
|
|
||||||
ColorField,
|
|
||||||
ControlRow,
|
ControlRow,
|
||||||
HeaderNavigation,
|
HeaderNavigation,
|
||||||
HeaderProfile,
|
HeaderProfile,
|
||||||
HeaderWorkspace,
|
|
||||||
Icon,
|
Icon,
|
||||||
Inspector,
|
Inspector,
|
||||||
RangeControl,
|
|
||||||
Select,
|
|
||||||
StatusBadge,
|
StatusBadge,
|
||||||
TextField,
|
TextField,
|
||||||
|
ToastStack,
|
||||||
UserProfileMenu,
|
UserProfileMenu,
|
||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
@@ -30,6 +28,8 @@ import { SystemNavigationPanel } from "./components/system/SystemNavigationPanel
|
|||||||
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
import { SystemWorkspaceSelector } from "./components/system/SystemWorkspaceSelector";
|
||||||
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
import { useComputeContourSettings } from "./components/system/useComputeContourSettings";
|
||||||
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
import { useApplicationPanelActions } from "./components/useApplicationPanelActions";
|
||||||
|
import { useSessionOverviewMode } from "./core/observation/useSessionOverview";
|
||||||
|
import { SessionOverviewWorkspace } from "./workspaces/recordings/SessionOverviewWorkspace";
|
||||||
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
import { useEnvironmentSettings } from "./core/environment/useEnvironmentSettings";
|
||||||
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
import { useDevicePluginHost } from "./core/device-plugins/DevicePluginHost";
|
||||||
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
import { useMissionRuntime } from "./core/runtime/MissionRuntimeContext";
|
||||||
@@ -50,6 +50,7 @@ import type {
|
|||||||
} from "./core/observation/sessionArchive";
|
} from "./core/observation/sessionArchive";
|
||||||
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
import { useRecordedSessionAdmission } from "./core/observation/useRecordedSessionAdmission";
|
||||||
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
import { useWorkspaceLayoutProfile } from "./core/observation/useWorkspaceLayoutProfile";
|
||||||
|
import { useSessionDisplayProfile } from "./core/observation/useSessionDisplayProfile";
|
||||||
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
import { viewerSettingsTargetIdentity } from "./core/observation/viewerSettingsTarget";
|
||||||
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
import { resolvePolygonRunRoute } from "./core/polygon/runArchive";
|
||||||
import {
|
import {
|
||||||
@@ -67,33 +68,18 @@ import {
|
|||||||
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
import { backendLabel, localConnectionPhaseLabel, phaseTone } from "./presentation";
|
||||||
import {
|
import {
|
||||||
defaultSceneSettings,
|
defaultSceneSettings,
|
||||||
type PointColorMode,
|
|
||||||
type PointPalette,
|
|
||||||
type SceneSettings,
|
type SceneSettings,
|
||||||
} from "./sceneSettings";
|
} from "./sceneSettings";
|
||||||
import { DeviceWorkspace } from "./workspaces/DeviceWorkspace";
|
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 { WorkspaceRenderer } from "./workspaces/Workspaces";
|
||||||
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
import { useLaboratoryAnnotationHeader } from "./components/laboratory/useLaboratoryAnnotationHeader";
|
||||||
import "./styles/scene-windows.css";
|
import "./styles/scene-windows.css";
|
||||||
type SceneToolWindowId = "sources" | "display" | "layers";
|
type SceneToolWindowId = "sources" | "display" | "layers";
|
||||||
const viewerSettingsQuietPeriodMs = 750;
|
const viewerSettingsQuietPeriodMs = 750;
|
||||||
|
|
||||||
const colorModeOptions: Array<{ value: PointColorMode; label: string; description: string }> = [
|
|
||||||
{ value: "intensity", label: "Интенсивность", description: "Значение отражённого сигнала" },
|
|
||||||
{ value: "height", label: "Высота Z", description: "Градиент по вертикальной координате" },
|
|
||||||
{ value: "distance", label: "Расстояние", description: "Дальность точки от сенсора" },
|
|
||||||
{ value: "rgb", label: "RGB", description: "Цвет, если он присутствует в данных" },
|
|
||||||
{ value: "class", label: "Класс", description: "Категория внешнего модуля восприятия" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const paletteOptions: Array<{ value: PointPalette; label: string; description: string }> = [
|
|
||||||
{ value: "turbo", label: "Turbo", description: "Контрастный спектральный градиент" },
|
|
||||||
{ value: "viridis", label: "Viridis", description: "Равномерный перцептивный градиент" },
|
|
||||||
{ value: "plasma", label: "Plasma", description: "Тёплый контрастный градиент" },
|
|
||||||
{ value: "grayscale", label: "Серый", description: "Монохромное отображение" },
|
|
||||||
{ value: "custom", label: "Свой цвет", description: "Один назначенный цвет" },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface LivePerceptionLayers {
|
interface LivePerceptionLayers {
|
||||||
detections2d: boolean;
|
detections2d: boolean;
|
||||||
segmentation: boolean;
|
segmentation: boolean;
|
||||||
@@ -166,11 +152,15 @@ export default function App() {
|
|||||||
contentExpanded: true,
|
contentExpanded: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [navigationRevision, setNavigationRevision] = useState(0);
|
||||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||||
polygonDatasetRoute.active ? "data" : null,
|
polygonDatasetRoute.active ? "data" : null,
|
||||||
);
|
);
|
||||||
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
const [environmentSettingsOpen, setEnvironmentSettingsOpen] = useState(false);
|
||||||
|
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||||
const [sourceUrl, setSourceUrl] = useState("");
|
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 [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||||
@@ -195,6 +185,7 @@ export default function App() {
|
|||||||
const appliedProfileKeyRef = useRef<string | null>(null);
|
const appliedProfileKeyRef = useRef<string | null>(null);
|
||||||
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
const sceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||||
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
const displayDraftRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||||
|
const closeDisplayRef = useRef<() => void>(() => {});
|
||||||
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
const confirmedSceneSettingsRef = useRef<SceneSettings>(defaultSceneSettings);
|
||||||
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
const viewerSettingsCommitTimerRef = useRef<number | null>(null);
|
||||||
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
const runtimeUpdateViewerSettingsRef = useRef(runtime.updateViewerSettings);
|
||||||
@@ -242,6 +233,13 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
runtimeUpdateViewerSettingsRef.current = runtime.updateViewerSettings;
|
||||||
replayActiveRef.current = replayPresented;
|
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(() => {
|
useEffect(() => {
|
||||||
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
if (!polygonDatasetRoute.active || polygonDatasetRouteOpenedRef.current) return;
|
||||||
@@ -324,6 +322,7 @@ export default function App() {
|
|||||||
const remote = runtime.state?.viewerSettings;
|
const remote = runtime.state?.viewerSettings;
|
||||||
if (
|
if (
|
||||||
!remote ||
|
!remote ||
|
||||||
|
replayActiveRef.current ||
|
||||||
workspaceLayoutProfile.profile ||
|
workspaceLayoutProfile.profile ||
|
||||||
sceneSettingsCommitterRef.current?.isBusy()
|
sceneSettingsCommitterRef.current?.isBusy()
|
||||||
) return;
|
) return;
|
||||||
@@ -346,7 +345,7 @@ export default function App() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const profile = workspaceLayoutProfile.profile;
|
const profile = workspaceLayoutProfile.profile;
|
||||||
if (!profile) return;
|
if (!profile || selectedRecordedSessionIdRef.current) return;
|
||||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
||||||
viewerSettingsCommitTimerRef.current = null;
|
viewerSettingsCommitTimerRef.current = null;
|
||||||
@@ -417,7 +416,10 @@ export default function App() {
|
|||||||
|
|
||||||
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
const closeSceneWindow = useCallback((windowId: SceneToolWindowId) => {
|
||||||
if (windowId === "sources") setSourceWindowOpen(false);
|
if (windowId === "sources") setSourceWindowOpen(false);
|
||||||
if (windowId === "display") setDisplayWindowOpen(false);
|
if (windowId === "display") {
|
||||||
|
setDisplayWindowOpen(false);
|
||||||
|
closeDisplayRef.current();
|
||||||
|
}
|
||||||
if (windowId === "layers") setLayerInspectorOpen(false);
|
if (windowId === "layers") setLayerInspectorOpen(false);
|
||||||
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
setSceneWindowOrder((current) => current.filter((candidate) => candidate !== windowId));
|
||||||
}, []);
|
}, []);
|
||||||
@@ -449,13 +451,25 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const openView = (viewId: string) => {
|
const openView = (viewId: string, profile?: WorkspaceLaunchProfile) => {
|
||||||
const definition = workspaceById(viewId);
|
const definition = workspaceById(viewId);
|
||||||
if (!definition) return;
|
if (!definition) return;
|
||||||
|
setLaunchProfile(current => workspaceLaunchProfile(current, definition.kind, profile));
|
||||||
setActiveRoot(definition.root);
|
setActiveRoot(definition.root);
|
||||||
workspace.openView(viewId);
|
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 = () => {
|
const openSource = () => {
|
||||||
if (!sceneWorkspaceActive) return;
|
if (!sceneWorkspaceActive) return;
|
||||||
setSourceDraft(sourceUrl);
|
setSourceDraft(sourceUrl);
|
||||||
@@ -477,14 +491,22 @@ export default function App() {
|
|||||||
const next = { ...displayDraftRef.current, ...patch };
|
const next = { ...displayDraftRef.current, ...patch };
|
||||||
displayDraftRef.current = next;
|
displayDraftRef.current = next;
|
||||||
setDisplayDraft(next);
|
setDisplayDraft(next);
|
||||||
|
sessionDisplayProfile.edited();
|
||||||
if (viewerSettingsCommitTimerRef.current !== null) {
|
if (viewerSettingsCommitTimerRef.current !== null) {
|
||||||
window.clearTimeout(viewerSettingsCommitTimerRef.current);
|
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 = window.setTimeout(() => {
|
||||||
viewerSettingsCommitTimerRef.current = null;
|
viewerSettingsCommitTimerRef.current = null;
|
||||||
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
sceneSettingsCommitterRef.current?.enqueue(displayDraftRef.current);
|
||||||
}, viewerSettingsQuietPeriodMs);
|
}, viewerSettingsQuietPeriodMs);
|
||||||
}, []);
|
}, [sessionDisplayProfile.edited]);
|
||||||
|
|
||||||
const flushDisplaySettings = useCallback(() => {
|
const flushDisplaySettings = useCallback(() => {
|
||||||
if (viewerSettingsCommitTimerRef.current === null) return;
|
if (viewerSettingsCommitTimerRef.current === null) return;
|
||||||
@@ -494,8 +516,15 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
const commitDisplayPatch = useCallback((patch: Partial<SceneSettings>) => {
|
||||||
|
sessionDisplayProfile.edited();
|
||||||
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
commitDisplaySettings({ ...displayDraftRef.current, ...patch });
|
||||||
}, [commitDisplaySettings]);
|
}, [commitDisplaySettings, sessionDisplayProfile.edited]);
|
||||||
|
|
||||||
|
closeDisplayRef.current = () => {
|
||||||
|
const settings = {...displayDraftRef.current};
|
||||||
|
commitDisplaySettings(settings);
|
||||||
|
if (replayActiveRef.current) sessionDisplayProfile.save(settings);
|
||||||
|
};
|
||||||
|
|
||||||
const openDisplay = () => {
|
const openDisplay = () => {
|
||||||
if (!sceneWorkspaceActive) return;
|
if (!sceneWorkspaceActive) return;
|
||||||
@@ -629,6 +658,7 @@ export default function App() {
|
|||||||
return () => window.clearTimeout(timer);
|
return () => window.clearTimeout(timer);
|
||||||
}, [layoutSaveNotice]);
|
}, [layoutSaveNotice]);
|
||||||
|
|
||||||
|
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||||
const contentActions = useApplicationPanelActions({
|
const contentActions = useApplicationPanelActions({
|
||||||
definition: activeDefinition,
|
definition: activeDefinition,
|
||||||
refreshRuntime: runtime.refresh,
|
refreshRuntime: runtime.refresh,
|
||||||
@@ -637,6 +667,7 @@ export default function App() {
|
|||||||
saveWorkspaceLayout,
|
saveWorkspaceLayout,
|
||||||
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
workspaceLayoutSaving: workspaceLayoutProfile.state === "saving",
|
||||||
systemUtilityActions: computeContourSettings.utilityActions,
|
systemUtilityActions: computeContourSettings.utilityActions,
|
||||||
|
sessionOverview: { ...sessionOverview, available: Boolean(recordedReplay && replayPresented && !sourceSwitchBlocked) },
|
||||||
});
|
});
|
||||||
|
|
||||||
const header = (
|
const header = (
|
||||||
@@ -645,8 +676,6 @@ export default function App() {
|
|||||||
brandHref="/"
|
brandHref="/"
|
||||||
brandLabel="NODEDC MISSION CORE"
|
brandLabel="NODEDC MISSION CORE"
|
||||||
center={
|
center={
|
||||||
<>
|
|
||||||
<HeaderWorkspace kind="mark" label="Mission Core" imageUrl="/nodedc-mark.svg" />
|
|
||||||
<HeaderNavigation
|
<HeaderNavigation
|
||||||
label="Архитектурные блоки пункта управления"
|
label="Архитектурные блоки пункта управления"
|
||||||
value={activeRoot ?? undefined}
|
value={activeRoot ?? undefined}
|
||||||
@@ -656,7 +685,6 @@ export default function App() {
|
|||||||
}))}
|
}))}
|
||||||
onChange={selectRoot}
|
onChange={selectRoot}
|
||||||
/>
|
/>
|
||||||
</>
|
|
||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<HeaderProfile>
|
<HeaderProfile>
|
||||||
@@ -711,6 +739,7 @@ export default function App() {
|
|||||||
<SystemNavigationPanel
|
<SystemNavigationPanel
|
||||||
title={currentRoot.title}
|
title={currentRoot.title}
|
||||||
onAdd={computeContourSettings.openCreate}
|
onAdd={computeContourSettings.openCreate}
|
||||||
|
onNavigateRoot={() => openNavigationView("modules")}
|
||||||
onClose={workspace.closeNavigation}
|
onClose={workspace.closeNavigation}
|
||||||
/>
|
/>
|
||||||
) : currentRoot ? (
|
) : currentRoot ? (
|
||||||
@@ -737,7 +766,7 @@ export default function App() {
|
|||||||
icon: <Icon name={item.icon} />,
|
icon: <Icon name={item.icon} />,
|
||||||
}))}
|
}))}
|
||||||
activeId={workspace.activeView ?? undefined}
|
activeId={workspace.activeView ?? undefined}
|
||||||
onItemChange={openView}
|
onItemChange={openNavigationView}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||||
@@ -747,7 +776,8 @@ export default function App() {
|
|||||||
{rootWorkspaces.length}{" "}
|
{rootWorkspaces.length}{" "}
|
||||||
{rootWorkspaces.length === 1
|
{rootWorkspaces.length === 1
|
||||||
? "рабочая поверхность"
|
? "рабочая поверхность"
|
||||||
: "рабочих поверхностей"}
|
: rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4
|
||||||
|
? "рабочие поверхности" : "рабочих поверхностей"}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -758,7 +788,9 @@ export default function App() {
|
|||||||
key={activeDefinition.id}
|
key={activeDefinition.id}
|
||||||
eyebrow={activeDefinition.eyebrow}
|
eyebrow={activeDefinition.eyebrow}
|
||||||
title={
|
title={
|
||||||
activeDefinition.kind === "recordings"
|
activeDefinition.kind === "vehicles"
|
||||||
|
? <>{activeDefinition.title}<span className="fleet-header-title" ref={setWorkspaceHeaderTitleHost}/></>
|
||||||
|
: activeDefinition.kind === "recordings"
|
||||||
&& replayPresented
|
&& replayPresented
|
||||||
&& recordedReplayLabel
|
&& recordedReplayLabel
|
||||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||||
@@ -802,16 +834,20 @@ export default function App() {
|
|||||||
settleRecordedReplaySwitch(outcome)}
|
settleRecordedReplaySwitch(outcome)}
|
||||||
onDeleteBegin={releaseRecordedReplayForDelete}
|
onDeleteBegin={releaseRecordedReplayForDelete}
|
||||||
/>
|
/>
|
||||||
) : activeDefinition.kind === "datasets" ? (
|
) : activeDefinition.kind === "missions" ? (
|
||||||
|
<div ref={setWorkspaceHeaderToolsHost} />
|
||||||
|
) : activeDefinition.kind === "vehicles" ? <div className="fleet-header-tools" ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||||
) : activeDefinition.kind === "lab-archive" ? (
|
) : activeDefinition.kind === "lab-archive" ? (
|
||||||
laboratoryAnnotation.control
|
laboratoryAnnotation.control
|
||||||
) : activeDefinition.kind === "observatory" ? (
|
) : activeDefinition.kind === "observatory" ? (
|
||||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||||
|
) : activeDefinition.kind === "contour-health" ? (
|
||||||
|
<div ref={setWorkspaceHeaderToolsHost} />
|
||||||
) : activeDefinition.root === "system" ? (
|
) : activeDefinition.root === "system" ? (
|
||||||
<SystemWorkspaceSelector
|
<SystemWorkspaceSelector
|
||||||
value={activeDefinition.id}
|
value={activeDefinition.id}
|
||||||
onChange={openView}
|
onChange={openNavigationView}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||||
@@ -819,15 +855,29 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
utilityActions={contentActions}
|
utilityActions={contentActions}
|
||||||
onClose={workspace.closeView}
|
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" ? (
|
{activeDefinition.kind === "device" ? (
|
||||||
|
launchProfile === 'planning' ? <PlanningConnectionWindow>
|
||||||
<DeviceWorkspace
|
<DeviceWorkspace
|
||||||
onOpenSpatialScene={() => openView("spatial-scene")}
|
onOpenSpatialScene={() => openView("spatial-scene", 'planning')}
|
||||||
onActivateAutomaticSpatialSource={activateAutomaticSpatialSource}
|
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
|
<WorkspaceRenderer
|
||||||
|
launchProfile={launchProfile}
|
||||||
definition={activeDefinition}
|
definition={activeDefinition}
|
||||||
|
headerToolsHost={workspaceHeaderToolsHost}
|
||||||
|
headerTitleHost={workspaceHeaderTitleHost}
|
||||||
state={activeRuntimeState}
|
state={activeRuntimeState}
|
||||||
backendStatus={runtime.backendStatus}
|
backendStatus={runtime.backendStatus}
|
||||||
sourceUrl={effectiveSourceUrl}
|
sourceUrl={effectiveSourceUrl}
|
||||||
@@ -868,6 +918,7 @@ export default function App() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</PlanningCaptureGuard>
|
||||||
</ApplicationPanel>
|
</ApplicationPanel>
|
||||||
) : null}
|
) : null}
|
||||||
/>
|
/>
|
||||||
@@ -1029,133 +1080,12 @@ export default function App() {
|
|||||||
onPointerDown={() => activateSceneWindow("display")}
|
onPointerDown={() => activateSceneWindow("display")}
|
||||||
onClose={() => closeSceneWindow("display")}
|
onClose={() => closeSceneWindow("display")}
|
||||||
>
|
>
|
||||||
<Inspector
|
<SceneDisplayControls displayDraft={displayDraft} stageDisplayPatch={stageDisplayPatch}
|
||||||
defaultOpen={["points"]}
|
commitDisplayPatch={replayPresented ? stageDisplayPatch : commitDisplayPatch} flushDisplaySettings={flushDisplaySettings} replayPresented={replayPresented}/>
|
||||||
singleOpen
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
id: "points",
|
|
||||||
label: "Облако точек",
|
|
||||||
description: "Размер и способ окрашивания",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div
|
|
||||||
className="scene-settings-commit-field"
|
|
||||||
onPointerUp={flushDisplaySettings}
|
|
||||||
onKeyUp={flushDisplaySettings}
|
|
||||||
onBlur={flushDisplaySettings}
|
|
||||||
>
|
|
||||||
<RangeControl
|
|
||||||
label="Размер точки"
|
|
||||||
value={displayDraft.pointSize}
|
|
||||||
min={0.5}
|
|
||||||
max={12}
|
|
||||||
step={0.5}
|
|
||||||
formatValue={(value) => `${value.toFixed(1)} пкс`}
|
|
||||||
onChange={(pointSize) => stageDisplayPatch({ pointSize })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
|
||||||
<ControlRow label="Атрибут цвета">
|
|
||||||
<Select
|
|
||||||
variant="split"
|
|
||||||
label="Атрибут цвета"
|
|
||||||
value={displayDraft.colorMode}
|
|
||||||
options={colorModeOptions}
|
|
||||||
onChange={(colorMode) => stageDisplayPatch({ colorMode })}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
</div>
|
|
||||||
<div className="scene-settings-commit-field" onBlur={flushDisplaySettings}>
|
|
||||||
<ControlRow label="Палитра">
|
|
||||||
<Select
|
|
||||||
variant="split"
|
|
||||||
label="Палитра"
|
|
||||||
value={displayDraft.palette}
|
|
||||||
options={paletteOptions}
|
|
||||||
onChange={(palette) => stageDisplayPatch({ palette })}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
</div>
|
|
||||||
{displayDraft.palette === "custom" || displayDraft.colorMode === "class" ? (
|
|
||||||
<div
|
|
||||||
className="scene-settings-commit-field"
|
|
||||||
onPointerUp={flushDisplaySettings}
|
|
||||||
onKeyUp={flushDisplaySettings}
|
|
||||||
onBlur={flushDisplaySettings}
|
|
||||||
>
|
|
||||||
<ControlRow label="Цвет точек">
|
|
||||||
<ColorField
|
|
||||||
label="Цвет точек"
|
|
||||||
value={displayDraft.customColor}
|
|
||||||
onChange={(customColor) => stageDisplayPatch({ customColor })}
|
|
||||||
/>
|
|
||||||
</ControlRow>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{replayPresented ? (
|
|
||||||
<p className="scene-window-note">
|
|
||||||
Первый новый режим читает индекс архивных точек. Следующие палитры
|
|
||||||
переключаются из подготовленного цветового кэша без переэкспорта геометрии.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "history",
|
|
||||||
label: "Накопление и время",
|
|
||||||
description: "История облака и траектория",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div
|
|
||||||
className="scene-settings-commit-field"
|
|
||||||
onPointerUp={flushDisplaySettings}
|
|
||||||
onKeyUp={flushDisplaySettings}
|
|
||||||
onBlur={flushDisplaySettings}
|
|
||||||
>
|
|
||||||
<RangeControl
|
|
||||||
label="Окно накопления"
|
|
||||||
value={displayDraft.accumulationSeconds}
|
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
step={1}
|
|
||||||
formatValue={(value) => (value === 0 ? "Только кадр" : `${value} с`)}
|
|
||||||
onChange={(accumulationSeconds) => stageDisplayPatch({ accumulationSeconds })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Линия пути устройства в координатах сцены</span>
|
|
||||||
<Checker
|
|
||||||
checked={displayDraft.showTrajectory}
|
|
||||||
label="Показывать траекторию"
|
|
||||||
onChange={(showTrajectory) => commitDisplayPatch({ showTrajectory })}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "scene",
|
|
||||||
label: "Окружение сцены",
|
|
||||||
description: "Сетка, подписи и камеры",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<Checker checked={displayDraft.showGrid} label="Сетка и оси" onChange={(showGrid) => commitDisplayPatch({ showGrid })} />
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Появятся после подключения семантических сущностей</span>
|
|
||||||
<Checker checked={false} disabled label="Подписи сущностей" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Window>
|
</Window>
|
||||||
|
<ToastStack items={sessionDisplayProfile.error ? [{id: 'session-display-profile', tone: 'error',
|
||||||
|
title: 'Настройки записи', description: sessionDisplayProfile.error}] : []}
|
||||||
|
onDismiss={sessionDisplayProfile.dismissError} />
|
||||||
|
|
||||||
<Window
|
<Window
|
||||||
open={sceneWorkspaceActive && layerInspectorOpen}
|
open={sceneWorkspaceActive && layerInspectorOpen}
|
||||||
@@ -1180,64 +1110,7 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
<p>Структура повторяет продуктовые сущности, а не внутренние панели визуального движка.</p>
|
||||||
</div>
|
</div>
|
||||||
<Inspector
|
<SceneLayerControls sceneSettings={sceneSettings} pending={runtime.pendingAction === "viewer"} applyScenePatch={applyScenePatch}/>
|
||||||
defaultOpen={["geometry"]}
|
|
||||||
singleOpen
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
id: "geometry",
|
|
||||||
label: "Геометрия",
|
|
||||||
description: "Облако точек и путь",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Основной поток геометрии лидара</span>
|
|
||||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showPoints} label="Облако точек" onChange={(showPoints) => void applyScenePatch({ showPoints })} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Положение и ориентация устройства во времени</span>
|
|
||||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showTrajectory} label="Траектория" onChange={(showTrajectory) => void applyScenePatch({ showTrajectory })} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "frames",
|
|
||||||
label: "Координаты и камеры",
|
|
||||||
description: "Преобразования и области обзора",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<Checker disabled={runtime.pendingAction === "viewer"} checked={sceneSettings.showGrid} label="Сетка и оси" onChange={(showGrid) => void applyScenePatch({ showGrid })} />
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Камерный канал пока не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Области обзора камер" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "perception",
|
|
||||||
label: "Объекты и маски",
|
|
||||||
description: "Ожидают внешние обработчики",
|
|
||||||
content: (
|
|
||||||
<div className="inspector-control-stack">
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Рамки 2D и 3D" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Маски сегментации" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
<div className="nodedc-field">
|
|
||||||
<span className="nodedc-field__description">Внешний обработчик не подключён</span>
|
|
||||||
<Checker checked={false} disabled label="Ключевые точки и треки" onChange={() => undefined} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Window>
|
</Window>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,92 +1 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
export { EnvironmentBackgroundMedia } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type {
|
|
||||||
EnvironmentBackground,
|
|
||||||
EnvironmentMediaItem,
|
|
||||||
} from "../core/environment/environmentSettings";
|
|
||||||
|
|
||||||
type ReadyEnvironmentMediaItem = EnvironmentMediaItem & {
|
|
||||||
url: string;
|
|
||||||
mediaKind: "image" | "video";
|
|
||||||
};
|
|
||||||
|
|
||||||
function isReadyMediaItem(
|
|
||||||
item: EnvironmentMediaItem,
|
|
||||||
): item is ReadyEnvironmentMediaItem {
|
|
||||||
return Boolean(item.url && item.mediaKind);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvironmentBackgroundMedia({
|
|
||||||
background,
|
|
||||||
}: {
|
|
||||||
background: EnvironmentBackground;
|
|
||||||
}) {
|
|
||||||
const items = useMemo(
|
|
||||||
() => background.items.filter(isReadyMediaItem),
|
|
||||||
[background.items],
|
|
||||||
);
|
|
||||||
const playlistIdentity = items.map((item) => `${item.id}:${item.url}`).join("|");
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
|
||||||
const [failedIds, setFailedIds] = useState<Set<string>>(new Set());
|
|
||||||
const playableItems = items.filter((item) => !failedIds.has(item.id));
|
|
||||||
const activeItem = playableItems[activeIndex] ?? playableItems[0] ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setActiveIndex(0);
|
|
||||||
setFailedIds(new Set());
|
|
||||||
}, [playlistIdentity]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
!background.enabled
|
|
||||||
|| !activeItem
|
|
||||||
|| activeItem.mediaKind !== "image"
|
|
||||||
|| playableItems.length < 2
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const timer = window.setTimeout(() => {
|
|
||||||
setActiveIndex((current) => (current + 1) % playableItems.length);
|
|
||||||
}, background.imageDurationSeconds * 1_000);
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [
|
|
||||||
activeItem,
|
|
||||||
background.enabled,
|
|
||||||
background.imageDurationSeconds,
|
|
||||||
playableItems.length,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!background.enabled || !activeItem) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="landing-stage__media" aria-hidden="true">
|
|
||||||
{activeItem.mediaKind === "video" ? (
|
|
||||||
<video
|
|
||||||
key={activeItem.url}
|
|
||||||
src={activeItem.url}
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop={playableItems.length === 1}
|
|
||||||
playsInline
|
|
||||||
onEnded={() => setActiveIndex((current) => (
|
|
||||||
(current + 1) % playableItems.length
|
|
||||||
))}
|
|
||||||
onError={() => {
|
|
||||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
|
||||||
setActiveIndex(0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<img
|
|
||||||
key={activeItem.url}
|
|
||||||
src={activeItem.url}
|
|
||||||
alt=""
|
|
||||||
onError={() => {
|
|
||||||
setFailedIds((current) => new Set(current).add(activeItem.id));
|
|
||||||
setActiveIndex(0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,248 +1 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
export { EnvironmentMediaPlaylistEditor } from "@nodedc/ui-react";
|
||||||
import {
|
|
||||||
Icon,
|
|
||||||
IconButton,
|
|
||||||
MediaSourceField,
|
|
||||||
RangeControl,
|
|
||||||
SortableList,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
appendEnvironmentMediaItem,
|
|
||||||
inferEnvironmentMediaKind,
|
|
||||||
maxEnvironmentMediaItems,
|
|
||||||
removeEnvironmentMediaItem,
|
|
||||||
type EnvironmentBackground,
|
|
||||||
type EnvironmentMediaItem,
|
|
||||||
type EnvironmentSurfaceId,
|
|
||||||
type UploadedEnvironmentMedia,
|
|
||||||
} from "../core/environment/environmentSettings";
|
|
||||||
|
|
||||||
interface EnvironmentMediaPlaylistEditorProps {
|
|
||||||
surfaceId: EnvironmentSurfaceId;
|
|
||||||
background: EnvironmentBackground;
|
|
||||||
disabled: boolean;
|
|
||||||
error: string | null;
|
|
||||||
onChange: (background: EnvironmentBackground) => void;
|
|
||||||
onBusyChange: (busy: boolean) => void;
|
|
||||||
onUpload: (
|
|
||||||
surfaceId: EnvironmentSurfaceId,
|
|
||||||
itemId: string,
|
|
||||||
file: File,
|
|
||||||
) => Promise<UploadedEnvironmentMedia>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const acceptedEnvironmentMedia = [
|
|
||||||
"image/png",
|
|
||||||
"image/jpeg",
|
|
||||||
"image/gif",
|
|
||||||
"image/webp",
|
|
||||||
"image/avif",
|
|
||||||
"video/mp4",
|
|
||||||
"video/webm",
|
|
||||||
"video/quicktime",
|
|
||||||
".png",
|
|
||||||
".jpg",
|
|
||||||
".jpeg",
|
|
||||||
".gif",
|
|
||||||
".webp",
|
|
||||||
".avif",
|
|
||||||
".mp4",
|
|
||||||
".webm",
|
|
||||||
".mov",
|
|
||||||
].join(",");
|
|
||||||
|
|
||||||
function patchItem(
|
|
||||||
background: EnvironmentBackground,
|
|
||||||
itemId: string,
|
|
||||||
patch: Partial<EnvironmentMediaItem>,
|
|
||||||
): EnvironmentBackground {
|
|
||||||
return {
|
|
||||||
...background,
|
|
||||||
items: background.items.map((item) => (
|
|
||||||
item.id === itemId ? { ...item, ...patch } : item
|
|
||||||
)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvironmentMediaPlaylistEditor({
|
|
||||||
surfaceId,
|
|
||||||
background,
|
|
||||||
disabled,
|
|
||||||
error,
|
|
||||||
onChange,
|
|
||||||
onBusyChange,
|
|
||||||
onUpload,
|
|
||||||
}: EnvironmentMediaPlaylistEditorProps) {
|
|
||||||
const [uploadingIds, setUploadingIds] = useState<Set<string>>(new Set());
|
|
||||||
const [itemErrors, setItemErrors] = useState<Record<string, string>>({});
|
|
||||||
const backgroundRef = useRef(background);
|
|
||||||
backgroundRef.current = background;
|
|
||||||
const displayedItems = useMemo(
|
|
||||||
() => [...background.items].reverse(),
|
|
||||||
[background.items],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
onBusyChange(uploadingIds.size > 0);
|
|
||||||
}, [onBusyChange, uploadingIds.size]);
|
|
||||||
|
|
||||||
useEffect(() => () => onBusyChange(false), [onBusyChange]);
|
|
||||||
|
|
||||||
const setItemError = (itemId: string, message?: string) => {
|
|
||||||
setItemErrors((current) => {
|
|
||||||
const next = { ...current };
|
|
||||||
if (message) next[itemId] = message;
|
|
||||||
else delete next[itemId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFile = async (itemId: string, file?: File) => {
|
|
||||||
if (!file) return;
|
|
||||||
setUploadingIds((current) => new Set(current).add(itemId));
|
|
||||||
setItemError(itemId);
|
|
||||||
try {
|
|
||||||
const uploaded = await onUpload(surfaceId, itemId, file);
|
|
||||||
onChange(patchItem(backgroundRef.current, itemId, {
|
|
||||||
source: "file",
|
|
||||||
url: uploaded.url,
|
|
||||||
mediaKind: uploaded.mediaKind,
|
|
||||||
fileName: uploaded.fileName,
|
|
||||||
}));
|
|
||||||
} catch (reason) {
|
|
||||||
setItemError(
|
|
||||||
itemId,
|
|
||||||
reason instanceof Error
|
|
||||||
? reason.message
|
|
||||||
: "Не удалось загрузить медиаконтент.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setUploadingIds((current) => {
|
|
||||||
const next = new Set(current);
|
|
||||||
next.delete(itemId);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="environment-media-playlist">
|
|
||||||
<div className="environment-media-playlist__head">
|
|
||||||
<div>
|
|
||||||
<span>Видео / картинка</span>
|
|
||||||
<p>MP4, WebM, MOV, PNG, JPEG, GIF, WebP или AVIF · до 256 МБ.</p>
|
|
||||||
</div>
|
|
||||||
<IconButton
|
|
||||||
label="Добавить медиаконтент"
|
|
||||||
disabled={disabled || background.items.length >= maxEnvironmentMediaItems}
|
|
||||||
onClick={() => onChange(appendEnvironmentMediaItem(background))}
|
|
||||||
>
|
|
||||||
<Icon name="plus" />
|
|
||||||
</IconButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{displayedItems.length ? (
|
|
||||||
<SortableList
|
|
||||||
items={displayedItems}
|
|
||||||
getId={(item) => item.id}
|
|
||||||
className="environment-media-playlist__items"
|
|
||||||
onReorder={(items) => onChange({
|
|
||||||
...background,
|
|
||||||
items: [...items].reverse(),
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{(item, { handle }) => {
|
|
||||||
const playbackIndex = background.items.findIndex(
|
|
||||||
(candidate) => candidate.id === item.id,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="environment-media-playlist__item">
|
|
||||||
<MediaSourceField
|
|
||||||
label={`Медиаконтент ${String(playbackIndex + 1).padStart(2, "0")}`}
|
|
||||||
kindLabel={item.mediaKind ?? "media"}
|
|
||||||
source={item.source}
|
|
||||||
url={item.url ?? ""}
|
|
||||||
fileName={item.fileName}
|
|
||||||
uploading={uploadingIds.has(item.id)}
|
|
||||||
previewSrc={item.url}
|
|
||||||
previewKind={item.mediaKind}
|
|
||||||
accept={acceptedEnvironmentMedia}
|
|
||||||
path={`${surfaceId}.background.items[${playbackIndex}] → server environment media`}
|
|
||||||
hint="Файл сохраняется в Mission Core data root. URL должен вести прямо на media по HTTP(S)."
|
|
||||||
error={itemErrors[item.id] ?? (
|
|
||||||
playbackIndex === background.items.length - 1 ? error : null
|
|
||||||
)}
|
|
||||||
onSourceChange={(source) => {
|
|
||||||
if (source === item.source) return;
|
|
||||||
setItemError(item.id);
|
|
||||||
onChange(patchItem(background, item.id, {
|
|
||||||
source,
|
|
||||||
url: null,
|
|
||||||
mediaKind: null,
|
|
||||||
fileName: null,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
onUrlChange={(url) => {
|
|
||||||
setItemError(item.id);
|
|
||||||
onChange(patchItem(background, item.id, {
|
|
||||||
source: "url",
|
|
||||||
url: url || null,
|
|
||||||
mediaKind: url ? inferEnvironmentMediaKind(url) : null,
|
|
||||||
fileName: null,
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
onFileChange={(file) => void uploadFile(item.id, file)}
|
|
||||||
/>
|
|
||||||
<div className="environment-media-playlist__item-actions">
|
|
||||||
<IconButton
|
|
||||||
label={`Удалить медиаконтент ${playbackIndex + 1}`}
|
|
||||||
disabled={disabled || uploadingIds.has(item.id)}
|
|
||||||
onClick={() => {
|
|
||||||
setItemError(item.id);
|
|
||||||
onChange(removeEnvironmentMediaItem(background, item.id));
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon name="trash" />
|
|
||||||
</IconButton>
|
|
||||||
{handle}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</SortableList>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<p className="environment-media-playlist__empty">
|
|
||||||
Добавьте первый файл или прямую ссылку на медиаконтент.
|
|
||||||
</p>
|
|
||||||
{error ? (
|
|
||||||
<p className="environment-media-playlist__error" role="alert">
|
|
||||||
{error}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="environment-media-playlist__timing">
|
|
||||||
<RangeControl
|
|
||||||
label="Показывать изображение"
|
|
||||||
value={background.imageDurationSeconds}
|
|
||||||
min={1}
|
|
||||||
max={60}
|
|
||||||
step={1}
|
|
||||||
disabled={disabled}
|
|
||||||
formatValue={(value) => `${value} с`}
|
|
||||||
onChange={(imageDurationSeconds) => onChange({
|
|
||||||
...background,
|
|
||||||
imageDurationSeconds,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
Новые элементы появляются сверху. Воспроизведение начинается снизу;
|
|
||||||
перетаскивание меняет порядок.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,358 +1,25 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { EnvironmentSettingsWindow as SharedEnvironmentSettingsWindow } from "@nodedc/ui-react";
|
||||||
import {
|
import { environmentSurfaceIds, type EnvironmentSettings, type EnvironmentSurfaceId, type UploadedEnvironmentMedia } from "../core/environment/environmentSettings";
|
||||||
Button,
|
import { roots, workspaces, workspacesForRoot } from "../productModel";
|
||||||
FeatureSettingsWindow,
|
|
||||||
Select,
|
|
||||||
SettingsCard,
|
|
||||||
Switch,
|
|
||||||
TextAreaField,
|
|
||||||
TextField,
|
|
||||||
WindowFooterActions,
|
|
||||||
} from "@nodedc/ui-react";
|
|
||||||
|
|
||||||
import {
|
interface Props {
|
||||||
cloneEnvironmentSettings,
|
|
||||||
type EnvironmentBackground,
|
|
||||||
type EnvironmentPage,
|
|
||||||
type EnvironmentSettings,
|
|
||||||
type EnvironmentSurfaceId,
|
|
||||||
type UploadedEnvironmentMedia,
|
|
||||||
} from "../core/environment/environmentSettings";
|
|
||||||
import { EnvironmentMediaPlaylistEditor } from "./EnvironmentMediaPlaylistEditor";
|
|
||||||
import {
|
|
||||||
roots,
|
|
||||||
workspaces,
|
|
||||||
workspacesForRoot,
|
|
||||||
} from "../productModel";
|
|
||||||
|
|
||||||
interface EnvironmentSettingsWindowProps {
|
|
||||||
open: boolean;
|
open: boolean;
|
||||||
settings: EnvironmentSettings;
|
settings: EnvironmentSettings;
|
||||||
state: "loading" | "ready" | "saving" | "error";
|
state: "loading" | "ready" | "saving" | "error";
|
||||||
error: string | null;
|
error: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
onSave: (settings: EnvironmentSettings) => Promise<EnvironmentSettings>;
|
||||||
onUpload: (
|
onUpload: (surfaceId: EnvironmentSurfaceId, itemId: string, file: File) => Promise<UploadedEnvironmentMedia>;
|
||||||
surfaceId: EnvironmentSurfaceId,
|
|
||||||
itemId: string,
|
|
||||||
file: File,
|
|
||||||
) => Promise<UploadedEnvironmentMedia>;
|
|
||||||
}
|
}
|
||||||
|
const surfaces = environmentSurfaceIds.map(id => ({
|
||||||
function patchPage(
|
id, home: id === "home", description: id === "home" ? "Главная страница продукта" : `Стартовая страница раздела «${roots.find(root => root.id === id)?.label}»`,
|
||||||
draft: EnvironmentSettings,
|
actions: id === "home" ? workspaces.filter(item => !item.internalOnly) : workspacesForRoot(id),
|
||||||
surfaceId: EnvironmentSurfaceId,
|
}));
|
||||||
patch: Partial<EnvironmentPage>,
|
export function EnvironmentSettingsWindow(props: Props) {
|
||||||
): EnvironmentSettings {
|
return <SharedEnvironmentSettingsWindow {...props} productName="Mission Core" surfaces={surfaces} initialSurfaceId="fleet"
|
||||||
return {
|
onSave={draft => props.onSave({ ...draft, pages: Object.fromEntries(environmentSurfaceIds.map(id => [id, draft.pages[id]])) as EnvironmentSettings["pages"] })}
|
||||||
...draft,
|
onUpload={(id, itemId, file) => {
|
||||||
pages: {
|
if (!environmentSurfaceIds.includes(id as EnvironmentSurfaceId)) throw new Error("Страница окружения недоступна.");
|
||||||
...draft.pages,
|
return props.onUpload(id as EnvironmentSurfaceId, itemId, file);
|
||||||
[surfaceId]: {
|
}} />;
|
||||||
...draft.pages[surfaceId],
|
|
||||||
...patch,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchBackground(
|
|
||||||
draft: EnvironmentSettings,
|
|
||||||
surfaceId: EnvironmentSurfaceId,
|
|
||||||
patch: Partial<EnvironmentBackground>,
|
|
||||||
): EnvironmentSettings {
|
|
||||||
const page = draft.pages[surfaceId];
|
|
||||||
return patchPage(draft, surfaceId, {
|
|
||||||
background: {
|
|
||||||
...page.background,
|
|
||||||
...patch,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function EnvironmentSettingsWindow({
|
|
||||||
open,
|
|
||||||
settings,
|
|
||||||
state,
|
|
||||||
error,
|
|
||||||
onClose,
|
|
||||||
onSave,
|
|
||||||
onUpload,
|
|
||||||
}: EnvironmentSettingsWindowProps) {
|
|
||||||
const [draft, setDraft] = useState(() => cloneEnvironmentSettings(settings));
|
|
||||||
const [surfaceId, setSurfaceId] = useState<EnvironmentSurfaceId>("fleet");
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [localError, setLocalError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return;
|
|
||||||
setDraft(cloneEnvironmentSettings(settings));
|
|
||||||
setLocalError(null);
|
|
||||||
}, [open, settings]);
|
|
||||||
|
|
||||||
const selectedPage = draft.pages[surfaceId];
|
|
||||||
const selectedBackground = selectedPage.background;
|
|
||||||
const pageOptions = useMemo(() => [
|
|
||||||
{
|
|
||||||
value: "home" as const,
|
|
||||||
label: draft.pages.home.headerLabel,
|
|
||||||
description: "Главная страница продукта",
|
|
||||||
},
|
|
||||||
...roots.map((root) => ({
|
|
||||||
value: root.id,
|
|
||||||
label: draft.pages[root.id].headerLabel,
|
|
||||||
description: `Стартовая страница раздела «${draft.pages[root.id].title}»`,
|
|
||||||
})),
|
|
||||||
], [draft.pages]);
|
|
||||||
const quickActionWorkspaces = useMemo(
|
|
||||||
() => surfaceId === "home"
|
|
||||||
? workspaces.filter((workspace) => !workspace.internalOnly)
|
|
||||||
: workspacesForRoot(surfaceId),
|
|
||||||
[surfaceId],
|
|
||||||
);
|
|
||||||
const quickActionOptions = useMemo(() => [
|
|
||||||
{
|
|
||||||
value: "none",
|
|
||||||
label: "Не показывать",
|
|
||||||
description: "Кнопка скрыта на стартовом экране",
|
|
||||||
},
|
|
||||||
...quickActionWorkspaces.map((workspace) => ({
|
|
||||||
value: workspace.id,
|
|
||||||
label: workspace.label,
|
|
||||||
description: workspace.description,
|
|
||||||
})),
|
|
||||||
], [quickActionWorkspaces]);
|
|
||||||
const dirty = useMemo(
|
|
||||||
() => JSON.stringify(draft) !== JSON.stringify(settings),
|
|
||||||
[draft, settings],
|
|
||||||
);
|
|
||||||
const busy = state === "saving" || uploading;
|
|
||||||
|
|
||||||
const updatePage = (patch: Partial<EnvironmentPage>) => {
|
|
||||||
setDraft((current) => patchPage(current, surfaceId, patch));
|
|
||||||
};
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
const invalid = Object.entries(draft.pages).find(([, page]) => (
|
|
||||||
!page.headerLabel.trim()
|
|
||||||
|| !page.eyebrow.trim()
|
|
||||||
|| !page.title.trim()
|
|
||||||
|| !page.description.trim()
|
|
||||||
));
|
|
||||||
if (invalid) {
|
|
||||||
setLocalError("Название, надзаголовок, заголовок и описание не могут быть пустыми.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
selectedPage.primaryWorkspaceId
|
|
||||||
&& selectedPage.primaryWorkspaceId === selectedPage.secondaryWorkspaceId
|
|
||||||
) {
|
|
||||||
setLocalError("Быстрые кнопки должны вести на разные рабочие поверхности.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const invalidMedia = Object.entries(draft.pages).find(([, page]) => (
|
|
||||||
(page.background.enabled && !page.background.items.length)
|
|
||||||
|| page.background.items.some((item) => {
|
|
||||||
if (!item.url || !item.mediaKind) return true;
|
|
||||||
if (item.source !== "url") return false;
|
|
||||||
try {
|
|
||||||
const parsed = new URL(item.url);
|
|
||||||
return !["http:", "https:"].includes(parsed.protocol);
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
));
|
|
||||||
if (invalidMedia) {
|
|
||||||
setSurfaceId(invalidMedia[0] as EnvironmentSurfaceId);
|
|
||||||
setLocalError(
|
|
||||||
"Каждый элемент фона должен содержать загруженный файл или прямой HTTP(S) URL.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLocalError(null);
|
|
||||||
try {
|
|
||||||
await onSave({
|
|
||||||
...draft,
|
|
||||||
pages: Object.fromEntries(
|
|
||||||
Object.entries(draft.pages).map(([id, page]) => [id, {
|
|
||||||
...page,
|
|
||||||
headerLabel: page.headerLabel.trim(),
|
|
||||||
eyebrow: page.eyebrow.trim(),
|
|
||||||
title: page.title.trim(),
|
|
||||||
description: page.description.trim(),
|
|
||||||
}]),
|
|
||||||
) as EnvironmentSettings["pages"],
|
|
||||||
});
|
|
||||||
onClose();
|
|
||||||
} catch (reason) {
|
|
||||||
setLocalError(reason instanceof Error
|
|
||||||
? reason.message
|
|
||||||
: "Не удалось сохранить настройки окружения.");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FeatureSettingsWindow
|
|
||||||
open={open}
|
|
||||||
title="Настройки Mission Core"
|
|
||||||
subtitle="Локальное операторское окружение"
|
|
||||||
identity={{
|
|
||||||
title: "DC",
|
|
||||||
subtitle: "Mission Core",
|
|
||||||
avatarLabel: "DC",
|
|
||||||
}}
|
|
||||||
sections={[
|
|
||||||
{
|
|
||||||
id: "environment",
|
|
||||||
label: "Окружение",
|
|
||||||
group: "MISSION CORE",
|
|
||||||
icon: "settings",
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
activeSection="environment"
|
|
||||||
onSectionChange={() => undefined}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={(
|
|
||||||
<WindowFooterActions>
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!dirty || busy}
|
|
||||||
onClick={() => {
|
|
||||||
setDraft(cloneEnvironmentSettings(settings));
|
|
||||||
setLocalError(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сбросить изменения
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
disabled={!dirty || busy}
|
|
||||||
onClick={() => void save()}
|
|
||||||
>
|
|
||||||
{state === "saving" ? "Сохраняем…" : "Сохранить"}
|
|
||||||
</Button>
|
|
||||||
</WindowFooterActions>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="environment-settings">
|
|
||||||
<SettingsCard
|
|
||||||
eyebrow="ОКРУЖЕНИЕ"
|
|
||||||
title="Основные элементы управления"
|
|
||||||
description="Выберите страницу и настройте её название в шапке, содержание стартового экрана, подложку и быстрые переходы."
|
|
||||||
actions={(
|
|
||||||
<Switch
|
|
||||||
checked={selectedBackground.enabled}
|
|
||||||
label="Показывать фон"
|
|
||||||
onChange={(enabled) => {
|
|
||||||
if (enabled && !selectedBackground.items.length) {
|
|
||||||
setLocalError("Сначала добавьте медиаконтент.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLocalError(null);
|
|
||||||
setDraft((current) =>
|
|
||||||
patchBackground(current, surfaceId, { enabled }));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="environment-settings__editor">
|
|
||||||
<div className="environment-settings__surface">
|
|
||||||
<span>Страница</span>
|
|
||||||
<Select
|
|
||||||
label="Выбрать страницу окружения"
|
|
||||||
value={surfaceId}
|
|
||||||
options={pageOptions}
|
|
||||||
variant="split"
|
|
||||||
menuWidth="anchor"
|
|
||||||
onChange={(value) => {
|
|
||||||
setSurfaceId(value);
|
|
||||||
setLocalError(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="environment-settings__copy">
|
|
||||||
<TextField
|
|
||||||
label={surfaceId === "home" ? "Название продукта" : "Название в шапке"}
|
|
||||||
value={selectedPage.headerLabel}
|
|
||||||
maxLength={40}
|
|
||||||
onChange={(event) => updatePage({
|
|
||||||
headerLabel: event.currentTarget.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Надзаголовок"
|
|
||||||
value={selectedPage.eyebrow}
|
|
||||||
maxLength={80}
|
|
||||||
onChange={(event) => updatePage({
|
|
||||||
eyebrow: event.currentTarget.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<TextField
|
|
||||||
label="Основной заголовок"
|
|
||||||
value={selectedPage.title}
|
|
||||||
maxLength={120}
|
|
||||||
onChange={(event) => updatePage({
|
|
||||||
title: event.currentTarget.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<TextAreaField
|
|
||||||
label="Описание"
|
|
||||||
value={selectedPage.description}
|
|
||||||
maxLength={500}
|
|
||||||
rows={3}
|
|
||||||
onChange={(event) => updatePage({
|
|
||||||
description: event.currentTarget.value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="environment-settings__quick-actions">
|
|
||||||
<div>
|
|
||||||
<span>Кнопка 1</span>
|
|
||||||
<Select
|
|
||||||
label="Выбрать первую быструю кнопку"
|
|
||||||
value={selectedPage.primaryWorkspaceId ?? "none"}
|
|
||||||
options={quickActionOptions}
|
|
||||||
variant="split"
|
|
||||||
menuWidth="anchor"
|
|
||||||
onChange={(value) => updatePage({
|
|
||||||
primaryWorkspaceId: value === "none" ? null : value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Кнопка 2</span>
|
|
||||||
<Select
|
|
||||||
label="Выбрать вторую быструю кнопку"
|
|
||||||
value={selectedPage.secondaryWorkspaceId ?? "none"}
|
|
||||||
options={quickActionOptions}
|
|
||||||
variant="split"
|
|
||||||
menuWidth="anchor"
|
|
||||||
onChange={(value) => updatePage({
|
|
||||||
secondaryWorkspaceId: value === "none" ? null : value,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EnvironmentMediaPlaylistEditor
|
|
||||||
surfaceId={surfaceId}
|
|
||||||
background={selectedBackground}
|
|
||||||
disabled={busy}
|
|
||||||
error={localError ?? error}
|
|
||||||
onBusyChange={setUploading}
|
|
||||||
onChange={(background) => {
|
|
||||||
setLocalError(null);
|
|
||||||
setDraft((current) =>
|
|
||||||
patchBackground(current, surfaceId, background));
|
|
||||||
}}
|
|
||||||
onUpload={onUpload}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</SettingsCard>
|
|
||||||
</div>
|
|
||||||
</FeatureSettingsWindow>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useLayoutEffect, useState, type RefObject } from "react";
|
import type {RefObject} from "react";
|
||||||
import { WorkspaceWindow } from "@nodedc/ui-react";
|
import {FloatingMediaWindow} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||||
|
export {initialObservationWindowRect,shouldCaptureWorkspacePointer} from "../../../../packages/spatial-ui/src/FloatingMediaWindow";
|
||||||
|
|
||||||
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
import type { ObservationWindowRect } from "../core/observation/useObservationLayout";
|
||||||
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
import type { ObservationSourceDescriptor } from "../core/runtime/contracts";
|
||||||
@@ -10,60 +11,6 @@ import type {
|
|||||||
RecordedCameraAdmissionState,
|
RecordedCameraAdmissionState,
|
||||||
} from "../core/observation/recordedSessionAdmission";
|
} from "../core/observation/recordedSessionAdmission";
|
||||||
|
|
||||||
const WINDOW_WIDTH = 336;
|
|
||||||
const WINDOW_HEIGHT = 210;
|
|
||||||
const WINDOW_GAP = 16;
|
|
||||||
const WINDOW_INSET = 18;
|
|
||||||
const TIMELINE_CLEARANCE = 64;
|
|
||||||
|
|
||||||
type ClosestTarget = { closest: (selectors: string) => unknown };
|
|
||||||
|
|
||||||
export function shouldCaptureWorkspacePointer(
|
|
||||||
button: number,
|
|
||||||
target: ClosestTarget | null,
|
|
||||||
): boolean {
|
|
||||||
if (button !== 0 || !target) return false;
|
|
||||||
if (target.closest(".nodedc-workspace-window__resize")) return true;
|
|
||||||
if (!target.closest(".nodedc-workspace-window__head")) return false;
|
|
||||||
return !target.closest("button, input, select, textarea, a");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initialObservationWindowRect(
|
|
||||||
index: number,
|
|
||||||
count = 1,
|
|
||||||
bounds: { width: number; height: number } = { width: 1280, height: 720 },
|
|
||||||
): ObservationWindowRect {
|
|
||||||
const availableWidth = Math.max(0, bounds.width - WINDOW_INSET * 2);
|
|
||||||
const width = Math.max(1, Math.min(WINDOW_WIDTH, availableWidth || WINDOW_WIDTH));
|
|
||||||
const availableHeight = Math.max(0, bounds.height - WINDOW_INSET - TIMELINE_CLEARANCE);
|
|
||||||
const maxColumns = Math.max(
|
|
||||||
1,
|
|
||||||
Math.floor((availableWidth + WINDOW_GAP) / (width + WINDOW_GAP)),
|
|
||||||
);
|
|
||||||
const columns = Math.max(1, Math.min(Math.max(1, count), maxColumns));
|
|
||||||
const rows = Math.max(1, Math.ceil(Math.max(1, count) / columns));
|
|
||||||
const rowHeight = Math.max(
|
|
||||||
1,
|
|
||||||
(availableHeight - Math.max(0, rows - 1) * WINDOW_GAP) / rows,
|
|
||||||
);
|
|
||||||
const height = Math.max(1, Math.min(WINDOW_HEIGHT, rowHeight));
|
|
||||||
const row = Math.floor(index / columns);
|
|
||||||
const column = index % columns;
|
|
||||||
const rowItemCount = Math.min(columns, Math.max(1, count - row * columns));
|
|
||||||
const rowWidth = rowItemCount * width + Math.max(0, rowItemCount - 1) * WINDOW_GAP;
|
|
||||||
const rowStart = Math.max(0, bounds.width - WINDOW_INSET - rowWidth);
|
|
||||||
|
|
||||||
return {
|
|
||||||
x: rowStart + Math.min(column, rowItemCount - 1) * (width + WINDOW_GAP),
|
|
||||||
y: Math.max(
|
|
||||||
0,
|
|
||||||
bounds.height - TIMELINE_CLEARANCE - height - row * (height + WINDOW_GAP),
|
|
||||||
),
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingObservationWindow({
|
export function FloatingObservationWindow({
|
||||||
source,
|
source,
|
||||||
index,
|
index,
|
||||||
@@ -104,76 +51,12 @@ export function FloatingObservationWindow({
|
|||||||
state: RecordedCameraAdmissionState,
|
state: RecordedCameraAdmissionState,
|
||||||
) => void;
|
) => void;
|
||||||
}) {
|
}) {
|
||||||
const [bounds, setBounds] = useState<{ width: number; height: number } | null>(null);
|
return <FloatingMediaWindow title={source.label} subtitle={source.description}
|
||||||
|
index={index} count={count} boundsRef={boundsRef} rect={rect} maximized={maximized}
|
||||||
useLayoutEffect(() => {
|
active={active} hidden={hidden} onRectChange={onRectChange} onMaximizedChange={onMaximizedChange}
|
||||||
const element = boundsRef.current;
|
onActivate={onActivate} onClose={onClose} resizable={source.capabilities.resizable}
|
||||||
if (!element) return;
|
status={<span className="floating-observation-window__status"><i data-availability={source.availability} aria-hidden="true"/>{observationSourceStatusLabel(source)}</span>}
|
||||||
|
footer={<span className="floating-observation-window__footer"><span>{source.endpointLabel || source.transport}</span><span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span></span>}>
|
||||||
const measure = () => {
|
|
||||||
const next = { width: element.clientWidth, height: element.clientHeight };
|
|
||||||
setBounds((current) => (
|
|
||||||
current?.width === next.width && current.height === next.height ? current : next
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
measure();
|
|
||||||
const observer = new ResizeObserver(measure);
|
|
||||||
observer.observe(element);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}, [boundsRef]);
|
|
||||||
|
|
||||||
// Camera media admission must not wait for the viewport observer. A source
|
|
||||||
// can arrive in the same commit as a workspace transition; mounting it with
|
|
||||||
// the canonical fallback geometry keeps the browser transport alive until
|
|
||||||
// the real bounds are measured immediately afterward.
|
|
||||||
const initialRect = initialObservationWindowRect(index, count, bounds ?? undefined);
|
|
||||||
const windowRect = rect ?? initialRect;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<WorkspaceWindow
|
|
||||||
boundsRef={boundsRef}
|
|
||||||
rect={windowRect}
|
|
||||||
onRectChange={onRectChange}
|
|
||||||
maximized={maximized}
|
|
||||||
onMaximizedChange={onMaximizedChange}
|
|
||||||
onActivate={onActivate}
|
|
||||||
onClose={onClose}
|
|
||||||
title={source.label}
|
|
||||||
subtitle={source.description}
|
|
||||||
status={(
|
|
||||||
<span className="floating-observation-window__status">
|
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
|
||||||
{observationSourceStatusLabel(source)}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
footer={(
|
|
||||||
<span className="floating-observation-window__footer">
|
|
||||||
<span>{source.endpointLabel || source.transport}</span>
|
|
||||||
<span>{source.capabilities.timelineMode === "live-only" ? "Эфир без буфера" : "Временная шкала"}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
minWidth={Math.min(280, windowRect.width)}
|
|
||||||
minHeight={Math.min(190, windowRect.height)}
|
|
||||||
resizable={source.capabilities.resizable}
|
|
||||||
active={active}
|
|
||||||
zIndex={maximized ? 15 : active ? 9 : 7}
|
|
||||||
className={`floating-observation-window${hidden ? " floating-observation-window--hidden" : ""}`}
|
|
||||||
onPointerDownCapture={(event) => {
|
|
||||||
if (!shouldCaptureWorkspacePointer(event.button, event.target as HTMLElement)) return;
|
|
||||||
try {
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId);
|
|
||||||
} catch {
|
|
||||||
// Pointer capture can fail if the browser ended the pointer between
|
|
||||||
// dispatch and capture. The donor's window listeners remain fallback.
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
closeLabel={`Закрыть ${source.label}`}
|
|
||||||
maximizeLabel={`Развернуть ${source.label}`}
|
|
||||||
restoreLabel={`Восстановить ${source.label}`}
|
|
||||||
moveLabel={`Переместить ${source.label}`}
|
|
||||||
resizeLabel={`Изменить размер ${source.label}`}
|
|
||||||
>
|
|
||||||
<ObservationMedia
|
<ObservationMedia
|
||||||
source={source}
|
source={source}
|
||||||
playback={playback}
|
playback={playback}
|
||||||
@@ -182,6 +65,5 @@ export function FloatingObservationWindow({
|
|||||||
recordedAdmissionKey={recordedAdmissionKey}
|
recordedAdmissionKey={recordedAdmissionKey}
|
||||||
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
onRecordedAdmissionChange={onRecordedAdmissionChange}
|
||||||
/>
|
/>
|
||||||
</WorkspaceWindow>
|
</FloatingMediaWindow>;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
import { LandingStage as SharedLandingStage, StatusBadge } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
import type { BackendStatus, RuntimePhase } from "../core/runtime/contracts";
|
||||||
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
import type { EnvironmentPage } from "../core/environment/environmentSettings";
|
||||||
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
import type { RootDefinition, WorkspaceDefinition } from "../productModel";
|
||||||
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
import { backendLabel, backendTone, phaseLabel, phaseTone } from "../presentation";
|
||||||
import { EnvironmentBackgroundMedia } from "./EnvironmentBackgroundMedia";
|
|
||||||
|
|
||||||
export interface LandingStageProps {
|
export interface LandingStageProps {
|
||||||
root: RootDefinition | null;
|
root: RootDefinition | null;
|
||||||
@@ -25,39 +24,9 @@ export function LandingStage({
|
|||||||
quickActions,
|
quickActions,
|
||||||
onOpenWorkspace,
|
onOpenWorkspace,
|
||||||
}: LandingStageProps) {
|
}: LandingStageProps) {
|
||||||
const { background } = page;
|
return <SharedLandingStage page={page} pageId={root?.id ?? "home"}
|
||||||
const hasMedia = background.enabled && background.items.some(
|
actions={quickActions.map(workspace => ({ id: workspace.id, label: workspace.label, icon: workspace.icon, onSelect: () => onOpenWorkspace(workspace.id) }))}
|
||||||
(item) => item.url && item.mediaKind,
|
status={<>
|
||||||
);
|
|
||||||
return (
|
|
||||||
<section
|
|
||||||
className="landing-stage"
|
|
||||||
data-root={root?.id ?? "home"}
|
|
||||||
data-has-media={hasMedia ? "true" : undefined}
|
|
||||||
>
|
|
||||||
<EnvironmentBackgroundMedia background={background} />
|
|
||||||
<div className="landing-stage__shade" aria-hidden="true" />
|
|
||||||
<div className="landing-stage__copy">
|
|
||||||
<span className="section-eyebrow">{page.eyebrow}</span>
|
|
||||||
<h1>{page.title}</h1>
|
|
||||||
<p>{page.description}</p>
|
|
||||||
{quickActions.length ? (
|
|
||||||
<div className="landing-stage__actions">
|
|
||||||
{quickActions.map((workspace, index) => (
|
|
||||||
<Button
|
|
||||||
key={workspace.id}
|
|
||||||
variant={index === 0 ? "primary" : "secondary"}
|
|
||||||
icon={<Icon name={workspace.icon} />}
|
|
||||||
onClick={() => onOpenWorkspace(workspace.id)}
|
|
||||||
>
|
|
||||||
{workspace.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="landing-stage__status">
|
|
||||||
<div>
|
<div>
|
||||||
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
<span className="section-eyebrow">ЛОКАЛЬНЫЙ КОНТУР</span>
|
||||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||||
@@ -67,12 +36,9 @@ export function LandingStage({
|
|||||||
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
<StatusBadge tone={phaseTone(phase)}>{phaseLabel(phase)}</StatusBadge>
|
||||||
</div>
|
</div>
|
||||||
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
<p>{message || "Выберите архитектурный блок в верхней навигации."}</p>
|
||||||
</div>
|
</>}
|
||||||
|
footer={<>
|
||||||
<footer className="landing-stage__footer">
|
|
||||||
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
<span>{root?.accent ?? "НАБЛЮДЕНИЕ · МИССИИ · ДАННЫЕ"}</span>
|
||||||
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
<span>01 / ЛОКАЛЬНЫЙ СТЕНД</span>
|
||||||
</footer>
|
</>} />;
|
||||||
</section>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface CameraLeaseRetryBudget {
|
|||||||
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
const CAMERA_LEASE_RETRY_DELAYS = [400, 1_000, 2_000, 5_000] as const;
|
||||||
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
export const CAMERA_FIRST_MEDIA_TIMEOUT_MS = 8_000;
|
||||||
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
export const CAMERA_FIRST_PLAYABLE_FRAME_TIMEOUT_MS = 8_000;
|
||||||
|
export const CAMERA_BUFFERING_NOTICE_DELAY_MS = 1_000;
|
||||||
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
// The gateway may release an 8 MiB / 64-fragment slow-reader backlog after a
|
||||||
// main-thread stall. Keep one bounded append margin above that complete batch;
|
// main-thread stall. Keep one bounded append margin above that complete batch;
|
||||||
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
// crossing either limit replaces the MSE epoch instead of dropping fragments.
|
||||||
@@ -284,6 +285,7 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
let onBufferError: (() => void) | null = null;
|
let onBufferError: (() => void) | null = null;
|
||||||
let objectUrl = "";
|
let objectUrl = "";
|
||||||
let retryTimer: number | undefined;
|
let retryTimer: number | undefined;
|
||||||
|
let bufferingNoticeTimer: number | undefined;
|
||||||
let receivedMedia = false;
|
let receivedMedia = false;
|
||||||
let failed = false;
|
let failed = false;
|
||||||
let playingReported = false;
|
let playingReported = false;
|
||||||
@@ -295,10 +297,15 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
);
|
);
|
||||||
const queue: ArrayBuffer[] = [];
|
const queue: ArrayBuffer[] = [];
|
||||||
let queuedBytes = 0;
|
let queuedBytes = 0;
|
||||||
|
const clearBufferingNotice = () => {
|
||||||
|
if (bufferingNoticeTimer !== undefined) window.clearTimeout(bufferingNoticeTimer);
|
||||||
|
bufferingNoticeTimer = undefined;
|
||||||
|
};
|
||||||
|
|
||||||
const fail = (copy: string) => {
|
const fail = (copy: string) => {
|
||||||
if (!transportIsCurrent() || failed) return;
|
if (!transportIsCurrent() || failed) return;
|
||||||
startupWatchdog?.clear();
|
startupWatchdog?.clear();
|
||||||
|
clearBufferingNotice();
|
||||||
failed = true;
|
failed = true;
|
||||||
transportHealthyRef.current = false;
|
transportHealthyRef.current = false;
|
||||||
recoveryPendingAuthorityRef.current = null;
|
recoveryPendingAuthorityRef.current = null;
|
||||||
@@ -335,6 +342,7 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
startupWatchdog?.clear();
|
startupWatchdog?.clear();
|
||||||
|
clearBufferingNotice();
|
||||||
failed = true;
|
failed = true;
|
||||||
transportHealthyRef.current = false;
|
transportHealthyRef.current = false;
|
||||||
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
const retry = consumeCameraLeaseRetry(leaseRetryRef.current, delivery.id);
|
||||||
@@ -392,6 +400,7 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
|
|
||||||
const onPlaying = () => {
|
const onPlaying = () => {
|
||||||
if (!transportIsCurrent() || failed) return;
|
if (!transportIsCurrent() || failed) return;
|
||||||
|
clearBufferingNotice();
|
||||||
startupWatchdog?.markPlaying();
|
startupWatchdog?.markPlaying();
|
||||||
transportHealthyRef.current = true;
|
transportHealthyRef.current = true;
|
||||||
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
leaseRetryRef.current = resetCameraLeaseRetryBudget(delivery.id);
|
||||||
@@ -410,7 +419,21 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onWaiting = () => {
|
||||||
|
if (!transportIsCurrent() || failed || !playingReported) return;
|
||||||
|
// A short receipt gap must not flicker or restart the acquisition-owned
|
||||||
|
// stream. A sustained decoder wait must not retain a playing presentation.
|
||||||
|
if (bufferingNoticeTimer !== undefined) return;
|
||||||
|
bufferingNoticeTimer = window.setTimeout(() => {
|
||||||
|
bufferingNoticeTimer = undefined;
|
||||||
|
if (!transportIsCurrent() || failed || video.readyState >= 3) return;
|
||||||
|
setStatus("buffering");
|
||||||
|
setMessage("Ожидание новых кадров.");
|
||||||
|
}, CAMERA_BUFFERING_NOTICE_DELAY_MS);
|
||||||
|
};
|
||||||
video.addEventListener("playing", onPlaying);
|
video.addEventListener("playing", onPlaying);
|
||||||
|
video.addEventListener("waiting", onWaiting);
|
||||||
|
video.addEventListener("stalled", onWaiting);
|
||||||
|
|
||||||
const appendNext = () => {
|
const appendNext = () => {
|
||||||
if (
|
if (
|
||||||
@@ -594,10 +617,13 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
disposed = true;
|
disposed = true;
|
||||||
transportHealthyRef.current = false;
|
transportHealthyRef.current = false;
|
||||||
startupWatchdog?.clear();
|
startupWatchdog?.clear();
|
||||||
|
clearBufferingNotice();
|
||||||
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
if (retryTimer !== undefined) window.clearTimeout(retryTimer);
|
||||||
queue.length = 0;
|
queue.length = 0;
|
||||||
socket?.close(1000, "Browser preview transport replaced or hidden");
|
socket?.close(1000, "Browser preview transport replaced or hidden");
|
||||||
video.removeEventListener("playing", onPlaying);
|
video.removeEventListener("playing", onPlaying);
|
||||||
|
video.removeEventListener("waiting", onWaiting);
|
||||||
|
video.removeEventListener("stalled", onWaiting);
|
||||||
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
mediaSource.removeEventListener("sourceopen", onSourceOpen);
|
||||||
if (sourceBuffer && onBufferUpdateEnd) {
|
if (sourceBuffer && onBufferUpdateEnd) {
|
||||||
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
sourceBuffer.removeEventListener("updateend", onBufferUpdateEnd);
|
||||||
@@ -708,7 +734,7 @@ export function MseFmp4WebSocketPlayer({
|
|||||||
{status !== "playing" ? (
|
{status !== "playing" ? (
|
||||||
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
<div className="mse-fmp4-player__status" role="status" aria-live="polite">
|
||||||
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
<Icon name={status === "error" ? "alert" : "video"} size={20} />
|
||||||
<strong>{status === "error" ? "Канал прерван" : "Подготовка камеры"}</strong>
|
<strong>{status === "error" ? "Канал прерван" : status === "buffering" ? "Ожидание изображения" : "Подготовка камеры"}</strong>
|
||||||
<span>{message}</span>
|
<span>{message}</span>
|
||||||
{status === "error" ? (
|
{status === "error" ? (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export function ObservationSessionSelect({
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<ObservationSessionSummary | null>(null);
|
||||||
const sessions = useObservationSessions({
|
const sessions = useObservationSessions({
|
||||||
limit,
|
limit,
|
||||||
scope: "source",
|
scope: "standalone",
|
||||||
replayEnabled: blockedReason === null,
|
replayEnabled: blockedReason === null,
|
||||||
onReplayBegin,
|
onReplayBegin,
|
||||||
onReplayAccepted,
|
onReplayAccepted,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { Dropdown, Icon, type IconName } from "@nodedc/ui-react";
|
import {sourceIcon,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||||
|
export {ObservationSourcePicker,observationSourceStatusLabel} from "../../../../packages/spatial-ui/src/ObservationSourcePicker";
|
||||||
|
import { Icon } from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ObservationSourceAvailability,
|
|
||||||
ObservationSourceDescriptor,
|
ObservationSourceDescriptor,
|
||||||
ObservationSourceModality,
|
|
||||||
} from "../core/runtime/contracts";
|
} from "../core/runtime/contracts";
|
||||||
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
import { MseFmp4WebSocketPlayer } from "./MseFmp4WebSocketPlayer";
|
||||||
import {
|
import {
|
||||||
@@ -16,28 +16,6 @@ import type {
|
|||||||
} from "../core/observation/recordedSessionAdmission";
|
} from "../core/observation/recordedSessionAdmission";
|
||||||
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
import { liveCameraPlaybackAuthorityIdentity } from "../core/observation/liveCameraRecovery";
|
||||||
|
|
||||||
const sourceIcon: Record<ObservationSourceModality, IconName> = {
|
|
||||||
"point-cloud": "globe",
|
|
||||||
video: "video",
|
|
||||||
image: "image",
|
|
||||||
depth: "image",
|
|
||||||
};
|
|
||||||
|
|
||||||
const availabilityCopy: Record<ObservationSourceAvailability, string> = {
|
|
||||||
unverified: "Не подтверждён",
|
|
||||||
declared: "Канал объявлен",
|
|
||||||
available: "Доступен",
|
|
||||||
connecting: "Подключение",
|
|
||||||
streaming: "Эфир",
|
|
||||||
degraded: "Нестабильно",
|
|
||||||
unavailable: "Недоступен",
|
|
||||||
error: "Ошибка",
|
|
||||||
};
|
|
||||||
|
|
||||||
export function observationSourceStatusLabel(source: ObservationSourceDescriptor): string {
|
|
||||||
return availabilityCopy[source.availability];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ObservationMedia({
|
export function ObservationMedia({
|
||||||
source,
|
source,
|
||||||
playback,
|
playback,
|
||||||
@@ -159,105 +137,3 @@ export function ObservationMedia({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ObservationSourcePicker({
|
|
||||||
sources,
|
|
||||||
visibleSourceIds,
|
|
||||||
pendingSourceIds,
|
|
||||||
onToggle,
|
|
||||||
}: {
|
|
||||||
sources: readonly ObservationSourceDescriptor[];
|
|
||||||
visibleSourceIds: ReadonlySet<string>;
|
|
||||||
pendingSourceIds?: ReadonlySet<string>;
|
|
||||||
onToggle: (sourceId: string) => void | Promise<boolean>;
|
|
||||||
}) {
|
|
||||||
const visibleCount = sources.filter((source) => visibleSourceIds.has(source.id)).length;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
className="scene-source-picker"
|
|
||||||
placement="bottom-start"
|
|
||||||
width={340}
|
|
||||||
offset={10}
|
|
||||||
surfaceRole="dialog"
|
|
||||||
surfaceClassName="observation-source-menu"
|
|
||||||
trigger={({ open, toggle, setAnchorRef, setTriggerRef, surfaceId }) => (
|
|
||||||
<button
|
|
||||||
ref={(node) => {
|
|
||||||
setAnchorRef(node);
|
|
||||||
setTriggerRef(node);
|
|
||||||
}}
|
|
||||||
type="button"
|
|
||||||
className="scene-source-picker__trigger"
|
|
||||||
data-active={open || visibleCount > 0 ? "true" : undefined}
|
|
||||||
aria-label="Источники данных сцены"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
aria-expanded={open}
|
|
||||||
aria-controls={surfaceId}
|
|
||||||
onClick={toggle}
|
|
||||||
>
|
|
||||||
<Icon name="database" size={17} />
|
|
||||||
{sources.length > 0 ? <span>{visibleCount}</span> : null}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<header className="observation-source-menu__head">
|
|
||||||
<div>
|
|
||||||
<span className="section-eyebrow">НАБЛЮДЕНИЕ</span>
|
|
||||||
<strong>Источники данных</strong>
|
|
||||||
</div>
|
|
||||||
<small>{sources.length ? `${visibleCount} из ${sources.length}` : "нет источников"}</small>
|
|
||||||
</header>
|
|
||||||
{sources.length ? (
|
|
||||||
<div className="observation-source-menu__list">
|
|
||||||
{sources.map((source) => {
|
|
||||||
const selected = visibleSourceIds.has(source.id);
|
|
||||||
const pending = pendingSourceIds?.has(source.id) ?? false;
|
|
||||||
const canOpen = Boolean(
|
|
||||||
selected ||
|
|
||||||
source.modality === "point-cloud" ||
|
|
||||||
source.previewUrl ||
|
|
||||||
source.delivery ||
|
|
||||||
source.activation?.controllable,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={source.id}
|
|
||||||
type="button"
|
|
||||||
className="nodedc-dropdown-option observation-source-option"
|
|
||||||
data-selected={selected ? "true" : undefined}
|
|
||||||
aria-pressed={selected}
|
|
||||||
disabled={pending || !canOpen}
|
|
||||||
onClick={() => void onToggle(source.id)}
|
|
||||||
>
|
|
||||||
<span className="nodedc-dropdown-option__icon">
|
|
||||||
<Icon name={sourceIcon[source.modality]} size={16} />
|
|
||||||
</span>
|
|
||||||
<span className="nodedc-dropdown-option__body">
|
|
||||||
<span className="nodedc-dropdown-option__label">{source.label}</span>
|
|
||||||
<span className="nodedc-dropdown-option__description">
|
|
||||||
<i data-availability={source.availability} aria-hidden="true" />
|
|
||||||
{pending ? "Переключение" : observationSourceStatusLabel(source)} · {source.endpointLabel || source.transport}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
<span className="nodedc-dropdown-option__check">
|
|
||||||
{selected ? <Icon name="check" size={15} /> : null}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="observation-source-menu__empty">
|
|
||||||
<Icon name="database" size={18} />
|
|
||||||
<strong>Каталог пока пуст</strong>
|
|
||||||
<span>Выберите профиль устройства — его плагин опубликует доступные каналы.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<footer className="observation-source-menu__foot">
|
|
||||||
Каналы приходят из активного контура или выбранной сохранённой сессии; сцена не знает
|
|
||||||
модель оборудования.
|
|
||||||
</footer>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,197 +1 @@
|
|||||||
import { Button, Icon, Select } from "@nodedc/ui-react";
|
export * from "../../../../packages/spatial-ui/src/ObservationTimeline";
|
||||||
|
|
||||||
import type { ObservationTimelineMode } from "../core/runtime/contracts";
|
|
||||||
|
|
||||||
export function ObservationTimeline({
|
|
||||||
active,
|
|
||||||
sourceCount,
|
|
||||||
mode = "live-only",
|
|
||||||
seekable = false,
|
|
||||||
synchronization = "host-arrival-best-effort",
|
|
||||||
rangeNs = null,
|
|
||||||
currentNs = null,
|
|
||||||
playing = false,
|
|
||||||
onSeek,
|
|
||||||
onPlayingChange,
|
|
||||||
onJumpToEnd,
|
|
||||||
showJumpToEnd = true,
|
|
||||||
playbackRate,
|
|
||||||
onPlaybackRateChange,
|
|
||||||
accumulationSeconds,
|
|
||||||
onAccumulationChange,
|
|
||||||
onAccumulationCommit,
|
|
||||||
className = "",
|
|
||||||
}: {
|
|
||||||
active: boolean;
|
|
||||||
sourceCount: number;
|
|
||||||
mode?: ObservationTimelineMode;
|
|
||||||
seekable?: boolean;
|
|
||||||
synchronization?: "host-arrival-best-effort" | "shared-clock" | "frame-accurate";
|
|
||||||
rangeNs?: { min: number; max: number } | null;
|
|
||||||
currentNs?: number | null;
|
|
||||||
playing?: boolean;
|
|
||||||
onSeek?: (timeNs: number) => void;
|
|
||||||
onPlayingChange?: (playing: boolean) => void;
|
|
||||||
onJumpToEnd?: () => void;
|
|
||||||
showJumpToEnd?: boolean;
|
|
||||||
playbackRate?: number;
|
|
||||||
onPlaybackRateChange?: (rate: number) => void;
|
|
||||||
accumulationSeconds?: number;
|
|
||||||
onAccumulationChange?: (value: number) => void;
|
|
||||||
onAccumulationCommit?: () => void;
|
|
||||||
className?: string;
|
|
||||||
}) {
|
|
||||||
const playbackRange = normalizeTimelineRange(rangeNs);
|
|
||||||
const buffered = Boolean(
|
|
||||||
seekable &&
|
|
||||||
(mode === "buffered" || mode === "recorded") &&
|
|
||||||
playbackRange,
|
|
||||||
);
|
|
||||||
const elapsedSeconds = playbackRange
|
|
||||||
? timelineOffsetSeconds(playbackRange, currentNs ?? playbackRange.min)
|
|
||||||
: 0;
|
|
||||||
const durationSeconds = playbackRange
|
|
||||||
? Math.max(0, (playbackRange.max - playbackRange.min) / 1_000_000_000)
|
|
||||||
: 0;
|
|
||||||
const synchronizationLabel = {
|
|
||||||
"host-arrival-best-effort": "Синхронизация по приходу",
|
|
||||||
"shared-clock": "Общие часы",
|
|
||||||
"frame-accurate": "Покадровая синхронизация",
|
|
||||||
}[synchronization];
|
|
||||||
const accumulationValue = accumulationSeconds === undefined
|
|
||||||
? null
|
|
||||||
: normalizeAccumulationSeconds(accumulationSeconds);
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`observation-timeline ${className}`.trim()}
|
|
||||||
data-active={active ? "true" : undefined}
|
|
||||||
data-mode={mode}
|
|
||||||
data-accumulation={accumulationValue !== null ? "true" : undefined}
|
|
||||||
>
|
|
||||||
{accumulationValue !== null ? (
|
|
||||||
<div className="observation-timeline__accumulation">
|
|
||||||
<span>Накопление</span>
|
|
||||||
<input
|
|
||||||
className="observation-timeline__track"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={120}
|
|
||||||
step={1}
|
|
||||||
value={accumulationValue}
|
|
||||||
aria-label="Окно накопления облака точек"
|
|
||||||
aria-valuetext={formatAccumulationDuration(accumulationValue)}
|
|
||||||
onChange={(event) =>
|
|
||||||
onAccumulationChange?.(normalizeAccumulationSeconds(Number(event.target.value)))}
|
|
||||||
onPointerUp={onAccumulationCommit}
|
|
||||||
onTouchEnd={onAccumulationCommit}
|
|
||||||
onKeyUp={onAccumulationCommit}
|
|
||||||
onBlur={onAccumulationCommit}
|
|
||||||
/>
|
|
||||||
<code>{formatAccumulationDuration(accumulationValue)}</code>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="observation-timeline__playback">
|
|
||||||
<Button
|
|
||||||
size="compact"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!buffered || !onSeek}
|
|
||||||
aria-label="Перейти к началу"
|
|
||||||
onClick={() => playbackRange && onSeek?.(playbackRange.min)}
|
|
||||||
>
|
|
||||||
<Icon name="chevron-left" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="observation-timeline__transport"
|
|
||||||
size="compact"
|
|
||||||
variant="ghost"
|
|
||||||
disabled={!buffered || !onPlayingChange}
|
|
||||||
aria-label={buffered ? (playing ? "Пауза" : "Воспроизвести") : "Только эфир"}
|
|
||||||
icon={<Icon name={playing ? "stop" : "play"} />}
|
|
||||||
onClick={() => onPlayingChange?.(!playing)}
|
|
||||||
/>
|
|
||||||
{buffered && playbackRate !== undefined && onPlaybackRateChange ? (
|
|
||||||
<Select
|
|
||||||
label="Скорость воспроизведения"
|
|
||||||
value={String(playbackRate)}
|
|
||||||
options={[
|
|
||||||
{ value: "0.5", label: "0,5×" },
|
|
||||||
{ value: "1", label: "1×" },
|
|
||||||
{ value: "2", label: "2×" },
|
|
||||||
]}
|
|
||||||
variant="inline"
|
|
||||||
menuWidth="anchor"
|
|
||||||
onChange={(value) => onPlaybackRateChange(Number(value))}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<input
|
|
||||||
className="observation-timeline__track"
|
|
||||||
type="range"
|
|
||||||
min={0}
|
|
||||||
max={Math.max(durationSeconds, 0.001)}
|
|
||||||
step={0.01}
|
|
||||||
value={Math.min(elapsedSeconds, durationSeconds)}
|
|
||||||
disabled={!buffered || !onSeek}
|
|
||||||
aria-label="Позиция воспроизведения"
|
|
||||||
onChange={(event) => {
|
|
||||||
if (!playbackRange) return;
|
|
||||||
onSeek?.(playbackRange.min + Number(event.target.value) * 1_000_000_000);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="observation-timeline__meta">
|
|
||||||
<code>
|
|
||||||
{buffered
|
|
||||||
? `${formatTimelineDuration(elapsedSeconds)} / ${formatTimelineDuration(durationSeconds)}`
|
|
||||||
: active ? "LIVE" : "—:—:—.———"}
|
|
||||||
</code>
|
|
||||||
<small>
|
|
||||||
{buffered ? `${sourceCount} каналов` : `${synchronizationLabel} · буфер не включён`}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
{showJumpToEnd ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="observation-timeline__follow"
|
|
||||||
data-active={!buffered && active ? "true" : undefined}
|
|
||||||
disabled={buffered ? !onJumpToEnd : true}
|
|
||||||
onClick={onJumpToEnd}
|
|
||||||
>
|
|
||||||
{buffered ? "К КОНЦУ" : "ЭФИР"}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeAccumulationSeconds(value: number): number {
|
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.min(120, Math.max(0, Math.round(value)));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatAccumulationDuration(value: number): string {
|
|
||||||
const seconds = normalizeAccumulationSeconds(value);
|
|
||||||
return seconds === 0 ? "Кадр" : `${seconds} с`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeTimelineRange(
|
|
||||||
range: { min: number; max: number } | null | undefined,
|
|
||||||
): { min: number; max: number } | null {
|
|
||||||
if (!range || !Number.isFinite(range.min) || !Number.isFinite(range.max)) return null;
|
|
||||||
if (range.max <= range.min) return null;
|
|
||||||
return range;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function timelineOffsetSeconds(
|
|
||||||
range: { min: number; max: number },
|
|
||||||
currentNs: number,
|
|
||||||
): number {
|
|
||||||
const clamped = Math.min(Math.max(currentNs, range.min), range.max);
|
|
||||||
return Math.max(0, (clamped - range.min) / 1_000_000_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function formatTimelineDuration(seconds: number): string {
|
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return "00:00.000";
|
|
||||||
const wholeMinutes = Math.floor(seconds / 60);
|
|
||||||
const remaining = seconds - wholeMinutes * 60;
|
|
||||||
return `${String(wholeMinutes).padStart(2, "0")}:${remaining.toFixed(3).padStart(6, "0")}`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
} from "./rerun/recordedRerunCameraJournal";
|
} from "./rerun/recordedRerunCameraJournal";
|
||||||
|
|
||||||
import type { SceneSettings } from "../sceneSettings";
|
import type { SceneSettings } from "../sceneSettings";
|
||||||
|
import {useRecordedPointDisplay} from './rerun/useRecordedPointDisplay';
|
||||||
import {
|
import {
|
||||||
advanceLiveReceiverOpenWatchdog,
|
advanceLiveReceiverOpenWatchdog,
|
||||||
advanceLiveReceiverWatchdog,
|
advanceLiveReceiverWatchdog,
|
||||||
@@ -130,6 +131,7 @@ export interface RerunViewportProps {
|
|||||||
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
onPlaybackControllerChange?: (controller: RerunPlaybackController | null) => void;
|
||||||
sceneSettings?: Pick<
|
sceneSettings?: Pick<
|
||||||
SceneSettings,
|
SceneSettings,
|
||||||
|
| "pointDecimationPercent"
|
||||||
| "accumulationSeconds"
|
| "accumulationSeconds"
|
||||||
| "showGrid"
|
| "showGrid"
|
||||||
| "showPoints"
|
| "showPoints"
|
||||||
@@ -148,14 +150,8 @@ interface RerunBlueprintChannel {
|
|||||||
cameraContract?: string | null;
|
cameraContract?: string | null;
|
||||||
appliedFollowTrajectory?: boolean | null;
|
appliedFollowTrajectory?: boolean | null;
|
||||||
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
pendingFollowCameraEye?: RecordedRerunCameraEye | null;
|
||||||
configureCameraJournal?: (
|
getCameraEye?: () => RecordedRerunCameraEye | null;
|
||||||
eye: RecordedRerunCameraEye,
|
|
||||||
spatialViewportStart: number,
|
|
||||||
) => void;
|
|
||||||
getCameraEye?: () => RecordedRerunCameraEye;
|
|
||||||
setCameraViewportStart?: (spatialViewportStart: number) => void;
|
|
||||||
getCurrentTimeNs?: () => number | null;
|
getCurrentTimeNs?: () => number | null;
|
||||||
setCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
|
||||||
channel: {
|
channel: {
|
||||||
readonly ready: boolean;
|
readonly ready: boolean;
|
||||||
send_rrd: (rrdBytes: Uint8Array) => void;
|
send_rrd: (rrdBytes: Uint8Array) => void;
|
||||||
@@ -421,6 +417,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
currentTimeNs,
|
currentTimeNs,
|
||||||
reactivateUpdates = false,
|
reactivateUpdates = false,
|
||||||
onCameraMaxOrbitalRadius,
|
onCameraMaxOrbitalRadius,
|
||||||
|
displayPointBank,
|
||||||
perceptionLayers = {
|
perceptionLayers = {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
detections2d: false,
|
detections2d: false,
|
||||||
@@ -444,12 +441,17 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
currentTimeNs?: number | null;
|
currentTimeNs?: number | null;
|
||||||
reactivateUpdates?: boolean;
|
reactivateUpdates?: boolean;
|
||||||
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
onCameraMaxOrbitalRadius?: (maxOrbitalRadius: number) => void;
|
||||||
|
displayPointBank?: string | null;
|
||||||
perceptionLayers?: RecordedPerceptionLayers;
|
perceptionLayers?: RecordedPerceptionLayers;
|
||||||
fetcher?: typeof globalThis.fetch;
|
fetcher?: typeof globalThis.fetch;
|
||||||
},
|
},
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const resolvedUnifiedPerception =
|
const resolvedUnifiedPerception =
|
||||||
unifiedPerception ?? (perceptionLayers.enabled && activeView !== "spatial");
|
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 base = new URL(origin);
|
||||||
const endpoint = new URL(endpointUrl, base.origin);
|
const endpoint = new URL(endpointUrl, base.origin);
|
||||||
if (
|
if (
|
||||||
@@ -481,7 +483,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
...cameraEye.eyeUp,
|
...cameraEye.eyeUp,
|
||||||
].some((value) => !Number.isFinite(value))) ||
|
].some((value) => !Number.isFinite(value))) ||
|
||||||
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
(currentTimeNs !== undefined && currentTimeNs !== null && (
|
||||||
!Number.isSafeInteger(currentTimeNs) || currentTimeNs < 0
|
!Number.isSafeInteger(requestTimeNs) || currentTimeNs < 0
|
||||||
)) ||
|
)) ||
|
||||||
[
|
[
|
||||||
perceptionLayers.enabled,
|
perceptionLayers.enabled,
|
||||||
@@ -508,6 +510,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
application_id: identity.applicationId,
|
application_id: identity.applicationId,
|
||||||
recording_id: identity.recordingId,
|
recording_id: identity.recordingId,
|
||||||
blueprint_session_id: blueprintSessionId,
|
blueprint_session_id: blueprintSessionId,
|
||||||
|
...(displayPointBank == null ? {} : {display_point_bank: displayPointBank}),
|
||||||
accumulation_seconds: settings.accumulationSeconds,
|
accumulation_seconds: settings.accumulationSeconds,
|
||||||
show_grid: settings.showGrid,
|
show_grid: settings.showGrid,
|
||||||
show_points: settings.showPoints,
|
show_points: settings.showPoints,
|
||||||
@@ -528,7 +531,7 @@ export async function fetchRecordedBlueprintRrd(
|
|||||||
eye_up: cameraEye?.eyeUp ?? null,
|
eye_up: cameraEye?.eyeUp ?? null,
|
||||||
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
...(eyeRelativeToTracking ? { eye_relative_to_tracking: true } : {}),
|
||||||
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
...(currentTimeNs === undefined || currentTimeNs === null ? {} : {
|
||||||
current_time_ns: currentTimeNs,
|
current_time_ns: requestTimeNs,
|
||||||
}),
|
}),
|
||||||
show_detections_2d: perceptionLayers.detections2d,
|
show_detections_2d: perceptionLayers.detections2d,
|
||||||
show_camera_image: perceptionLayers.cameraImage ?? true,
|
show_camera_image: perceptionLayers.cameraImage ?? true,
|
||||||
@@ -585,10 +588,8 @@ export function recordedCameraJournalContract(
|
|||||||
followTrajectory: boolean;
|
followTrajectory: boolean;
|
||||||
},
|
},
|
||||||
): string {
|
): string {
|
||||||
// Following is a tracking property of the current native eye. It must never
|
// Following uses the native eye snapshot, never the startup preset.
|
||||||
// initialize the browser journal again: doing so replaces the operator's
|
// Plan/3D and explicit reset are the only preset transitions.
|
||||||
// current pose with the startup preset immediately before the blueprint is
|
|
||||||
// sent. Plan/3D and explicit reset are the only preset transitions.
|
|
||||||
return [activeView, viewResetGeneration, planView].join(":");
|
return [activeView, viewResetGeneration, planView].join(":");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -762,7 +763,15 @@ export async function probeRecordedPerceptionViewerSource(
|
|||||||
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
return { sourceUrl: endpoint.href, byteLength: declaredLength };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RerunViewport({
|
export function RerunViewport(props: RerunViewportProps) {
|
||||||
|
// Even when source URLs coincide, another profile cannot inherit a mounted
|
||||||
|
// viewer's playback, blueprint channels, recovery state or scene draft.
|
||||||
|
const identity = props.profile.kind === "laboratory-result"
|
||||||
|
? `${props.profile.kind}:${props.profile.resultId}` : props.profile.kind;
|
||||||
|
return <RerunViewportInstance key={identity} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function RerunViewportInstance({
|
||||||
profile,
|
profile,
|
||||||
onStatusChange,
|
onStatusChange,
|
||||||
onSelectionChange,
|
onSelectionChange,
|
||||||
@@ -772,7 +781,7 @@ export function RerunViewport({
|
|||||||
onPerceptionLoadChange,
|
onPerceptionLoadChange,
|
||||||
onPointColorLoadChange,
|
onPointColorLoadChange,
|
||||||
}: RerunViewportProps) {
|
}: RerunViewportProps) {
|
||||||
const recordedProfile = profile.kind === "recorded-session" ? profile : null;
|
const recordedProfile = profile.kind === "recorded-session" || profile.kind === "laboratory-result" ? profile : null;
|
||||||
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
const liveProfile = profile.kind === "live-acquisition" ? profile : null;
|
||||||
const sourceUrl = profile.sourceUrl;
|
const sourceUrl = profile.sourceUrl;
|
||||||
const recordedArtifact = recordedProfile?.artifact ?? null;
|
const recordedArtifact = recordedProfile?.artifact ?? null;
|
||||||
@@ -833,6 +842,7 @@ export function RerunViewport({
|
|||||||
const presentationGateRef = useRef(presentationGate);
|
const presentationGateRef = useRef(presentationGate);
|
||||||
presentationGateRef.current = presentationGate;
|
presentationGateRef.current = presentationGate;
|
||||||
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
const [blueprintChannelRevision, setBlueprintChannelRevision] = useState(0);
|
||||||
|
const [pointDisplayVisibilityGate, setPointDisplayVisibilityGate] = useState('');
|
||||||
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
const [perceptionChannelRevision, setPerceptionChannelRevision] = useState(0);
|
||||||
const recordedBlueprintUrl = sourceUrl
|
const recordedBlueprintUrl = sourceUrl
|
||||||
? resolveRecordedBlueprintUrl(
|
? resolveRecordedBlueprintUrl(
|
||||||
@@ -849,6 +859,17 @@ export function RerunViewport({
|
|||||||
const recordedPointColorsUrl = recordedBlueprintUrl
|
const recordedPointColorsUrl = recordedBlueprintUrl
|
||||||
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
? recordedBlueprintUrl.replace(/\/blueprint\.rrd$/, "/point-colors.rrd")
|
||||||
: sourceUrl ? resolveRecordedPointColorsUrl(sourceUrl, window.location.origin) : null;
|
: 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(
|
const presentationStatus = rerunPresentationStatus(
|
||||||
status,
|
status,
|
||||||
presentationGate,
|
presentationGate,
|
||||||
@@ -1630,29 +1651,14 @@ export function RerunViewport({
|
|||||||
cameraContract: null,
|
cameraContract: null,
|
||||||
appliedFollowTrajectory: null,
|
appliedFollowTrajectory: null,
|
||||||
pendingFollowCameraEye: null,
|
pendingFollowCameraEye: null,
|
||||||
configureCameraJournal: (eye, spatialViewportStart) => {
|
|
||||||
if ("configure_camera_journal" in viewer) {
|
|
||||||
viewer.configure_camera_journal(eye, spatialViewportStart);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCameraEye: () => "get_camera_eye" in viewer
|
getCameraEye: () => "get_camera_eye" in viewer
|
||||||
? viewer.get_camera_eye()
|
? viewer.get_camera_eye()
|
||||||
: RECORDED_RERUN_ORBITAL_EYE,
|
: null,
|
||||||
setCameraViewportStart: (spatialViewportStart) => {
|
|
||||||
if ("set_camera_viewport_start" in viewer) {
|
|
||||||
viewer.set_camera_viewport_start(spatialViewportStart);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
getCurrentTimeNs: () => {
|
getCurrentTimeNs: () => {
|
||||||
const currentIdentity = recordedIdentityRef.current;
|
const currentIdentity = recordedIdentityRef.current;
|
||||||
if (!currentIdentity) return null;
|
if (!currentIdentity) return null;
|
||||||
return viewer.get_current_time(currentIdentity.recordingId, "session_time");
|
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,
|
channel,
|
||||||
};
|
};
|
||||||
blueprintChannelRef.current = blueprintChannel;
|
blueprintChannelRef.current = blueprintChannel;
|
||||||
@@ -2002,6 +2008,7 @@ export function RerunViewport({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!recordedPointColorsUrl || !sceneSettings) return;
|
if (!recordedPointColorsUrl || !sceneSettings) return;
|
||||||
|
if ((sceneSettings.pointDecimationPercent ?? 0) > 0) return;
|
||||||
const active = blueprintChannelRef.current;
|
const active = blueprintChannelRef.current;
|
||||||
const identity = recordedIdentityRef.current;
|
const identity = recordedIdentityRef.current;
|
||||||
if (
|
if (
|
||||||
@@ -2081,6 +2088,7 @@ export function RerunViewport({
|
|||||||
sceneSettings?.colorMode,
|
sceneSettings?.colorMode,
|
||||||
sceneSettings?.customColor,
|
sceneSettings?.customColor,
|
||||||
sceneSettings?.palette,
|
sceneSettings?.palette,
|
||||||
|
sceneSettings?.pointDecimationPercent,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2105,12 +2113,6 @@ export function RerunViewport({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const cameraContractChanged = active.cameraContract !== cameraContract;
|
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 abort = new AbortController();
|
||||||
const previousFollow = active.appliedFollowTrajectory ?? false;
|
const previousFollow = active.appliedFollowTrajectory ?? false;
|
||||||
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
const enablingFollow = recordedFollowTrajectory && !previousFollow;
|
||||||
@@ -2135,7 +2137,9 @@ export function RerunViewport({
|
|||||||
if (eyeRelativeToTracking && currentTimeNs == null) {
|
if (eyeRelativeToTracking && currentTimeNs == null) {
|
||||||
throw new Error("Recorded tracking cursor is unavailable");
|
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,
|
origin: window.location.origin,
|
||||||
blueprintSessionId: blueprintSessionIdRef.current,
|
blueprintSessionId: blueprintSessionIdRef.current,
|
||||||
signal: abort.signal,
|
signal: abort.signal,
|
||||||
@@ -2149,13 +2153,11 @@ export function RerunViewport({
|
|||||||
planView: recordedPlanView,
|
planView: recordedPlanView,
|
||||||
cameraEye,
|
cameraEye,
|
||||||
eyeRelativeToTracking,
|
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,
|
reactivateUpdates,
|
||||||
onCameraMaxOrbitalRadius: (maxOrbitalRadius) => {
|
|
||||||
if (blueprintChannelRef.current === active) {
|
|
||||||
active.setCameraMaxOrbitalRadius?.(maxOrbitalRadius);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const canApply = () => (
|
const canApply = () => (
|
||||||
@@ -2167,24 +2169,29 @@ export function RerunViewport({
|
|||||||
const applyPayload = (payload: Uint8Array) => {
|
const applyPayload = (payload: Uint8Array) => {
|
||||||
if (!canApply()) return false;
|
if (!canApply()) return false;
|
||||||
active.channel.send_rrd(payload);
|
active.channel.send_rrd(payload);
|
||||||
active.setCameraViewportStart?.(
|
// Exclude inactive point generations before admitting their data into
|
||||||
recordedUnifiedPerception ? recordedUnifiedCameraShare : 0,
|
// cached recordings whose embedded blueprint predates display controls.
|
||||||
);
|
setPointDisplayVisibilityGate(`${recordedBlueprintUrl}:${blueprintChannelRevision}`);
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const firstEye = disablingFollow
|
const firstEye = disablingFollow
|
||||||
? transitionEye ?? undefined
|
? transitionEye ?? undefined
|
||||||
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
: !enablingFollow && (cameraContractChanged || pendingFollowEye)
|
||||||
? pendingFollowEye ?? active.getCameraEye?.()
|
? pendingFollowEye ?? (recordedPlanView ? RECORDED_RERUN_PLAN_EYE : RECORDED_RERUN_ORBITAL_EYE)
|
||||||
: undefined;
|
: !enablingFollow ? active.getCameraEye?.() ?? undefined : undefined;
|
||||||
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
const firstEyeIsTrackingRelative = Boolean(firstEye) && (
|
||||||
disablingFollow || recordedFollowTrajectory
|
disablingFollow || recordedFollowTrajectory
|
||||||
);
|
);
|
||||||
const firstPayload = await requestBlueprint(
|
const firstPayload = await requestBlueprint(
|
||||||
firstEye,
|
firstEye,
|
||||||
firstEyeIsTrackingRelative,
|
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;
|
if (!applyPayload(firstPayload)) return;
|
||||||
active.cameraContract = cameraContract;
|
active.cameraContract = cameraContract;
|
||||||
@@ -2205,9 +2212,10 @@ export function RerunViewport({
|
|||||||
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
const stabilizedPayload = await requestBlueprint(transitionEye, true, true);
|
||||||
if (!applyPayload(stabilizedPayload)) return;
|
if (!applyPayload(stabilizedPayload)) return;
|
||||||
active.pendingFollowCameraEye = null;
|
active.pendingFollowCameraEye = null;
|
||||||
})().catch(() => {
|
})().catch((error: unknown) => {
|
||||||
// The recording remains usable with its embedded default blueprint.
|
// The recording remains usable with its embedded default blueprint.
|
||||||
// A later settings change retries through the same small channel.
|
// 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();
|
return () => abort.abort();
|
||||||
}, [
|
}, [
|
||||||
@@ -2235,6 +2243,9 @@ export function RerunViewport({
|
|||||||
sceneSettings?.showPoints,
|
sceneSettings?.showPoints,
|
||||||
sceneSettings?.showTrajectory,
|
sceneSettings?.showTrajectory,
|
||||||
sceneSettings?.showGrid,
|
sceneSettings?.showGrid,
|
||||||
|
pointDisplay.ready,
|
||||||
|
pointDisplay.bank,
|
||||||
|
pointDisplay.hidePoints,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
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;
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
} from "../laboratory/CanonicalRecordedLabReplay";
|
} from "../laboratory/CanonicalRecordedLabReplay";
|
||||||
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
import type { CanonicalLabReplayDescriptor } from "../../core/laboratory/canonicalLabReplay";
|
||||||
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
import type { ObservationSessionReplayLaunch } from "../../core/observation/sessionArchive";
|
||||||
import { recordedSessionRerunProfile } from "../../core/observation/viewerProfile";
|
import { laboratoryResultRerunProfile } from "../../core/observation/viewerProfile";
|
||||||
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
import { resolveObservationSessionReplay } from "../../core/observation/useObservationSessions";
|
||||||
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
import type { AIViewerLayer } from "../../core/observatory/aiComposition";
|
||||||
import {
|
import {
|
||||||
@@ -305,7 +305,8 @@ export function CanonicalResultRerunReplay({
|
|||||||
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
accumulationSeconds: showLocalSlam ? sceneDraft.accumulationSeconds : 0,
|
||||||
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
}), [sceneDraft, showLocalSlam, showSourcePoints, spatialMode]);
|
||||||
const semanticVisible = showDDRNet || showEoMT;
|
const semanticVisible = showDDRNet || showEoMT;
|
||||||
const profile = launch ? recordedSessionRerunProfile({
|
const profile = launch ? laboratoryResultRerunProfile({
|
||||||
|
resultId,
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
artifact: {
|
artifact: {
|
||||||
sourceUrl: launch.replay.sourceUrl,
|
sourceUrl: launch.replay.sourceUrl,
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {MapVisualSettings} from '../../core/map/mapView';
|
||||||
|
import type {CesiumMapRendererProps} from '@nodedc/map-cesium-react';
|
||||||
|
|
||||||
|
export function mapPresentation(settings:MapVisualSettings):CesiumMapRendererProps['settings'] {
|
||||||
|
return {
|
||||||
|
atmosphere_enabled: settings.atmosphereEnabled,
|
||||||
|
atmosphere_hue: settings.atmosphereHue,
|
||||||
|
atmosphere_saturation:
|
||||||
|
settings.atmosphereSaturation,
|
||||||
|
atmosphere_brightness:
|
||||||
|
settings.atmosphereBrightness,
|
||||||
|
fog_enabled: settings.fogEnabled,
|
||||||
|
fog_density: settings.fogDensity,
|
||||||
|
sun_enabled: settings.sunEnabled,
|
||||||
|
sun_hour: settings.sunHour,
|
||||||
|
sun_intensity: settings.sunIntensity,
|
||||||
|
shadows_enabled: settings.shadowsEnabled,
|
||||||
|
monochrome_enabled: settings.monochromeEnabled,
|
||||||
|
monochrome_color: settings.monochromeColor,
|
||||||
|
imagery_brightness: settings.imageryBrightness,
|
||||||
|
imagery_contrast: settings.imageryContrast,
|
||||||
|
imagery_saturation: settings.imagerySaturation,
|
||||||
|
imagery_gamma: settings.imageryGamma,
|
||||||
|
imagery_hue: settings.imageryHue,
|
||||||
|
imagery_alpha: settings.imageryAlpha,
|
||||||
|
globe_color: settings.globeColor,
|
||||||
|
background_color: settings.backgroundColor,
|
||||||
|
terrain_exaggeration:
|
||||||
|
settings.terrainExaggeration,
|
||||||
|
buildings_color: settings.buildingsColor,
|
||||||
|
buildings_opacity: settings.buildingsOpacity,
|
||||||
|
buildings_maximum_screen_space_error:
|
||||||
|
settings.buildingsMaximumScreenSpaceError,
|
||||||
|
grid_lod_enabled: settings.gridLodEnabled,
|
||||||
|
grid_height_meters: settings.gridHeightMeters,
|
||||||
|
grid_lod_1_max_height_km:
|
||||||
|
settings.gridLod1MaxHeightKm,
|
||||||
|
grid_lod_1_step_km:
|
||||||
|
settings.gridLod1StepKm,
|
||||||
|
grid_lod_2_max_height_km:
|
||||||
|
settings.gridLod2MaxHeightKm,
|
||||||
|
grid_lod_2_step_km:
|
||||||
|
settings.gridLod2StepKm,
|
||||||
|
grid_lod_3_step_km:
|
||||||
|
settings.gridLod3StepKm,
|
||||||
|
grid_radius_km: settings.gridRadiusKm,
|
||||||
|
grid_line_width: settings.gridLineWidth,
|
||||||
|
grid_color: settings.gridColor,
|
||||||
|
grid_opacity: settings.gridOpacity,
|
||||||
|
grid_dots_enabled:
|
||||||
|
settings.gridDotsEnabled,
|
||||||
|
grid_dots_size: settings.gridDotsSize,
|
||||||
|
grid_dots_color: settings.gridDotsColor,
|
||||||
|
grid_dots_opacity:
|
||||||
|
settings.gridDotsOpacity,
|
||||||
|
camera_animation_enabled:
|
||||||
|
settings.cameraAnimationEnabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { Button, LoadingRegion, Window } from "@nodedc/ui-react";
|
||||||
|
import type { RouteCheck } from "../../core/missions/planner";
|
||||||
|
export function MissionCheckWindow({ open, onClose, busy, error, report, run }: {
|
||||||
|
open: boolean; onClose: () => void; busy: boolean; error: string | null; report: RouteCheck | null; run: () => void;
|
||||||
|
}) {
|
||||||
|
return <Window open={open} title="Проверка маршрута" size="md" onClose={onClose}><div className="mission-planner__check">
|
||||||
|
<p>Проверка сохранённой записи и выбранного участка. Новый проход со сканером для этой проверки не требуется.</p>
|
||||||
|
<LoadingRegion loading={busy} label="Проверка исходных данных">
|
||||||
|
{error && <p role="alert">{error}</p>}
|
||||||
|
{report && <><dl>
|
||||||
|
<div><dt>Исходная запись</dt><dd>{report.source_verified ? "Контрольные суммы совпадают" : "Недоступна"}</dd></div>
|
||||||
|
<div><dt>Версия черновика</dt><dd>{report.revision}</dd></div>
|
||||||
|
<div><dt>Длина маршрута</dt><dd>{report.length_m.toFixed(2)} м</dd></div>
|
||||||
|
<div><dt>Положения сканера</dt><dd>{report.pose_count.toLocaleString("ru-RU")}</dd></div>
|
||||||
|
<div><dt>Максимальный шаг</dt><dd>{report.max_step_m.toFixed(2)} м</dd></div></dl>
|
||||||
|
{report.warnings.map(w => <p key={w}>{w}</p>)}
|
||||||
|
<p>Совмещение с повторным проходом не выполнялось. Записанная траектория описывает движение сканера; проходимость для аппарата по ней не проверена.</p></>}
|
||||||
|
<Button disabled={busy} onClick={run}>{report ? "Повторить проверку" : "Начать проверку"}</Button>
|
||||||
|
</LoadingRegion>
|
||||||
|
</div></Window>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { RangeControl } from "@nodedc/ui-react";
|
||||||
|
import { routeLength, type PlanningPose, type PlanningSource } from "../../core/missions/planner";
|
||||||
|
/** Equal-axis plan projection; the moving marker is an archive pose, not localization. */
|
||||||
|
export function MissionRoutePreview({ source, poses }: { source: PlanningSource; poses: PlanningPose[] }) {
|
||||||
|
const [cursor, setCursor] = useState(0);
|
||||||
|
useEffect(() => setCursor(0), [poses]);
|
||||||
|
const projection = useMemo(() => {
|
||||||
|
let xmin = Infinity, xmax = -Infinity, ymin = Infinity, ymax = -Infinity;
|
||||||
|
for (const p of (poses.length ? poses : source.poses)) { xmin = Math.min(xmin, p.position[0]); xmax = Math.max(xmax, p.position[0]); ymin = Math.min(ymin, p.position[1]); ymax = Math.max(ymax, p.position[1]); }
|
||||||
|
const scale = Math.min(720 / Math.max(1, xmax - xmin), 400 / Math.max(1, ymax - ymin));
|
||||||
|
const project = (p: PlanningPose) => [400 + (p.position[0] - (xmin + xmax) / 2) * scale, 240 - (p.position[1] - (ymin + ymax) / 2) * scale];
|
||||||
|
const line = (items: PlanningPose[]) => items.filter((_, i) => i % Math.max(1, Math.ceil(items.length / 5000)) === 0 || i === items.length - 1).map(p => project(p).join(",")).join(" ");
|
||||||
|
return { project, reference: line(source.poses), selected: line(poses), scale };
|
||||||
|
}, [source, poses]);
|
||||||
|
const point = poses[Math.min(cursor, poses.length - 1)];
|
||||||
|
if (!point) return <div className="session-overview__empty">Выберите участок траектории.</div>;
|
||||||
|
const [x, y] = projection.project(point);
|
||||||
|
return <div className="mission-route-preview"><h2>Выбранный маршрут · вид сверху</h2>
|
||||||
|
<svg viewBox="0 0 800 480" role="img" aria-label="Траектория записи и выбранный маршрут">
|
||||||
|
<polyline points={projection.reference} className="mission-route-preview__reference" fill="none" />
|
||||||
|
<polyline points={projection.selected} className="mission-route-preview__selected" fill="none" />
|
||||||
|
<circle cx={x} cy={y} r="6" className="mission-route-preview__cursor" />
|
||||||
|
<path d="M40 450h100" className="mission-route-preview__selected" /><text x="40" y="438">{(100 / projection.scale).toFixed(1)} м</text>
|
||||||
|
</svg><p>Путь: {routeLength(poses).toFixed(2)} м · {poses.length.toLocaleString("ru-RU")} положений · X/Y, масштаб осей одинаковый</p>
|
||||||
|
<RangeControl label="Положение на маршруте" value={Math.min(cursor, poses.length - 1) + 1} min={1} max={poses.length} step={1} formatValue={n => `${n} / ${poses.length}`} onChange={n => setCursor(n - 1)} />
|
||||||
|
<p>Кадр {point.index + 1} · X {point.position[0].toFixed(2)} · Y {point.position[1].toFixed(2)} · Z {point.position[2].toFixed(2)} м</p>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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, 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}&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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {useEffect,useState,type ReactNode} from 'react';
|
||||||
|
import {Button,LoadingRegion,StatusBadge,Window} from '@nodedc/ui-react';
|
||||||
|
import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost';
|
||||||
|
import {planningTestTerminal,usePlanningTest} from '../../core/missions/PlanningTestContext';
|
||||||
|
|
||||||
|
/** Wrap the existing plugin connection flow, retaining its acquisition authority. */
|
||||||
|
export function PlanningConnectionWindow({children}:{children:ReactNode}){
|
||||||
|
const p=usePlanningTest(); const [open,setOpen]=useState(true);
|
||||||
|
const {selection,registry,selectModel,selectionTransitionPending}=useDevicePluginHost();
|
||||||
|
useEffect(()=>{
|
||||||
|
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.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>
|
||||||
|
<p>Укажите новое имя проекта сканера. Неподвижная калибровка просматривает весь выбранный маршрут; после калибровки сканера оставайтесь на месте до статуса «Сопровождение». Если совпадение не найдено или неоднозначно, сцена сообщит причину и не начнёт сопровождение.</p></>;
|
||||||
|
return <div className="mission-planner__check">
|
||||||
|
{details}<p>{t.message}</p>{p.error&&<p role="alert">{p.error}</p>}
|
||||||
|
<div className="mission-planner__actions"><Button onClick={()=>setOpen(true)}>Подключение сканера</Button>
|
||||||
|
{t.state==='running'&&t.planning_phase==='lost'&&!t.tracking_established&&<Button loading={p.busy} onClick={()=>void p.retryInitialization()}>Переинициализировать</Button>}
|
||||||
|
{!terminal&&<Button loading={p.busy} onClick={()=>void p.finish()}>Завершить исследование</Button>}</div>
|
||||||
|
<Window open={open} title="Подключение сканера · Планирование" size="lg" className="planning-connection" onClose={()=>setOpen(false)}>
|
||||||
|
<div className="mission-planner__check">{details}
|
||||||
|
{t.state==='preparing'?<LoadingRegion loading label="Подготовка эталонного участка" />
|
||||||
|
: terminal?<p>{t.message}</p>:children}
|
||||||
|
</div>
|
||||||
|
</Window>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {useEffect,useRef,useState} from 'react';
|
||||||
|
import {Button,LoadingRegion,RangeControl} from '@nodedc/ui-react';
|
||||||
|
import {createIsolatedRerunHost} from '../rerun/isolatedRerunHost';
|
||||||
|
import {plannerBase} from '../../core/missions/planner';
|
||||||
|
import {startPlanningSceneStream} from '../../core/missions/planningSceneStream';
|
||||||
|
import {createPlanningPresentationReporter} from '../../core/missions/planningPresentationTelemetry';
|
||||||
|
|
||||||
|
function nextAnimationFrame(limitMs=500):Promise<number|null>{
|
||||||
|
return new Promise(resolve=>{
|
||||||
|
let frame:number|undefined,settled=false;
|
||||||
|
const finish=(value:number|null)=>{
|
||||||
|
if(settled)return;
|
||||||
|
settled=true;
|
||||||
|
if(frame!==undefined&&value===null)cancelAnimationFrame(frame);
|
||||||
|
clearTimeout(timeout);resolve(value);
|
||||||
|
};
|
||||||
|
const timeout=setTimeout(()=>finish(null),limitMs);
|
||||||
|
frame=requestAnimationFrame(()=>finish(performance.now()));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One persistent viewer/channel. Updates replace entities without resetting the camera. */
|
||||||
|
type HeightBounds={min:number;max:number};
|
||||||
|
const CLIP_CEILING_M=80;
|
||||||
|
export function PlanningLiveScene({runId,options={},active=false,revision=0,heightBounds=null}:{runId:string;options?:Record<string,string|number|boolean>;active?:boolean;revision?:number;heightBounds?:HeightBounds|null}){
|
||||||
|
const optionsRef=useRef(options);optionsRef.current=options;
|
||||||
|
const stateRef=useRef({active,revision});stateRef.current={active,revision};
|
||||||
|
const ceilingRef=useRef<number|null>(null);
|
||||||
|
const boundsRef=useRef<{min:number;max:number}|null>(null);
|
||||||
|
const host=useRef<HTMLDivElement>(null);
|
||||||
|
const [state,setState]=useState('loading'),[retry,setRetry]=useState(0);
|
||||||
|
const [bounds,setBounds]=useState<{min:number;max:number}|null>(null);
|
||||||
|
const [ceiling,setCeiling]=useState<number|null>(null);
|
||||||
|
useEffect(()=>{
|
||||||
|
ceilingRef.current=null;
|
||||||
|
const next=heightBounds&&Number.isFinite(heightBounds.min)&&Number.isFinite(heightBounds.max)&&heightBounds.max>heightBounds.min?heightBounds:null;
|
||||||
|
boundsRef.current=next;
|
||||||
|
setBounds(next);setCeiling(null);
|
||||||
|
},[runId,heightBounds?.min,heightBounds?.max]);
|
||||||
|
useEffect(()=>{
|
||||||
|
if(!host.current)return;
|
||||||
|
let disposed=false,sceneAvailable=false,stopStream:(()=>void)|undefined;
|
||||||
|
const reporter=createPlanningPresentationReporter({
|
||||||
|
url:`${plannerBase}/live-tests/${runId}/presentation-observations`,
|
||||||
|
});
|
||||||
|
const runtime=createIsolatedRerunHost(host.current);setState('loading');
|
||||||
|
const timeout=setTimeout(()=>{if(!disposed){setState('error');stopStream?.();runtime.dispose();}},60000);
|
||||||
|
void runtime.ready.then(async({viewer,mount})=>{
|
||||||
|
if(disposed)return;
|
||||||
|
await viewer.start(null,mount,{width:'100%',height:'100%',hide_welcome_screen:true,enable_history:false,allow_fullscreen:false});
|
||||||
|
if(disposed)return;
|
||||||
|
const channel=viewer.open_channel('planning-'+runId);
|
||||||
|
viewer.on('recording_open',()=>{if(!disposed&&sceneAvailable){clearTimeout(timeout);setState('ready');}});
|
||||||
|
stopStream=startPlanningSceneStream({
|
||||||
|
url:`${plannerBase}/live-tests/${runId}/scene-delta.rrd`,
|
||||||
|
snapshot:()=>({...stateRef.current,options:{...optionsRef.current,
|
||||||
|
...(ceilingRef.current===null?{}:{ceiling_m:ceilingRef.current})}}),
|
||||||
|
apply:async(bytes,delivery)=>{
|
||||||
|
if(disposed)return;
|
||||||
|
if(delivery.heightMinM!==null&&delivery.heightMaxM!==null&&delivery.heightMaxM>delivery.heightMinM){
|
||||||
|
const next={min:delivery.heightMinM,max:delivery.heightMaxM};
|
||||||
|
const previous=boundsRef.current;
|
||||||
|
if(!previous||previous.min!==next.min||previous.max!==next.max){
|
||||||
|
boundsRef.current=next;setBounds(next);
|
||||||
|
if(ceilingRef.current!==null&&(ceilingRef.current<next.min||ceilingRef.current>next.max)){
|
||||||
|
ceilingRef.current=null;setCeiling(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sceneAvailable=true;
|
||||||
|
const admissionStarted=performance.now();
|
||||||
|
channel.send_rrd(bytes);
|
||||||
|
const admissionEnded=performance.now();
|
||||||
|
for(const panel of ['top','blueprint','selection','time'] as const)viewer.override_panel_state(panel,'hidden');
|
||||||
|
if(viewer.get_active_recording_id()){clearTimeout(timeout);setState('ready');}
|
||||||
|
// Rerun exposes no canvas-paint receipt. Two bounded browser frame
|
||||||
|
// opportunities are retained as an explicit proxy, never as a GPU claim.
|
||||||
|
const first=await nextAnimationFrame();
|
||||||
|
const second=first===null?null:await nextAnimationFrame();
|
||||||
|
if(!disposed)reporter.record(delivery,{
|
||||||
|
rerunAdmissionMs:admissionEnded-admissionStarted,
|
||||||
|
firstAnimationFrameMs:first===null?null:first-admissionEnded,
|
||||||
|
secondAnimationFrameMs:second===null?null:second-admissionEnded,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
error:()=>{sceneAvailable=false;if(!disposed)setState('error');},
|
||||||
|
});
|
||||||
|
}).catch(()=>{if(!disposed){clearTimeout(timeout);setState('error');}});
|
||||||
|
return()=>{disposed=true;stopStream?.();reporter.dispose();clearTimeout(timeout);runtime.dispose();};
|
||||||
|
},[runId,retry]);
|
||||||
|
const sourceBounds=bounds??heightBounds;
|
||||||
|
const rangeBounds=sourceBounds?{min:Math.min(sourceBounds.min,0),max:CLIP_CEILING_M}:null;
|
||||||
|
return <LoadingRegion loading={state==='loading'} label="Загрузка эталона и нового прохода" className="planning-live__scene">
|
||||||
|
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{visibility:state==='ready'?'visible':'hidden'}} />
|
||||||
|
{rangeBounds&&state==='ready'&&<div className="session-overview__height">
|
||||||
|
<RangeControl orientation="vertical" limitSide="left" label="Срез" value={ceiling??rangeBounds.max} min={rangeBounds.min} max={rangeBounds.max} step="any"
|
||||||
|
formatValue={value=>`${value.toFixed(1).replace('.',',')} м`} formatLimit={value=>value.toFixed(1).replace('.',',')}
|
||||||
|
onChange={value=>{
|
||||||
|
const next=value>=rangeBounds.max-Math.max(1,rangeBounds.max-rangeBounds.min)*1e-9?null:value;
|
||||||
|
ceilingRef.current=next;setCeiling(next);
|
||||||
|
}}/>
|
||||||
|
</div>}
|
||||||
|
{state==='error'&&<div className="session-overview__empty"><span>Обновление сцены недоступно.</span><Button onClick={()=>setRetry(v=>v+1)}>Повторить подключение сцены</Button></div>}
|
||||||
|
</LoadingRegion>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { Inspector, Icon, StatusBadge } from '@nodedc/ui-react';
|
||||||
|
import { planningProjectStatus, type PlanningProjectDetail } from '../../core/missions/planningProjects';
|
||||||
|
|
||||||
|
export function PlanningProjectResult({project:p}:{project:PlanningProjectDetail}) {
|
||||||
|
const r=p.result;
|
||||||
|
return <Inspector variant="panel" defaultOpen={['sources','result']} sections={[
|
||||||
|
{id:'sources',label:'Проект',icon:<Icon name="file"/>,content:<div className="inspector-control-stack">
|
||||||
|
<p>{p.name}</p><small>{new Date(p.created_at_utc).toLocaleString('ru-RU')}</small>
|
||||||
|
<dl><div><dt>Эталон</dt><dd>{p.reference_label}</dd></div><div><dt>Повторный проход</dt><dd>{p.query_label??'—'}</dd></div>
|
||||||
|
<div><dt>Участок эталона</dt><dd>{p.draft.route.length_m.toFixed(2)} м</dd></div></dl>
|
||||||
|
</div>},
|
||||||
|
{id:'result',label:'Результат',icon:<Icon name="activity"/>,content:<div className="inspector-control-stack">
|
||||||
|
<StatusBadge tone={r?.status==='rejected'?'warning':'neutral'}>{planningProjectStatus(p)}</StatusBadge>
|
||||||
|
{r?<><dl><div><dt>Точки в пределах 0,5 м</dt><dd>{(r.overlap*100).toFixed(1)}%</dd></div>
|
||||||
|
<div><dt>Расхождение поверхностей</dt><dd>{r.inlier_rmse_m==null?'—':`${r.inlier_rmse_m.toFixed(3)} м`}</dd></div>
|
||||||
|
{r.correction_m!=null&&<div><dt>Уточнение привязки</dt><dd>{r.correction_m.toFixed(2)} м · {r.correction_deg.toFixed(1)}°</dd></div>}
|
||||||
|
{r.registration_seconds!=null&&<div><dt>Расчёт</dt><dd>{r.registration_seconds.toFixed(2)} с</dd></div>}
|
||||||
|
</dl><small>Совпадение поверхностей не является измеренной точностью положения.</small></>:<p>{p.message?`Сохранённое сообщение: ${p.message}`:'Расчёт совмещения ещё не выполнен.'}</p>}
|
||||||
|
{p.evidence_relation==='same_recording'&&<small>Оба участка из одной записи: внутренняя проверка общей карты.</small>}
|
||||||
|
{p.scene_note&&p.scene_url&&<small>{p.scene_note}</small>}
|
||||||
|
{r?.reasons.map(reason=><small key={reason}>{reason}</small>)}
|
||||||
|
{!p.scene_url&&r&&<small>Сохранённое совмещённое облако недоступно.</small>}
|
||||||
|
</div>},
|
||||||
|
]}/>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ConfirmationModal, Icon, Select, ToastStack } from '@nodedc/ui-react';
|
||||||
|
import { planningProjectDeletable, planningProjectStatus, type PlanningProject } from '../../core/missions/planningProjects';
|
||||||
|
|
||||||
|
export function PlanningProjectSelect({items, value, disabled, onChange, onRemove}: {
|
||||||
|
items: PlanningProject[];
|
||||||
|
value: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (key: string) => void;
|
||||||
|
onRemove: (project: PlanningProject) => Promise<void>;
|
||||||
|
}) {
|
||||||
|
const [target, setTarget] = useState<PlanningProject | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
return <>
|
||||||
|
<Select label="Совмещённые маршруты" value={value} disabled={disabled || busy} searchable
|
||||||
|
options={[{value: '', label: 'Совмещённые маршруты'}, ...items.map(item => ({
|
||||||
|
value: item.key, label: item.name,
|
||||||
|
description: `${new Date(item.created_at_utc).toLocaleString('ru-RU')} · ${planningProjectStatus(item)}${item.query_label ? ` · ${item.query_label}` : ''}`,
|
||||||
|
action: {
|
||||||
|
label: `Удалить совмещённый маршрут ${item.name} · ${new Date(item.created_at_utc).toLocaleString('ru-RU')}`,
|
||||||
|
icon: <Icon name="trash" />,
|
||||||
|
tone: 'danger' as const,
|
||||||
|
disabled: !planningProjectDeletable(item),
|
||||||
|
onAction: () => { setError(null); setTarget(item); },
|
||||||
|
},
|
||||||
|
}))]}
|
||||||
|
onChange={key => { if (key) onChange(key); }} />
|
||||||
|
<ConfirmationModal open={target !== null} title="Удалить совмещённый маршрут?"
|
||||||
|
description={target ? <>
|
||||||
|
<strong>{target.name}</strong>
|
||||||
|
<p>{new Date(target.created_at_utc).toLocaleString('ru-RU')} · {planningProjectStatus(target)}</p>
|
||||||
|
<p>Проект будет удалён из списка совмещённых маршрутов. Эталон, запись прохода и сохранённые отчёты останутся на диске. Другие проекты не изменятся.</p>
|
||||||
|
</> : null}
|
||||||
|
confirmLabel="Удалить маршрут" pendingLabel="Удаление…" danger
|
||||||
|
onClose={() => { if (!busy) setTarget(null); }}
|
||||||
|
onConfirm={async () => {
|
||||||
|
if (!target || busy) return;
|
||||||
|
setBusy(true); setError(null);
|
||||||
|
try { await onRemove(target); setTarget(null); }
|
||||||
|
catch (e) { setError(e instanceof Error ? e.message : 'Не удалось удалить проект. Повторите попытку.'); }
|
||||||
|
finally { setBusy(false); }
|
||||||
|
}} />
|
||||||
|
<ToastStack items={error ? [{id: 'planning-project-delete-error', tone: 'error', title: 'Маршрут не удалён', description: error}] : []}
|
||||||
|
onDismiss={() => setError(null)} />
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { Button, Icon, Inspector, InspectorSelectField, TextField } from '@nodedc/ui-react';
|
||||||
|
import { routeLength, canStartPlanningRoute } from '../../core/missions/planner';
|
||||||
|
import type { useMissionPlanner } from '../../core/missions/useMissionPlanner';
|
||||||
|
|
||||||
|
export function PlanningProjectSettings({p,onStart,starting}:{
|
||||||
|
p:ReturnType<typeof useMissionPlanner>; onStart:()=>void; starting:boolean;
|
||||||
|
}) {
|
||||||
|
const disabled=p.busy||starting;
|
||||||
|
const length=routeLength(p.poses);
|
||||||
|
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.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:'В обратную сторону'}]}/>
|
||||||
|
</>}
|
||||||
|
</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>
|
||||||
|
</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import {useState,type ReactNode,type RefObject} from 'react';
|
||||||
|
import {WorkspaceWindow,type WorkspaceWindowRect} from '@nodedc/ui-react';
|
||||||
|
|
||||||
|
/** A scene-bounded tool: never a portal, backdrop or renderer owner. */
|
||||||
|
export function PlanningSceneToolWindow({boundsRef,title,children,onClose}:{
|
||||||
|
boundsRef:RefObject<HTMLDivElement|null>;title:string;children:ReactNode;onClose:()=>void;
|
||||||
|
}){
|
||||||
|
const [rect,setRect]=useState<WorkspaceWindowRect>({x:16,y:120,width:390,height:260});
|
||||||
|
const [maximized,setMaximized]=useState(false);
|
||||||
|
return <WorkspaceWindow boundsRef={boundsRef} rect={rect} onRectChange={setRect}
|
||||||
|
title={title} maximized={maximized} onMaximizedChange={setMaximized}
|
||||||
|
minWidth={280} minHeight={200} active zIndex={100} onClose={onClose}
|
||||||
|
closeLabel="Закрыть инструмент" moveLabel="Переместить инструмент"
|
||||||
|
resizeLabel="Изменить размер инструмента" maximizeLabel="Развернуть инструмент" restoreLabel="Восстановить инструмент" onKeyDown={event=>{
|
||||||
|
if(event.key==='Escape'&&!event.defaultPrevented){event.preventDefault();event.stopPropagation();onClose();}
|
||||||
|
}}>
|
||||||
|
<div className="mission-planner__check">{children}</div>
|
||||||
|
</WorkspaceWindow>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Button, LoadingRegion } from "@nodedc/ui-react";
|
||||||
|
import { createIsolatedRerunHost } from "../rerun/isolatedRerunHost";
|
||||||
|
|
||||||
|
/** One isolated, read-only Rerun realm; disposal releases its WASM memory. */
|
||||||
|
export function RegistrationScene({ sourceUrl }: { sourceUrl: string }) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const [state, setState] = useState("loading"), [retry, setRetry] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!host.current) return;
|
||||||
|
let disposed = false; setState("loading");
|
||||||
|
const runtime = createIsolatedRerunHost(host.current);
|
||||||
|
const timer = window.setTimeout(() => { if (!disposed) { setState("error"); runtime.dispose(); } }, 60_000);
|
||||||
|
void runtime.ready.then(async ({ viewer, mount }) => {
|
||||||
|
if (disposed) return;
|
||||||
|
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||||
|
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||||
|
width: "100%", height: "100%", hide_welcome_screen: true, enable_history: false, allow_fullscreen: false,
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||||
|
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||||
|
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||||
|
return () => { disposed = true; clearTimeout(timer); runtime.dispose(); };
|
||||||
|
}, [sourceUrl, retry]);
|
||||||
|
return <LoadingRegion loading={state === "loading"} label="Загрузка совмещённых облаков" className="mission-registration__scene">
|
||||||
|
<div ref={host} className="session-overview__runtime rerun-single-view-content" style={{ visibility: state === "ready" ? "visible" : "hidden" }} />
|
||||||
|
{state === "error" && <div className="session-overview__empty"><span>Облако недоступно.</span><Button onClick={() => setRetry(n => n+1)}>Повторить</Button></div>}
|
||||||
|
</LoadingRegion>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { overviewChartPoints } from "../../core/observation/sessionOverview";
|
||||||
|
|
||||||
|
export function SessionIntervalChart({ chart, bucketSeconds }: { chart: [number, number][]; bucketSeconds: number }) {
|
||||||
|
const svg = useRef<SVGSVGElement>(null);
|
||||||
|
const [size, setSize] = useState({ width: 900, height: 160 });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!svg.current) return;
|
||||||
|
const observer = new ResizeObserver(([entry]) => {
|
||||||
|
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
||||||
|
setSize({ width: entry.contentRect.width, height: entry.contentRect.height });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(svg.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [chart.length > 0]);
|
||||||
|
const width = Math.max(1, size.width - 106);
|
||||||
|
const height = Math.max(1, size.height - 38);
|
||||||
|
const { points, xmax, ymax } = overviewChartPoints(chart, width, height);
|
||||||
|
if (!chart.length) return <div className="session-overview__empty">Временные метки кадров отсутствуют.</div>;
|
||||||
|
return <div className="session-overview__chart">
|
||||||
|
<svg ref={svg} viewBox={`0 0 ${size.width} ${size.height}`} role="img" aria-label="Интервалы поступления кадров облака">
|
||||||
|
<g transform="translate(64,10)">
|
||||||
|
{(height >= 70 ? [0, .5, 1] : height >= 32 ? [0, 1] : [1]).map(f => <g key={f}>
|
||||||
|
<line x1="0" x2={width} y1={height * (1 - f)} y2={height * (1 - f)} className="session-overview__grid" />
|
||||||
|
<text x="-12" y={height * (1 - f) + 4} textAnchor="end">{(f * ymax).toFixed(2)} с</text>
|
||||||
|
</g>)}
|
||||||
|
<polyline points={points} fill="none" className="session-overview__series" vectorEffect="non-scaling-stroke" />
|
||||||
|
{[0, .25, .5, .75, 1].map(f => <text key={f} x={f * width} y={height + 24} textAnchor="middle">{(f * xmax / 60).toFixed(1)} мин</text>)}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
<span className="session-overview__note">Максимальный интервал за каждые {bucketSeconds} с · время от первого кадра</span>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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, type OverviewRepresentation } from "../../core/observation/sessionOverviewSpatial";
|
||||||
|
|
||||||
|
/** A separate, bounded static recording; teardown releases the whole WASM realm. */
|
||||||
|
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);
|
||||||
|
const [metadata, setMetadata] = useState<OverviewSpatialMetadata | null>(null);
|
||||||
|
const [ceiling, setCeiling] = useState<number | null>(null);
|
||||||
|
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(() => {
|
||||||
|
if (!host.current) return;
|
||||||
|
let disposed = false;
|
||||||
|
const abort = new AbortController();
|
||||||
|
appliedMode.current = null;
|
||||||
|
setState("loading");
|
||||||
|
setMetadata(null); setCeiling(null); setMode("3d"); setVisiblePoints(null); setViewError(null);
|
||||||
|
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 }) => {
|
||||||
|
if (disposed) return;
|
||||||
|
viewer.on("recording_open", () => { if (!disposed) { clearTimeout(timer); setState("ready"); } });
|
||||||
|
await viewer.start(new URL(sourceUrl, window.location.origin).href, mount, {
|
||||||
|
width: "100%", height: "100%", hide_welcome_screen: true,
|
||||||
|
enable_history: false, allow_fullscreen: false,
|
||||||
|
});
|
||||||
|
if (disposed) return;
|
||||||
|
controller.current = { viewer, channel: viewer.open_channel("session-overview-controls") };
|
||||||
|
if (viewer.get_active_recording_id()) { clearTimeout(timer); setState("ready"); }
|
||||||
|
for (const panel of ["top", "blueprint", "selection", "time"] as const) viewer.override_panel_state(panel, "hidden");
|
||||||
|
}).catch(() => { if (!disposed) { clearTimeout(timer); setState("error"); runtime.dispose(); } });
|
||||||
|
return () => { disposed = true; abort.abort(); controller.current = null; clearTimeout(timer); runtime.dispose(); };
|
||||||
|
}, [sourceUrl, retry]);
|
||||||
|
|
||||||
|
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,
|
||||||
|
comparison?.generation ?? null, comparison ? representation : "original").then(result => {
|
||||||
|
if (abort.signal.aborted || !controller.current) return;
|
||||||
|
controller.current.channel.send_rrd(result.bytes);
|
||||||
|
appliedMode.current = mode;
|
||||||
|
setVisiblePoints(result.visiblePoints); setViewError(null);
|
||||||
|
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, comparison, representation]);
|
||||||
|
|
||||||
|
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" || (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('.', ',')}
|
||||||
|
onChange={value => setCeiling(value >= high - Math.max(1, high - low) * 1e-9 ? null : value)} />
|
||||||
|
</div>}
|
||||||
|
{state === "error" && <div className="session-overview__empty"><span>Не удалось открыть облако.</span><Button onClick={() => setRetry(n => n + 1)}>Повторить</Button></div>}
|
||||||
|
</LoadingRegion>
|
||||||
|
<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 = {
|
export type RecordedRerunCameraEye = {
|
||||||
readonly position: readonly [number, number, number];
|
readonly position: readonly [number, number, number];
|
||||||
readonly lookTarget: readonly [number, number, number];
|
readonly lookTarget: readonly [number, number, number];
|
||||||
readonly eyeUp: 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 = {
|
export const RECORDED_RERUN_ORBITAL_EYE: RecordedRerunCameraEye = {
|
||||||
position: [16, -16, 18],
|
position: [16, -16, 18], lookTarget: [0, 0, 0], eyeUp: [0, 0, 1],
|
||||||
lookTarget: [0, 0, 0],
|
|
||||||
eyeUp: [0, 0, 1],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
export const RECORDED_RERUN_PLAN_EYE: RecordedRerunCameraEye = {
|
||||||
position: [0, 0, 30],
|
position: [0, 0, 30], lookTarget: [0, 0, 0], eyeUp: [0, 1, 0],
|
||||||
lookTarget: [0, 0, 0],
|
|
||||||
eyeUp: [0, 1, 0],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const vector = (value: readonly [number, number, number]): Vector3 => [...value];
|
export function readNativeRerunCameraEye(value: unknown): RecordedRerunCameraEye | null {
|
||||||
const add = (a: Vector3, b: Vector3): Vector3 => [a[0] + b[0], a[1] + b[1], a[2] + b[2]];
|
if (value == null) return null; // No 3D frame rendered yet.
|
||||||
const subtract = (a: Vector3, b: Vector3): Vector3 => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
|
if (typeof value !== "object") throw new Error("Invalid native Rerun camera snapshot");
|
||||||
const scale = (value: Vector3, factor: number): Vector3 => [value[0] * factor, value[1] * factor, value[2] * factor];
|
const eye = value as Record<string, unknown>;
|
||||||
const dot = (a: Vector3, b: Vector3) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
|
const vector = (key: string): [number, number, number] => {
|
||||||
const cross = (a: Vector3, b: Vector3): Vector3 => [
|
const item = eye[key];
|
||||||
a[1] * b[2] - a[2] * b[1],
|
if (!Array.isArray(item) || item.length !== 3 ||
|
||||||
a[2] * b[0] - a[0] * b[2],
|
!item.every(component => typeof component === "number" && Number.isFinite(component))) {
|
||||||
a[0] * b[1] - a[1] * b[0],
|
throw new Error("Invalid native Rerun camera vector");
|
||||||
];
|
|
||||||
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),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
return [item[0], item[1], item[2]];
|
||||||
/**
|
|
||||||
* Mirrors Rerun 0.36.3's orbital eye math while the native viewer remains the
|
|
||||||
* renderer. Rerun writes operator navigation into its active blueprint clone;
|
|
||||||
* the WebViewer API cannot read that clone. Keeping the same three eye vectors
|
|
||||||
* here lets a later layer blueprint activate with the exact operator pose.
|
|
||||||
*/
|
|
||||||
export function createRecordedRerunCameraJournal(
|
|
||||||
canvas: HTMLCanvasElement,
|
|
||||||
scope: Window & typeof globalThis,
|
|
||||||
) {
|
|
||||||
let eye = cloneEye(RECORDED_RERUN_ORBITAL_EYE);
|
|
||||||
let spatialViewportStart = 0;
|
|
||||||
let pointerId: number | null = null;
|
|
||||||
let dragMode: DragMode | null = null;
|
|
||||||
let previousPointer: readonly [number, number] | null = null;
|
|
||||||
let latestPointer: readonly [number, number] | null = null;
|
|
||||||
let maxOrbitalRadius = 1.0e17;
|
|
||||||
|
|
||||||
const isSpatialPoint = (event: MouseEvent) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
return rect.width > 0 && (event.clientX - rect.left) / rect.width >= spatialViewportStart;
|
|
||||||
};
|
|
||||||
const forward = () => normalize(subtract(eye.lookTarget, eye.position), [0, 1, 0]);
|
|
||||||
const usableUp = () => {
|
|
||||||
const fwd = forward();
|
|
||||||
const fallbackRight: Vector3 = Math.abs(dot(fwd, [0, 0, 1])) > 0.9999
|
|
||||||
? [1, 0, 0]
|
|
||||||
: cross(fwd, [0, 0, 1]);
|
|
||||||
const fallback = normalize(cross(fwd, fallbackRight), [0, 0, 1]);
|
|
||||||
const candidate = normalize(eye.eyeUp, fallback);
|
|
||||||
return Math.abs(dot(candidate, fwd)) > 0.9999 ? fallback : candidate;
|
|
||||||
};
|
|
||||||
const orbitRadius = () => length(subtract(eye.position, eye.lookTarget));
|
|
||||||
|
|
||||||
const rotate = (deltaX: number, deltaY: number) => {
|
|
||||||
const radius = orbitRadius();
|
|
||||||
const up = usableUp();
|
|
||||||
let fwd = forward();
|
|
||||||
const oldPitch = Math.asin(clamp(dot(fwd, up), -1, 1));
|
|
||||||
fwd = normalize(
|
|
||||||
rotateAroundAxis(fwd, up, -ROTATION_RADIANS_PER_POINT * deltaX),
|
|
||||||
fwd,
|
|
||||||
);
|
|
||||||
const right = normalize(cross(fwd, up), [1, 0, 0]);
|
|
||||||
const maxPitch = 0.99 * Math.PI / 2;
|
|
||||||
const nextPitch = clamp(
|
|
||||||
oldPitch - ROTATION_RADIANS_PER_POINT * deltaY,
|
|
||||||
-maxPitch,
|
|
||||||
maxPitch,
|
|
||||||
);
|
|
||||||
fwd = normalize(rotateAroundAxis(fwd, right, nextPitch - oldPitch), fwd);
|
|
||||||
eye.position = subtract(eye.lookTarget, scale(fwd, radius));
|
|
||||||
};
|
|
||||||
|
|
||||||
const pan = (deltaX: number, deltaY: number) => {
|
|
||||||
const fwd = forward();
|
|
||||||
const right = normalize(cross(fwd, usableUp()), [1, 0, 0]);
|
|
||||||
const screenUp = normalize(cross(right, fwd), [0, 0, 1]);
|
|
||||||
const speed = 0.001 * orbitRadius();
|
|
||||||
const translation = add(scale(right, -deltaX * speed), scale(screenUp, deltaY * speed));
|
|
||||||
eye.position = add(eye.position, translation);
|
|
||||||
eye.lookTarget = add(eye.lookTarget, translation);
|
|
||||||
};
|
|
||||||
|
|
||||||
const roll = (event: PointerEvent, deltaX: number, deltaY: number) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const left = rect.left + rect.width * spatialViewportStart;
|
|
||||||
const centerX = left + (rect.right - left) / 2;
|
|
||||||
const centerY = rect.top + rect.height / 2;
|
|
||||||
const relativeX = event.clientX - centerX;
|
|
||||||
const relativeY = event.clientY - centerY;
|
|
||||||
const divisor = relativeX * relativeX + relativeY * relativeY;
|
|
||||||
if (divisor <= Number.EPSILON) return;
|
|
||||||
const angle = (-deltaY * relativeX + deltaX * relativeY) / divisor;
|
|
||||||
eye.eyeUp = normalize(rotateAroundAxis(eye.eyeUp, scale(forward(), -1), angle), eye.eyeUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const pointerDown = (event: PointerEvent) => {
|
|
||||||
latestPointer = [event.clientX, event.clientY];
|
|
||||||
if (!isSpatialPoint(event)) return;
|
|
||||||
pointerId = event.pointerId;
|
|
||||||
previousPointer = [event.clientX, event.clientY];
|
|
||||||
dragMode = event.button === 2 ? "pan" : event.button === 1 || event.altKey ? "roll" : "rotate";
|
|
||||||
};
|
|
||||||
const pointerMove = (event: PointerEvent) => {
|
|
||||||
latestPointer = [event.clientX, event.clientY];
|
|
||||||
if (event.pointerId !== pointerId || !previousPointer || !dragMode) return;
|
|
||||||
const deltaX = event.clientX - previousPointer[0];
|
|
||||||
const deltaY = event.clientY - previousPointer[1];
|
|
||||||
previousPointer = [event.clientX, event.clientY];
|
|
||||||
if (dragMode === "rotate") rotate(deltaX, deltaY);
|
|
||||||
else if (dragMode === "pan") pan(deltaX, deltaY);
|
|
||||||
else roll(event, deltaX, deltaY);
|
|
||||||
};
|
|
||||||
const pointerEnd = (event: PointerEvent) => {
|
|
||||||
if (event.pointerId !== pointerId) return;
|
|
||||||
pointerMove(event);
|
|
||||||
pointerId = null;
|
|
||||||
previousPointer = null;
|
|
||||||
dragMode = null;
|
|
||||||
};
|
|
||||||
const wheel = (event: WheelEvent) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const wheelX = event.clientX >= rect.left && event.clientX <= rect.right
|
|
||||||
? event.clientX
|
|
||||||
: latestPointer?.[0];
|
|
||||||
if (wheelX === undefined || rect.width <= 0) return;
|
|
||||||
if ((wheelX - rect.left) / rect.width < spatialViewportStart) return;
|
|
||||||
const unitMultiplier = event.deltaMode === DOM_WHEEL_DELTA_LINE
|
|
||||||
? RERUN_WEB_LINE_SCROLL_POINTS
|
|
||||||
: event.deltaMode === DOM_WHEEL_DELTA_PAGE
|
|
||||||
? rect.height
|
|
||||||
: 1;
|
|
||||||
const scrollPoints = (event.deltaX + event.deltaY) * unitMultiplier;
|
|
||||||
// eframe negates the DOM delta before Rerun applies
|
|
||||||
// radius / exp(smooth_scroll_delta / 200). Over a smoothed gesture the
|
|
||||||
// exponent products collapse to the raw accumulated DOM delta below.
|
|
||||||
const divisor = event.ctrlKey
|
|
||||||
? RERUN_WEB_PINCH_SCROLL_DIVISOR
|
|
||||||
: RERUN_ORBITAL_SCROLL_DIVISOR;
|
|
||||||
const radiusFactor = Math.exp(scrollPoints / divisor);
|
|
||||||
const offset = subtract(eye.position, eye.lookTarget);
|
|
||||||
const radius = length(offset);
|
|
||||||
// Keep the same orbital bounds as Rerun 0.36.3. An eye already outside
|
|
||||||
// the scene-derived cap is allowed to stay there and is never snapped in.
|
|
||||||
const nextRadius = clamp(
|
|
||||||
radius * radiusFactor,
|
|
||||||
RERUN_MIN_ORBIT_DISTANCE,
|
|
||||||
Math.max(radius, maxOrbitalRadius),
|
|
||||||
);
|
|
||||||
eye.position = add(
|
|
||||||
eye.lookTarget,
|
|
||||||
scale(offset, nextRadius / Math.max(radius, Number.EPSILON)),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.addEventListener("pointerdown", pointerDown, true);
|
|
||||||
scope.addEventListener("pointermove", pointerMove, true);
|
|
||||||
scope.addEventListener("pointerup", pointerEnd, true);
|
|
||||||
scope.addEventListener("pointercancel", pointerEnd, true);
|
|
||||||
scope.addEventListener("wheel", wheel, { capture: true, passive: true });
|
|
||||||
|
|
||||||
return {
|
|
||||||
current(): RecordedRerunCameraEye {
|
|
||||||
return {
|
|
||||||
position: [...eye.position],
|
|
||||||
lookTarget: [...eye.lookTarget],
|
|
||||||
eyeUp: [...eye.eyeUp],
|
|
||||||
};
|
|
||||||
},
|
|
||||||
configure(nextEye: RecordedRerunCameraEye, nextSpatialViewportStart: number) {
|
|
||||||
eye = cloneEye(nextEye);
|
|
||||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
|
||||||
pointerId = null;
|
|
||||||
dragMode = null;
|
|
||||||
previousPointer = null;
|
|
||||||
},
|
|
||||||
setSpatialViewportStart(nextSpatialViewportStart: number) {
|
|
||||||
spatialViewportStart = clamp(nextSpatialViewportStart, 0, 0.9);
|
|
||||||
},
|
|
||||||
setMaxOrbitalRadius(nextMaxOrbitalRadius: number) {
|
|
||||||
if (
|
|
||||||
Number.isFinite(nextMaxOrbitalRadius) &&
|
|
||||||
nextMaxOrbitalRadius >= RERUN_MIN_ORBIT_DISTANCE
|
|
||||||
) {
|
|
||||||
maxOrbitalRadius = nextMaxOrbitalRadius;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
dispose() {
|
|
||||||
canvas.removeEventListener("pointerdown", pointerDown, true);
|
|
||||||
scope.removeEventListener("pointermove", pointerMove, true);
|
|
||||||
scope.removeEventListener("pointerup", pointerEnd, true);
|
|
||||||
scope.removeEventListener("pointercancel", pointerEnd, true);
|
|
||||||
scope.removeEventListener("wheel", wheel, true);
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
return { position: vector("position"), lookTarget: vector("lookTarget"), eyeUp: vector("eyeUp") };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,7 @@ export type RecordedRerunViewer = Pick<WebViewer,
|
|||||||
"set_current_time" | "set_playing"
|
"set_current_time" | "set_playing"
|
||||||
> & {
|
> & {
|
||||||
open_channel: (name?: string) => Channel;
|
open_channel: (name?: string) => Channel;
|
||||||
configure_camera_journal: (eye: RecordedRerunCameraEye, spatialViewportStart: number) => void;
|
get_camera_eye: () => RecordedRerunCameraEye | null;
|
||||||
get_camera_eye: () => RecordedRerunCameraEye;
|
|
||||||
set_camera_viewport_start: (spatialViewportStart: number) => void;
|
|
||||||
set_camera_max_orbital_radius: (maxOrbitalRadius: number) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Parent-owned values only. No SDK Promise or foreign prototype escapes. */
|
/** 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_active_timeline: (...args) => command("set_active_timeline", args),
|
||||||
set_current_time: (...args) => command("set_current_time", args),
|
set_current_time: (...args) => command("set_current_time", args),
|
||||||
set_playing: (...args) => command("set_playing", 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"),
|
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 };
|
return { facade, dispose, notify };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { WebViewer } from "@rerun-io/web-viewer";
|
import type { WebViewer } from "@rerun-io/web-viewer";
|
||||||
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
import type { RerunFrameApi, RerunNotify } from "./recordedRerunProtocol";
|
||||||
import { createRecordedRerunCameraJournal } from "./recordedRerunCameraJournal";
|
import { readNativeRerunCameraEye } from "./recordedRerunCameraJournal";
|
||||||
|
|
||||||
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
/** Lives entirely inside the disposable iframe, including pending SDK starts. */
|
||||||
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLElement): RerunFrameApi {
|
||||||
@@ -8,7 +8,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
|||||||
let notify: RerunNotify | null = null;
|
let notify: RerunNotify | null = null;
|
||||||
const subscriptions = new Map<number, () => void>();
|
const subscriptions = new Map<number, () => void>();
|
||||||
const channels = new Map<number, ReturnType<WebViewer["open_channel"]>>();
|
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 send = (message: object) => notify?.(JSON.stringify(message));
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
notify = null;
|
notify = null;
|
||||||
@@ -20,8 +19,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
|||||||
try { channel.close(); } catch { /* Other channels must still close. */ }
|
try { channel.close(); } catch { /* Other channels must still close. */ }
|
||||||
}
|
}
|
||||||
channels.clear();
|
channels.clear();
|
||||||
cameraJournal?.dispose();
|
|
||||||
cameraJournal = null;
|
|
||||||
const current = native;
|
const current = native;
|
||||||
native = null;
|
native = null;
|
||||||
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
try { current?.stop(); } catch { /* Realm teardown remains authoritative. */ }
|
||||||
@@ -34,13 +31,6 @@ export function createRecordedRerunOwner(create: () => WebViewer, mount: HTMLEle
|
|||||||
const starting = required();
|
const starting = required();
|
||||||
try {
|
try {
|
||||||
await starting.start(args[0], mount, args[1]);
|
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 });
|
if (native === starting) send({ type: "started", id });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (native === starting) send({ type: "start-failed", id, message: String(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);
|
channels.delete(id);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "configure-camera-journal": cameraJournal?.configure(args[0], args[1]); break;
|
case "get-camera-eye": result = readNativeRerunCameraEye(
|
||||||
case "get-camera-eye": result = cameraJournal?.current(); break;
|
(required() as WebViewer & { get_camera_eye?(): unknown }).get_camera_eye?.(),
|
||||||
case "set-camera-viewport-start": cameraJournal?.setSpatialViewportStart(args[0]); break;
|
); break;
|
||||||
case "set-camera-max-orbital-radius": cameraJournal?.setMaxOrbitalRadius(args[0]); break;
|
|
||||||
case "open": required().open(args[0]); break;
|
case "open": required().open(args[0]); break;
|
||||||
case "close": required().close(args[0]); break;
|
case "close": required().close(args[0]); break;
|
||||||
case "override_panel_state": required().override_panel_state(args[0], args[1]); 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 {
|
interface SystemNavigationPanelProps {
|
||||||
title: string;
|
title: string;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
|
onNavigateRoot: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SystemNavigationPanel({
|
export function SystemNavigationPanel({
|
||||||
title,
|
title,
|
||||||
onAdd,
|
onAdd,
|
||||||
|
onNavigateRoot,
|
||||||
onClose,
|
onClose,
|
||||||
}: SystemNavigationPanelProps) {
|
}: SystemNavigationPanelProps) {
|
||||||
const { contours, selectedContour, selectContour } = useComputeContours();
|
const { contours, selectedContour, selectContour } = useComputeContours();
|
||||||
@@ -40,7 +42,7 @@ export function SystemNavigationPanel({
|
|||||||
description: contour.expected_node_id,
|
description: contour.expected_node_id,
|
||||||
icon: <Icon name="apps" />,
|
icon: <Icon name="apps" />,
|
||||||
active: selectedContour?.contour_id === contour.contour_id,
|
active: selectedContour?.contour_id === contour.contour_id,
|
||||||
onSelect: () => selectContour(contour.contour_id),
|
onSelect: () => { selectContour(contour.contour_id); onNavigateRoot(); },
|
||||||
}))}
|
}))}
|
||||||
items={[]}
|
items={[]}
|
||||||
onItemChange={() => undefined}
|
onItemChange={() => undefined}
|
||||||
|
|||||||
@@ -6,19 +6,18 @@ interface TelemetrySeriesProps {
|
|||||||
ceiling?: number;
|
ceiling?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function linePoints(values: Array<number | null>, ceiling?: number): string {
|
export function lineSegments(values: Array<number | null>, ceiling?: number): string[] {
|
||||||
const finite = values.filter((value): value is number =>
|
const finite = values.filter((value): value is number => value !== null && Number.isFinite(value));
|
||||||
value !== null && Number.isFinite(value),
|
if (!finite.length) return [];
|
||||||
);
|
const maximum = Math.max(ceiling ?? 0, ...finite, 1), denominator = Math.max(1, values.length - 1);
|
||||||
if (finite.length === 0) return "";
|
const minimum = Math.min(0, ...finite), span = maximum - minimum;
|
||||||
const maximum = Math.max(ceiling ?? 0, ...finite, 1);
|
const segments:string[]=[];let segment:string[]=[];
|
||||||
const denominator = Math.max(1, values.length - 1);
|
values.forEach((value,index)=>{
|
||||||
return values.map((value, index) => {
|
if(value===null||!Number.isFinite(value)){if(segment.length)segments.push(segment.join(' '));segment=[];return;}
|
||||||
const x = index / denominator * 100;
|
segment.push(`${(index/denominator*100).toFixed(2)},${(36-(Math.min(maximum,value)-minimum)/span*34).toFixed(2)}`);
|
||||||
const normalized = value === null ? 0 : Math.max(0, Math.min(maximum, value));
|
});
|
||||||
const y = 36 - normalized / maximum * 34;
|
if(segment.length)segments.push(segment.join(' '));
|
||||||
return `${x.toFixed(2)},${y.toFixed(2)}`;
|
return segments;
|
||||||
}).join(" ");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TelemetrySeries({
|
export function TelemetrySeries({
|
||||||
@@ -28,7 +27,7 @@ export function TelemetrySeries({
|
|||||||
resource,
|
resource,
|
||||||
ceiling,
|
ceiling,
|
||||||
}: TelemetrySeriesProps) {
|
}: TelemetrySeriesProps) {
|
||||||
const points = linePoints(values, ceiling);
|
const segments = lineSegments(values, ceiling);
|
||||||
return (
|
return (
|
||||||
<div className="system-telemetry-series">
|
<div className="system-telemetry-series">
|
||||||
<div>
|
<div>
|
||||||
@@ -45,7 +44,7 @@ export function TelemetrySeries({
|
|||||||
aria-label={`${label}: ${value}`}
|
aria-label={`${label}: ${value}`}
|
||||||
>
|
>
|
||||||
<path d="M0 37 H100" />
|
<path d="M0 37 H100" />
|
||||||
{points ? <polyline points={points} /> : null}
|
{segments.map((points,index)=><polyline key={index} points={points} />)}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ interface ApplicationPanelActionsOptions {
|
|||||||
saveWorkspaceLayout: () => Promise<void>;
|
saveWorkspaceLayout: () => Promise<void>;
|
||||||
workspaceLayoutSaving: boolean;
|
workspaceLayoutSaving: boolean;
|
||||||
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
systemUtilityActions: readonly ApplicationPanelUtilityAction[];
|
||||||
|
sessionOverview?: { open: boolean; toggle: () => void; available: boolean };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deviceRuntimeUtilityAction({
|
export function deviceRuntimeUtilityAction({
|
||||||
@@ -49,6 +50,7 @@ export function useApplicationPanelActions({
|
|||||||
saveWorkspaceLayout,
|
saveWorkspaceLayout,
|
||||||
workspaceLayoutSaving,
|
workspaceLayoutSaving,
|
||||||
systemUtilityActions,
|
systemUtilityActions,
|
||||||
|
sessionOverview,
|
||||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const actions: ApplicationPanelUtilityAction[] = [];
|
const actions: ApplicationPanelUtilityAction[] = [];
|
||||||
@@ -68,6 +70,11 @@ export function useApplicationPanelActions({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
if (definition?.root === "system") actions.push(...systemUtilityActions);
|
||||||
|
if (definition?.kind === "recordings" && sessionOverview) actions.push({
|
||||||
|
label: sessionOverview.open ? "Закрыть информацию о записи" : "Информация о записи",
|
||||||
|
icon: "info", pressed: sessionOverview.open, disabled: !sessionOverview.available,
|
||||||
|
onClick: sessionOverview.toggle,
|
||||||
|
});
|
||||||
return actions;
|
return actions;
|
||||||
}, [
|
}, [
|
||||||
definition,
|
definition,
|
||||||
@@ -77,5 +84,6 @@ export function useApplicationPanelActions({
|
|||||||
saveWorkspaceLayout,
|
saveWorkspaceLayout,
|
||||||
systemUtilityActions,
|
systemUtilityActions,
|
||||||
workspaceLayoutSaving,
|
workspaceLayoutSaving,
|
||||||
|
sessionOverview,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
|
import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin';
|
||||||
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
import type { DeviceUiPlugin } from "../core/device-plugins/contracts";
|
||||||
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin";
|
||||||
|
import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin';
|
||||||
|
|
||||||
// Composition root: this is the only place where Mission Core chooses which
|
// Composition root: this is the only place where Mission Core chooses which
|
||||||
// statically reviewed device plugins are shipped in the current build.
|
// statically reviewed device plugins are shipped in the current build.
|
||||||
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([
|
||||||
xgridsK1Plugin,
|
xgridsK1Plugin,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// 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,vescSensorUi]);
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {
|
|||||||
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
import { IdleMissionRuntimeProvider } from "../runtime/MissionRuntimeContext";
|
||||||
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
import type { DeviceUiPlugin, RegisteredDeviceModel } from "./contracts";
|
||||||
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
import { createDevicePluginRegistry, type DevicePluginRegistry } from "./registry";
|
||||||
|
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||||
|
const NO_NODE_SENSORS: readonly SensorUiContribution[] = Object.freeze([]);
|
||||||
|
|
||||||
interface DevicePluginHostValue {
|
interface DevicePluginHostValue {
|
||||||
registry: DevicePluginRegistry;
|
registry: DevicePluginRegistry;
|
||||||
@@ -83,12 +85,14 @@ export function commitPersistedDeviceModelId(
|
|||||||
|
|
||||||
export function DevicePluginHostProvider({
|
export function DevicePluginHostProvider({
|
||||||
plugins,
|
plugins,
|
||||||
|
nodeSensorContributions = NO_NODE_SENSORS,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
plugins: readonly DeviceUiPlugin[];
|
plugins: readonly DeviceUiPlugin[];
|
||||||
|
nodeSensorContributions?: readonly SensorUiContribution[];
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
const registry = useMemo(() => createDevicePluginRegistry(plugins, nodeSensorContributions), [plugins, nodeSensorContributions]);
|
||||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||||
|
|||||||
@@ -97,12 +97,24 @@ export interface DevicePluginHostActions {
|
|||||||
activateAutomaticSpatialSource: () => void;
|
activateAutomaticSpatialSource: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Session-bound, presentation-only extension of an active spatial workflow. */
|
||||||
|
export interface SpatialActivityPresentation {
|
||||||
|
sessionId: string;
|
||||||
|
label: string;
|
||||||
|
detail: string;
|
||||||
|
busy: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DevicePluginConnectionProps {
|
export interface DevicePluginConnectionProps {
|
||||||
model: DeviceModelDefinition;
|
model: DeviceModelDefinition;
|
||||||
host: DevicePluginHostActions;
|
host: DevicePluginHostActions;
|
||||||
|
spatialActivity?: SpatialActivityPresentation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeviceUiPlugin {
|
export interface DeviceUiPlugin {
|
||||||
|
sensorUi?: {
|
||||||
|
contributions: readonly import('../../../../../packages/sensor-ui/src/extensions').SensorUiContribution[];
|
||||||
|
};
|
||||||
manifest: DevicePluginManifest;
|
manifest: DevicePluginManifest;
|
||||||
RuntimeProvider: ComponentType<{
|
RuntimeProvider: ComponentType<{
|
||||||
activeModel: DeviceModelDefinition | null;
|
activeModel: DeviceModelDefinition | null;
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import {
|
|||||||
type DeviceUiPlugin,
|
type DeviceUiPlugin,
|
||||||
type RegisteredDeviceModel,
|
type RegisteredDeviceModel,
|
||||||
} from "./contracts";
|
} from "./contracts";
|
||||||
|
import type {SensorUiContribution} from '../../../../../packages/sensor-ui/src/extensions';
|
||||||
|
|
||||||
export interface DevicePluginRegistry {
|
export interface DevicePluginRegistry {
|
||||||
|
readonly sensorContributions: readonly SensorUiContribution[];
|
||||||
readonly plugins: readonly DeviceUiPlugin[];
|
readonly plugins: readonly DeviceUiPlugin[];
|
||||||
readonly models: readonly RegisteredDeviceModel[];
|
readonly models: readonly RegisteredDeviceModel[];
|
||||||
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
resolveModel: (modelId: string) => RegisteredDeviceModel | null;
|
||||||
@@ -15,6 +17,7 @@ export interface DevicePluginRegistry {
|
|||||||
|
|
||||||
export function createDevicePluginRegistry(
|
export function createDevicePluginRegistry(
|
||||||
installedPlugins: readonly DeviceUiPlugin[],
|
installedPlugins: readonly DeviceUiPlugin[],
|
||||||
|
nodeSensorContributions: readonly SensorUiContribution[] = [],
|
||||||
): DevicePluginRegistry {
|
): DevicePluginRegistry {
|
||||||
const pluginIds = new Set<string>();
|
const pluginIds = new Set<string>();
|
||||||
const modelIds = new Set<string>();
|
const modelIds = new Set<string>();
|
||||||
@@ -133,7 +136,15 @@ export function createDevicePluginRegistry(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
const modelById = new Map(models.map((registered) => [registered.model.id, registered]));
|
||||||
|
const sensorContributions = [
|
||||||
|
...installedPlugins.flatMap(plugin => plugin.sensorUi?.contributions ?? []),
|
||||||
|
...nodeSensorContributions,
|
||||||
|
];
|
||||||
|
if (new Set(sensorContributions.map(value => value.kind)).size !== sensorContributions.length) {
|
||||||
|
throw new Error('Плагины повторяют модель устройства БК.');
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
|
sensorContributions: Object.freeze(sensorContributions),
|
||||||
plugins: Object.freeze([...installedPlugins]),
|
plugins: Object.freeze([...installedPlugins]),
|
||||||
models: Object.freeze(models),
|
models: Object.freeze(models),
|
||||||
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
resolveModel: (modelId) => modelById.get(modelId) ?? null,
|
||||||
|
|||||||
@@ -1,32 +1,9 @@
|
|||||||
import { roots, type RootId } from "../../productModel";
|
import { roots, type RootId } from "../../productModel";
|
||||||
|
|
||||||
export type EnvironmentSurfaceId = "home" | RootId;
|
export type EnvironmentSurfaceId = "home" | RootId;
|
||||||
export type EnvironmentMediaKind = "image" | "video";
|
import { defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems, type EnvironmentBackground, type EnvironmentMediaItem, type EnvironmentMediaKind, type EnvironmentMediaSource, type EnvironmentPage } from "@nodedc/ui-core";
|
||||||
export type EnvironmentMediaSource = "file" | "url";
|
export { createEnvironmentMediaItem, appendEnvironmentMediaItem, removeEnvironmentMediaItem, inferEnvironmentMediaKind, defaultEnvironmentImageDurationSeconds, maxEnvironmentMediaItems } from "@nodedc/ui-core";
|
||||||
|
export type { EnvironmentBackground, EnvironmentMediaItem, EnvironmentMediaKind, EnvironmentMediaSource, EnvironmentPage } from "@nodedc/ui-core";
|
||||||
export interface EnvironmentMediaItem {
|
|
||||||
id: string;
|
|
||||||
source: EnvironmentMediaSource;
|
|
||||||
url: string | null;
|
|
||||||
mediaKind: EnvironmentMediaKind | null;
|
|
||||||
fileName: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvironmentBackground {
|
|
||||||
enabled: boolean;
|
|
||||||
imageDurationSeconds: number;
|
|
||||||
items: EnvironmentMediaItem[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvironmentPage {
|
|
||||||
headerLabel: string;
|
|
||||||
eyebrow: string;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
primaryWorkspaceId: string | null;
|
|
||||||
secondaryWorkspaceId: string | null;
|
|
||||||
background: EnvironmentBackground;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvironmentSettings {
|
export interface EnvironmentSettings {
|
||||||
revision: number;
|
revision: number;
|
||||||
@@ -54,72 +31,19 @@ const surfaceIds: readonly EnvironmentSurfaceId[] = [
|
|||||||
"polygon",
|
"polygon",
|
||||||
];
|
];
|
||||||
|
|
||||||
export const defaultEnvironmentImageDurationSeconds = 10;
|
function emptyBackground(): EnvironmentBackground { return { enabled: false, imageDurationSeconds: defaultEnvironmentImageDurationSeconds, items: [] }; }
|
||||||
export const maxEnvironmentMediaItems = 24;
|
|
||||||
|
|
||||||
function emptyBackground(): EnvironmentBackground {
|
|
||||||
return {
|
|
||||||
enabled: false,
|
|
||||||
imageDurationSeconds: defaultEnvironmentImageDurationSeconds,
|
|
||||||
items: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createEnvironmentMediaItem(): EnvironmentMediaItem {
|
|
||||||
return {
|
|
||||||
id: `media-${crypto.randomUUID()}`,
|
|
||||||
source: "file",
|
|
||||||
url: null,
|
|
||||||
mediaKind: null,
|
|
||||||
fileName: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendEnvironmentMediaItem(
|
|
||||||
background: EnvironmentBackground,
|
|
||||||
item: EnvironmentMediaItem = createEnvironmentMediaItem(),
|
|
||||||
): EnvironmentBackground {
|
|
||||||
if (
|
|
||||||
background.items.length >= maxEnvironmentMediaItems
|
|
||||||
|| background.items.some((candidate) => candidate.id === item.id)
|
|
||||||
) {
|
|
||||||
return background;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...background,
|
|
||||||
enabled: background.items.length === 0 ? true : background.enabled,
|
|
||||||
items: [...background.items, item],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function removeEnvironmentMediaItem(
|
|
||||||
background: EnvironmentBackground,
|
|
||||||
itemId: string,
|
|
||||||
): EnvironmentBackground {
|
|
||||||
const items = background.items.filter((item) => item.id !== itemId);
|
|
||||||
if (items.length === background.items.length) return background;
|
|
||||||
return {
|
|
||||||
...background,
|
|
||||||
enabled: items.length > 0 && background.enabled,
|
|
||||||
items,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function inferEnvironmentMediaKind(url: string): EnvironmentMediaKind {
|
|
||||||
return /\.(mp4|webm|mov)(?:[?#].*)?$/i.test(url) ? "video" : "image";
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultQuickActions: Record<
|
const defaultQuickActions: Record<
|
||||||
EnvironmentSurfaceId,
|
EnvironmentSurfaceId,
|
||||||
readonly [string | null, string | null]
|
readonly [string | null, string | null]
|
||||||
> = {
|
> = {
|
||||||
home: ["spatial-scene", "local-device"],
|
home: ["spatial-scene", "vehicles"],
|
||||||
fleet: ["contour-health", "local-device"],
|
fleet: ["vehicles", "contour-health"],
|
||||||
observation: ["spatial-scene", "cameras"],
|
observation: ["cameras", "world-map"],
|
||||||
missions: ["mission-planner", null],
|
missions: ["routes", null],
|
||||||
data: ["recordings", "datasets"],
|
data: ["recordings", "datasets"],
|
||||||
system: ["modules", "integrations"],
|
system: ["modules", "integrations"],
|
||||||
polygon: ["lab-archive", null],
|
polygon: ["observatory", "local-device"],
|
||||||
};
|
};
|
||||||
|
|
||||||
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
export function defaultEnvironmentSettings(): EnvironmentSettings {
|
||||||
@@ -216,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 {
|
function decodePage(value: unknown, path: string): EnvironmentPage {
|
||||||
const record = requireRecord(value, path);
|
const record = requireRecord(value, path);
|
||||||
return {
|
return {
|
||||||
@@ -223,16 +151,16 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
|
|||||||
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
|
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
|
||||||
title: requireString(record.title, `${path}.title`)!,
|
title: requireString(record.title, `${path}.title`)!,
|
||||||
description: requireString(record.description, `${path}.description`)!,
|
description: requireString(record.description, `${path}.description`)!,
|
||||||
primaryWorkspaceId: requireString(
|
primaryWorkspaceId: currentQuickAction(requireString(
|
||||||
record.primary_workspace_id,
|
record.primary_workspace_id,
|
||||||
`${path}.primary_workspace_id`,
|
`${path}.primary_workspace_id`,
|
||||||
true,
|
true,
|
||||||
),
|
)),
|
||||||
secondaryWorkspaceId: requireString(
|
secondaryWorkspaceId: currentQuickAction(requireString(
|
||||||
record.secondary_workspace_id,
|
record.secondary_workspace_id,
|
||||||
`${path}.secondary_workspace_id`,
|
`${path}.secondary_workspace_id`,
|
||||||
true,
|
true,
|
||||||
),
|
)),
|
||||||
background: decodeBackground(record.background, `${path}.background`),
|
background: decodeBackground(record.background, `${path}.background`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import {createBoardLayoutStore,type BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
|
||||||
|
import {fleetRequest} from './useFleet';
|
||||||
|
|
||||||
|
const layouts=new Map<string,BoardLayoutStore>();
|
||||||
|
export function boardLayout(vehicleID:string):BoardLayoutStore {
|
||||||
|
let layout=layouts.get(vehicleID);
|
||||||
|
if(!layout){
|
||||||
|
const path=`/${encodeURIComponent(vehicleID)}/board-layout`;
|
||||||
|
layout=createBoardLayoutStore({read:()=>fleetRequest(path),patch:(section,open)=>fleetRequest(path,'PATCH',{section,open})});
|
||||||
|
layouts.set(vehicleID,layout);
|
||||||
|
}
|
||||||
|
return layout;
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {useEffect,useState} from 'react';
|
||||||
|
|
||||||
|
export interface MetricDefinition {label:string;group:string;resource:string;unit:string;reason:string|null}
|
||||||
|
export interface BoardMonitor {
|
||||||
|
available:boolean;fresh:boolean;storage:string;source_id:string;
|
||||||
|
latest:{seq:number;at:number;boot_id:string;values:Record<string,number|null>}|null;
|
||||||
|
definitions:Record<string,MetricDefinition>;received_at:number;backlog:number;
|
||||||
|
database_bytes:number|null;database_budget_bytes:number;archive_since:number|null;
|
||||||
|
start:number;end:number;bucket_seconds:number;
|
||||||
|
series:{at:number;min:number|null;max:number|null;mean:number|null;count:number}[];
|
||||||
|
events:{at:number;code:string;kind:string;locations:string[]}[];
|
||||||
|
}
|
||||||
|
export function useBoardMonitor(vehicle:string,metric:string,window:number,end:number|null){
|
||||||
|
const [value,setValue]=useState<BoardMonitor|null>(null),[error,setError]=useState('');
|
||||||
|
useEffect(()=>{
|
||||||
|
let active=true;let pending:AbortController|undefined;let timer:ReturnType<typeof setTimeout>|undefined;
|
||||||
|
setValue(null);setError('');
|
||||||
|
const read=async()=>{
|
||||||
|
const controller=new AbortController();pending=controller;
|
||||||
|
const deadline=setTimeout(()=>controller.abort(),12000);
|
||||||
|
try {
|
||||||
|
const query=new URLSearchParams({metric,window:String(window),...(end===null?{}:{end:String(end)})});
|
||||||
|
const response=await fetch(`/api/v1/fleet/${encodeURIComponent(vehicle)}/monitor?${query}`,{credentials:'same-origin',cache:'no-store',signal:controller.signal});
|
||||||
|
if(!response.ok)throw new Error('Мониторинг БК недоступен.');
|
||||||
|
const next=await response.json() as BoardMonitor;
|
||||||
|
if(active){setValue(next);setError('');}
|
||||||
|
}catch {if(active)setError('Не удалось обновить мониторинг БК.');}
|
||||||
|
finally {clearTimeout(deadline);if(active)timer=setTimeout(()=>void read(),5000);}
|
||||||
|
};
|
||||||
|
void read();return()=>{active=false;pending?.abort();clearTimeout(timer);};
|
||||||
|
},[vehicle,metric,window,end]);
|
||||||
|
return {value,error,dismissError:()=>setError('')};
|
||||||
|
}
|
||||||
|
export function monitorSeries(value:BoardMonitor,mode:'min'|'max'|'mean') {
|
||||||
|
const count=Math.ceil((value.end-value.start)/value.bucket_seconds)+1;
|
||||||
|
const values:Array<number|null>=Array(Math.min(count,302)).fill(null);
|
||||||
|
const first=Math.floor(value.start/value.bucket_seconds)*value.bucket_seconds;
|
||||||
|
for(const point of value.series){const index=Math.round((point.at-first)/value.bucket_seconds);if(index>=0&&index<values.length)values[index]=point[mode];}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
export function monitorValue(value:number|null|undefined,unit=''){
|
||||||
|
if(value==null||!Number.isFinite(value))return 'Нет измерения';
|
||||||
|
if(unit==='bytes'||unit==='bytes/s'){
|
||||||
|
const labels=unit==='bytes'?['Б','КиБ','МиБ','ГиБ']:['Б/с','КиБ/с','МиБ/с','ГиБ/с'];let index=0;
|
||||||
|
while(value>=1024&&index<3){value/=1024;index++;}
|
||||||
|
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})} ${labels[index]}`;
|
||||||
|
}
|
||||||
|
return `${value.toLocaleString('ru-RU',{maximumFractionDigits:1})}${unit?' '+unit:''}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface ObservationLayout {
|
||||||
|
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||||
|
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))]));
|
||||||
|
if(source.arrangement==='rows'||source.arrangement==='columns')result.arrangement=source.arrangement;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
export function orderedObservationIDs(layout:ObservationLayout,available:string[]):string[]{
|
||||||
|
const present=new Set(available);return [...layout.order.filter(id=>present.has(id)),...available.filter(id=>!layout.order.includes(id))];
|
||||||
|
}
|
||||||
|
export function moveObservation(layout:ObservationLayout,available:string[],from:string,to:string):ObservationLayout {
|
||||||
|
const order=orderedObservationIDs(layout,available);const source=order.indexOf(from),target=order.indexOf(to);
|
||||||
|
if(source<0||target<0||source===target)return layout;
|
||||||
|
order.splice(source,1);order.splice(target,0,from);return {...layout,order};
|
||||||
|
}
|
||||||
@@ -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:'',
|
||||||
|
}:{} )};
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type {SensorInventory, SensorTransport} from '../../../../../packages/sensor-ui/src/contracts';
|
||||||
|
import {fleetRequest} from './useFleet';
|
||||||
|
|
||||||
|
export function createFleetSensorTransport(vehicleID:string):SensorTransport {
|
||||||
|
const path=`/${encodeURIComponent(vehicleID)}/devices`;
|
||||||
|
const inventory=(fleet:{items:{id:string;connectivity:string;enrollment:string;sensor_state:SensorInventory}[]})=>{
|
||||||
|
const value=fleet.items.find(v=>v.id===vehicleID);
|
||||||
|
if(!value)throw new Error('Аппарат не найден.');
|
||||||
|
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();},
|
||||||
|
submit:value=>fleetRequest(`${path}/operations`,'POST',value),
|
||||||
|
operation:id=>fleetRequest(`${path}/operations/${encodeURIComponent(id)}`),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export interface BoardHost {
|
||||||
|
hostname: string; os: string; architecture: string; cpus: number;
|
||||||
|
memory_kib: number | null; collected_at: string;
|
||||||
|
networks: { name: string; up: boolean; addresses: string[] }[];
|
||||||
|
usb: { port: string; product: string }[];
|
||||||
|
}
|
||||||
|
export interface Vehicle {
|
||||||
|
id: string; node_id: string; name: string; platform: string;
|
||||||
|
enrollment: "pending" | "paired" | "revoked" | "failed";
|
||||||
|
connectivity: "online" | "offline"; last_seen: number | null;
|
||||||
|
host: BoardHost | null; notice: string; revision: number;
|
||||||
|
}
|
||||||
|
export interface FleetPreview {
|
||||||
|
preview_id: string; node_id: string; name: string; host: BoardHost;
|
||||||
|
endpoint: string; expires_at: number;
|
||||||
|
}
|
||||||
|
export async function fleetRequest<T>(path = "", method = "GET", body?: unknown): Promise<T> {
|
||||||
|
const response = await fetch(`/api/v1/fleet${path}`, {
|
||||||
|
method, credentials: "same-origin", cache: "no-store",
|
||||||
|
headers: body === undefined ? {} : { "Content-Type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(12000),
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(typeof data.detail === "string" ? data.detail : "Не удалось выполнить действие с аппаратом.");
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
export function useFleet() {
|
||||||
|
const [items, setItems] = useState<Vehicle[] | null>(null);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
const value = await fleetRequest<{ items: Vehicle[] }>();
|
||||||
|
setItems(value.items); setError(""); return value.items;
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
let fallback: ReturnType<typeof setInterval> | undefined;
|
||||||
|
const read = async () => {
|
||||||
|
try { const value = await fleetRequest<{ items: Vehicle[] }>(); if (active) { setItems(value.items); setError(""); } }
|
||||||
|
catch { if (active) setError("Реестр недоступен. Показаны последние полученные сведения; связь сейчас не подтверждена."); }
|
||||||
|
};
|
||||||
|
void read();
|
||||||
|
const events = new EventSource("/api/v1/fleet/events");
|
||||||
|
events.onmessage = event => {
|
||||||
|
if (!active) return;
|
||||||
|
try { const value = JSON.parse(event.data); setItems(value.items); setError(""); if (fallback) { clearInterval(fallback); fallback = undefined; } }
|
||||||
|
catch { unavailable(); }
|
||||||
|
};
|
||||||
|
const unavailable = () => {
|
||||||
|
if (!active) return;
|
||||||
|
setError("Связь обновляется. Показаны последние полученные сведения.");
|
||||||
|
if (!fallback) fallback = setInterval(() => void read(), 5000);
|
||||||
|
};
|
||||||
|
events.onerror = unavailable;
|
||||||
|
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
||||||
|
}, []);
|
||||||
|
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>;
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useRef, useState, type ReactNode } from 'react';
|
||||||
|
import { plannerBase, plannerRequest, type Draft } from './planner';
|
||||||
|
|
||||||
|
export interface PlanningLiveTest {
|
||||||
|
schema_version: 'missioncore.planning-live-test/v1'; id: string; profile: 'planning'; plugin_id:string; draft: Draft;
|
||||||
|
state: 'preparing'|'waiting'|'running'|'completed'|'cancelled'|'error'|'interrupted';
|
||||||
|
planning_phase?: 'preparing'|'waiting-cloud'|'collecting'|'searching'|'refreshing'|'validating'|'tracking'|'lost'|'ended';
|
||||||
|
tracking_state?: 'acquiring'|'tracking'|'lost';
|
||||||
|
tracking_established?: boolean;
|
||||||
|
presentation_state?: 'live'|'historical'|'unlocalized'; scene_revision?:number;
|
||||||
|
scene_height_min_m?:number|null; scene_height_max_m?:number|null;
|
||||||
|
message: string; query_session_id: string|null; distance_m: number; query_points?: number;
|
||||||
|
scene_available: boolean; stale: boolean; frame_age_s: number|null; result_age_s: number|null;
|
||||||
|
result: {status:'candidate'|'rejected'; overlap:number; inlier_rmse_m:number|null; reasons:string[]}|null;
|
||||||
|
route_relocalization_policy?: {version?:string;query_radius_m?:number};
|
||||||
|
}
|
||||||
|
export interface PlanningRunSummary {id:string;name:string;state:string;query_session_id:string|null;created_at_utc:string}
|
||||||
|
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<boolean>; busy:boolean; error:string|null;
|
||||||
|
begin:(draft:Draft)=>Promise<PlanningLiveTest|null>; finish:()=>Promise<void>; retryInitialization:()=>Promise<void>;
|
||||||
|
}|null>(null);
|
||||||
|
|
||||||
|
/** 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 [busy,setBusy]=useState(false),[error,setError]=useState<string|null>(null);
|
||||||
|
const [pollError,setPollError]=useState<string|null>(null);
|
||||||
|
const [history,setHistory]=useState<PlanningRunSummary[]>([]);
|
||||||
|
const generation=useRef(0);
|
||||||
|
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'}));return true;}
|
||||||
|
catch(e){setError(e instanceof Error?e.message:'Не удалось открыть исследование.');return false;}
|
||||||
|
finally{generation.current+=1;setBusy(false);}
|
||||||
|
},[]);
|
||||||
|
useEffect(()=>{
|
||||||
|
let disposed=false, timer:ReturnType<typeof setTimeout>;
|
||||||
|
const poll=async()=>{
|
||||||
|
const expected=generation.current;
|
||||||
|
try { const next=await plannerRequest<PlanningLiveTest|null>(endpoint+'/active'); if(!disposed && expected===generation.current) {setTest(next);setPollError(null);} }
|
||||||
|
catch(e) {if(!disposed && expected===generation.current)setPollError(e instanceof Error?e.message:'Исследование недоступно.');}
|
||||||
|
if(!disposed)timer=setTimeout(()=>void poll(),1500);
|
||||||
|
};void poll();return()=>{disposed=true;clearTimeout(timer);};
|
||||||
|
},[]);
|
||||||
|
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);return next;}
|
||||||
|
catch(e){setError(e instanceof Error?e.message:'Не удалось подготовить тест.');return null;}
|
||||||
|
finally{generation.current+=1;setBusy(false);}
|
||||||
|
},[]);
|
||||||
|
const finish=useCallback(async()=>{
|
||||||
|
if(!test)return;generation.current+=1;setBusy(true);
|
||||||
|
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/stop',{method:'POST'}));}
|
||||||
|
catch(e){setError(e instanceof Error?e.message:'Не удалось завершить тест.');}
|
||||||
|
finally{generation.current+=1;setBusy(false);}
|
||||||
|
},[test]);
|
||||||
|
const retryInitialization=useCallback(async()=>{
|
||||||
|
if(!test)return;generation.current+=1;setBusy(true);setError(null);
|
||||||
|
try{setTest(await plannerRequest<PlanningLiveTest>(endpoint+'/'+test.id+'/reinitialize',{method:'POST'}));}
|
||||||
|
catch(e){setError(e instanceof Error?e.message:'Не удалось переинициализировать привязку.');}
|
||||||
|
finally{generation.current+=1;setBusy(false);}
|
||||||
|
},[test]);
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
export interface PlanningPose { index: number; message_index: number; position: [number, number, number]; elapsed_s: number | null; distance_m: number }
|
||||||
|
export interface PlanningSource { schema_version: "missioncore.planning-source/v1"; session_id: string; generation: string; label: string; frame_id: string; units: "m"; poses: PlanningPose[]; path_m: number; decode_errors: number }
|
||||||
|
export interface SessionOption { id: string; label: string; modalities: string[]; replayable: boolean; lab?: unknown; }
|
||||||
|
export type Direction = "forward" | "reverse";
|
||||||
|
export interface Draft {
|
||||||
|
id: string; revision: number; name: string; updated_at_utc: string; vehicle_id: null;
|
||||||
|
zone: { session_id: string; generation: string; label: string };
|
||||||
|
route: { start_index: number; end_index: number; direction: Direction; length_m: number; points: { source_index: number; position: [number, number, number] }[] };
|
||||||
|
}
|
||||||
|
export interface RouteCheck { id: string; revision: number; length_m: number; pose_count: number; max_step_m: number; source_verified: boolean; warnings: string[]; localization: "not_run"; vehicle_control: false; }
|
||||||
|
export const plannerBase = "/api/v1/mission-planner";
|
||||||
|
export function canStartPlanningRoute(length: number, mode: 'scanner' | 'recording') {
|
||||||
|
return Number.isFinite(length) && length >= 3 && (mode === 'scanner' || length <= 40);
|
||||||
|
}
|
||||||
|
export async function plannerRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const response = await fetch(path, { ...init, cache: "no-store", headers: { "Content-Type": "application/json", ...init.headers } });
|
||||||
|
if (!response.ok) { const doc = await response.json().catch(() => null); throw new Error(typeof doc?.detail === "string" ? doc.detail : "Данные планировщика недоступны."); }
|
||||||
|
return response.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
export function canSelectSession(item: SessionOption) { return item.replayable && !item.lab && item.modalities.includes("point-cloud") && item.modalities.includes("trajectory"); }
|
||||||
|
export function selectedPoses(source: PlanningSource | null, start: number, end: number, direction: Direction) {
|
||||||
|
if (!source || start < 0 || end >= source.poses.length || start >= end) return [];
|
||||||
|
const poses = source.poses.slice(start, end + 1);
|
||||||
|
return direction === "reverse" ? poses.reverse() : poses;
|
||||||
|
}
|
||||||
|
export function routeLength(poses: PlanningPose[]) { return poses.slice(1).reduce((sum, p, index) => sum + Math.hypot(...p.position.map((v, axis) => v - poses[index].position[axis])), 0); }
|
||||||
|
export function endAtDistance(source: PlanningSource, start: number, length: number) {
|
||||||
|
const target = source.poses[start].distance_m + length;
|
||||||
|
const index = source.poses.findIndex((pose, index) => index > start && pose.distance_m >= target);
|
||||||
|
return index < 0 ? source.poses.length - 1 : index;
|
||||||
|
}
|
||||||
|
export function validatePlanningSource(data: PlanningSource, sessionId: string): PlanningSource {
|
||||||
|
if (data.schema_version !== "missioncore.planning-source/v1" || data.session_id !== sessionId || data.units !== "m"
|
||||||
|
|| !/^[a-f0-9]{64}$/.test(data.generation) || !Array.isArray(data.poses) || data.poses.length < 2 || data.poses.length > 100_000
|
||||||
|
|| !data.poses.every((p, i) => p.index === i && p.position.length === 3 && p.position.every(Number.isFinite) && Number.isFinite(p.distance_m))) throw new Error("Траектория записи некорректна.");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Distance along the recorded path, so overlapping outbound/return branches remain distinct. */
|
||||||
|
export function indexAtDistance(source: PlanningSource, distance: number, min = 0, max = source.poses.length - 1, before = false) {
|
||||||
|
let low = min, high = max;
|
||||||
|
while (low < high) { const mid = Math.floor((low + high) / 2); if (source.poses[mid].distance_m < distance) low = mid + 1; else high = mid; }
|
||||||
|
return before && low > min && source.poses[low].distance_m > distance ? low - 1 : low;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type {SpatialActivityPresentation} from '../device-plugins/contracts';
|
||||||
|
|
||||||
|
export interface PlanningMatchState {
|
||||||
|
state:string;stale:boolean;frame_age_s:number|null;result_age_s:number|null;
|
||||||
|
result:{status:string}|null;
|
||||||
|
tracking_state?:string;planning_phase?:string;tracking_established?:boolean;
|
||||||
|
presentation_state?:'live'|'historical'|'unlocalized';
|
||||||
|
}
|
||||||
|
/** Current geometry is insufficient: the temporal gate must also be tracking. */
|
||||||
|
export function planningMatchCurrent(t:PlanningMatchState,transportError=false){
|
||||||
|
return !transportError&&t.state==='running'&&t.tracking_state==='tracking'&&!t.stale
|
||||||
|
&&(t.presentation_state===undefined||t.presentation_state==='live')
|
||||||
|
&&t.frame_age_s!==null&&t.frame_age_s>=0&&t.frame_age_s<2
|
||||||
|
&&t.result_age_s!==null&&t.result_age_s>=0&&t.result_age_s<8
|
||||||
|
&&t.result?.status==='candidate';
|
||||||
|
}
|
||||||
|
|
||||||
|
type Status={label:string;tone:'neutral'|'success'|'warning'|'danger';message?:string;pulse?:boolean};
|
||||||
|
type PlanningPresentation=PlanningMatchState & {message:string};
|
||||||
|
const phaseLabels:Record<string,string>={
|
||||||
|
collecting:'Накопление данных',searching:'Поиск положения на маршруте',
|
||||||
|
refreshing:'Подтверждение привязки',validating:'Подтверждение привязки',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Terminal failures and data freshness take precedence over successful old fits. */
|
||||||
|
export function planningStatus(t:PlanningPresentation,error:string|null=null):Status{
|
||||||
|
if(error)return {label:'Исследование недоступно',tone:'danger',message:error};
|
||||||
|
if(t.state==='error'||t.state==='interrupted')return {label:'Совмещение остановлено',tone:'danger',message:t.message,pulse:true};
|
||||||
|
if(t.state==='completed'||t.state==='cancelled')return {label:'Исследование завершено',tone:'neutral',message:t.message+(t.presentation_state==='historical'?' Показана последняя принятая привязка; это не текущее положение.':'')};
|
||||||
|
if(planningMatchCurrent(t))return {label:'Сопровождение',tone:'success',message:t.message};
|
||||||
|
if(t.planning_phase==='recovering')return {label:'Восстановление привязки',tone:'danger',pulse:true,message:'Остановитесь. Ищем положение по новым данным; запись продолжается.'};
|
||||||
|
if(t.planning_phase==='lost'&&!t.tracking_established)
|
||||||
|
return {label:'Маршрут не синхронизирован',tone:'warning',message:t.message};
|
||||||
|
if(t.planning_phase==='lost'||t.planning_phase==='tracking')
|
||||||
|
return {label:'Привязка потеряна',tone:'danger',pulse:true,message:'Нет актуального подтверждения привязки. Остановитесь; ожидается восстановление по новым данным.'+(t.presentation_state==='historical'?' Сцена сохранена в последней принятой привязке.':'')};
|
||||||
|
if(t.planning_phase&&phaseLabels[t.planning_phase])
|
||||||
|
return {label:phaseLabels[t.planning_phase],tone:'neutral'};
|
||||||
|
if(t.result?.status==='rejected')return {label:'Совпадение не подтверждено',tone:'warning',message:t.message};
|
||||||
|
return {label:t.state==='preparing'?'Подготовка эталона':t.stale?'Ожидание данных':'Приём нового прохода',tone:'neutral',message:t.message};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A plugin may show this only for its matching, authoritative acquiring session. */
|
||||||
|
export function planningActivity(
|
||||||
|
t:PlanningPresentation & {query_session_id:string|null},error:string|null=null,
|
||||||
|
):SpatialActivityPresentation|undefined{
|
||||||
|
if(!t.query_session_id||!['running','error'].includes(t.state)
|
||||||
|
||!t.planning_phase||['preparing','waiting-cloud','ended'].includes(t.planning_phase))return undefined;
|
||||||
|
const status=planningStatus(t,error);
|
||||||
|
return {sessionId:t.query_session_id,label:status.label,detail:status.message??'',
|
||||||
|
busy:!error&&t.state==='running'&&Object.hasOwn(phaseLabels,t.planning_phase)};
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import type { PlanningSceneDelivery } from './planningSceneStream';
|
||||||
|
|
||||||
|
export const PLANNING_BROWSER_PRESENTATION_SCHEMA =
|
||||||
|
'missioncore.planning-browser-presentation/v1';
|
||||||
|
|
||||||
|
type BrowserPresentationSample = {
|
||||||
|
cloud_revision:number;
|
||||||
|
cloud_sequence:number;
|
||||||
|
display_epoch:string;
|
||||||
|
request_ms:number;
|
||||||
|
rerun_admission_ms:number;
|
||||||
|
first_animation_frame_ms:number|null;
|
||||||
|
second_animation_frame_ms:number|null;
|
||||||
|
frame_timeout:boolean;
|
||||||
|
source_to_second_animation_frame_upper_bound_ms:number|null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReporterDependencies = {
|
||||||
|
url:string;
|
||||||
|
fetcher?:typeof fetch;
|
||||||
|
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||||
|
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const batchSize=8;
|
||||||
|
const flushDelayMs=250;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounded, best-effort browser observation. These samples never drive scene
|
||||||
|
* admission, registration, navigation, or device control.
|
||||||
|
*/
|
||||||
|
export function createPlanningPresentationReporter(deps:ReporterDependencies){
|
||||||
|
const fetcher=deps.fetcher??fetch;
|
||||||
|
const schedule=deps.schedule??setTimeout;
|
||||||
|
const cancel=deps.cancel??clearTimeout;
|
||||||
|
let pending:BrowserPresentationSample[]=[];
|
||||||
|
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||||
|
let sending=false,stopped=false;
|
||||||
|
|
||||||
|
const flush=async()=>{
|
||||||
|
if(sending||stopped||!pending.length)return;
|
||||||
|
if(timer!==undefined){cancel(timer);timer=undefined;}
|
||||||
|
sending=true;
|
||||||
|
const samples=pending.splice(0,batchSize);
|
||||||
|
try{
|
||||||
|
// Deliberately best-effort: the presentation observer must not make the
|
||||||
|
// live Rerun channel wait for an unrelated report request.
|
||||||
|
const response=await fetcher(deps.url,{method:'POST',cache:'no-store',keepalive:true,
|
||||||
|
headers:{'Content-Type':'application/json'},
|
||||||
|
body:JSON.stringify({schema_version:PLANNING_BROWSER_PRESENTATION_SCHEMA,samples}),
|
||||||
|
});
|
||||||
|
if(!response.ok)throw new Error(`Planning presentation telemetry was not accepted (${response.status})`);
|
||||||
|
}catch{
|
||||||
|
// A lost browser report is observable as fewer samples, never retried into
|
||||||
|
// an unbounded queue and never promoted into a capture/fit failure.
|
||||||
|
}finally{
|
||||||
|
sending=false;
|
||||||
|
if(!stopped&&pending.length)void flush();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const record=(delivery:PlanningSceneDelivery,timing:{
|
||||||
|
rerunAdmissionMs:number;firstAnimationFrameMs:number|null;
|
||||||
|
secondAnimationFrameMs:number|null;
|
||||||
|
})=>{
|
||||||
|
if(stopped||delivery.presentation!=='live'||delivery.cloudRevision===null||
|
||||||
|
delivery.cloudSequence===null||delivery.displayEpoch===null)return;
|
||||||
|
const second=timing.secondAnimationFrameMs;
|
||||||
|
pending.push({
|
||||||
|
cloud_revision:delivery.cloudRevision,
|
||||||
|
cloud_sequence:delivery.cloudSequence,
|
||||||
|
display_epoch:delivery.displayEpoch,
|
||||||
|
request_ms:delivery.requestMs,
|
||||||
|
rerun_admission_ms:timing.rerunAdmissionMs,
|
||||||
|
first_animation_frame_ms:timing.firstAnimationFrameMs,
|
||||||
|
second_animation_frame_ms:second,
|
||||||
|
frame_timeout:second===null,
|
||||||
|
source_to_second_animation_frame_upper_bound_ms:second===null?null:
|
||||||
|
delivery.cloudAgeMs+delivery.requestMs+timing.rerunAdmissionMs+second,
|
||||||
|
});
|
||||||
|
if(pending.length>=batchSize)void flush();
|
||||||
|
else if(timer===undefined)timer=schedule(()=>{timer=undefined;void flush();},flushDelayMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
record,
|
||||||
|
dispose:()=>{
|
||||||
|
stopped=true;
|
||||||
|
if(timer!==undefined)cancel(timer);
|
||||||
|
pending=[];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Draft } from './planner';
|
||||||
|
import type { RegistrationReport } from './useRegistrationTest';
|
||||||
|
import { plannerBase, plannerRequest } from './planner';
|
||||||
|
|
||||||
|
export interface PlanningProject {
|
||||||
|
key: string; kind: 'recorded'|'live'|'draft'; id: string; name: string;
|
||||||
|
state: string; created_at_utc: string; result_status: string|null;
|
||||||
|
reference_label: string; query_label: string|null; draft_id: string; revision: number;
|
||||||
|
}
|
||||||
|
export interface PlanningProjectDetail extends PlanningProject {
|
||||||
|
draft: Draft; result: RegistrationReport['result'] | null; message?: string;
|
||||||
|
scene_url: string|null; evidence_relation?: string; elapsed_seconds?: number;
|
||||||
|
scene_note?:string|null;
|
||||||
|
}
|
||||||
|
export const planningProjectPending = (p: PlanningProject) => ['queued','running','preparing','waiting'].includes(p.state);
|
||||||
|
export const planningProjectDeletable = (p: PlanningProject) => p.kind === 'draft'
|
||||||
|
|| (p.kind === 'recorded' ? ['ready', 'error'] : ['completed', 'cancelled', 'error', 'interrupted']).includes(p.state);
|
||||||
|
export async function deletePlanningProject(project: PlanningProject) {
|
||||||
|
if (!planningProjectDeletable(project)) throw new Error('Сначала завершите исследование.');
|
||||||
|
const receipt = await plannerRequest<{key: string; deleted: boolean}>(
|
||||||
|
`${plannerBase}/projects/${project.kind}/${encodeURIComponent(project.id)}`,
|
||||||
|
{method: 'DELETE', body: JSON.stringify({revision: project.revision})},
|
||||||
|
);
|
||||||
|
if (receipt.key !== project.key || receipt.deleted !== true) throw new Error('Удаление проекта не подтверждено. Обновите список.');
|
||||||
|
}
|
||||||
|
export function planningProjectStatus(p: PlanningProject) {
|
||||||
|
if (p.kind === 'draft') return 'Подготовка';
|
||||||
|
if (planningProjectPending(p)) return 'Выполняется';
|
||||||
|
if (p.result_status === 'candidate') return 'Кандидат совмещения';
|
||||||
|
if (p.result_status === 'rejected') return 'Совмещение отклонено';
|
||||||
|
return 'Без результата совмещения';
|
||||||
|
}
|
||||||
|
export function defaultPlanningProject(items: PlanningProject[], last: string|null) {
|
||||||
|
return items.find(p => p.key === last)?.key
|
||||||
|
?? items.find(p => p.kind === 'recorded' && p.state === 'ready')?.key
|
||||||
|
?? items[0]?.key ?? '';
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
/** One in-flight request, latest-only deltas, no browser backlog or authority. */
|
||||||
|
type Snapshot = {options:Record<string,string|number|boolean>;active:boolean;revision:number};
|
||||||
|
export type PlanningSceneDelivery = {
|
||||||
|
presentation:'live'|'historical'; cloudAgeMs:number; fitAgeMs:number;
|
||||||
|
requestMs:number; cloudRevision:number|null; cloudSequence:number|null;
|
||||||
|
displayEpoch:string|null; heightMinM:number|null; heightMaxM:number|null;
|
||||||
|
};
|
||||||
|
type Dependencies = {
|
||||||
|
url:string; snapshot:()=>Snapshot;
|
||||||
|
apply:(bytes:Uint8Array,delivery:PlanningSceneDelivery)=>void|Promise<void>; error:()=>void;
|
||||||
|
fetcher?:typeof fetch; now?:()=>number;
|
||||||
|
schedule?:(callback:()=>void,ms:number)=>ReturnType<typeof setTimeout>;
|
||||||
|
cancel?:(timer:ReturnType<typeof setTimeout>)=>void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function startPlanningSceneStream(deps:Dependencies){
|
||||||
|
const fetcher=deps.fetcher??fetch, now=deps.now??(()=>performance.now());
|
||||||
|
const schedule=deps.schedule??setTimeout, cancel=deps.cancel??clearTimeout;
|
||||||
|
let stopped=false,cursor='',key='',revision=-1,needsBase=true;
|
||||||
|
let timer:ReturnType<typeof setTimeout>|undefined;
|
||||||
|
let request:AbortController|undefined;
|
||||||
|
const poll=async()=>{
|
||||||
|
if(stopped)return;
|
||||||
|
const started=now(), snapshot=deps.snapshot(), nextKey=JSON.stringify(snapshot.options);
|
||||||
|
if(!needsBase&&cursor&&!snapshot.active&&key===nextKey&&revision===snapshot.revision){
|
||||||
|
timer=schedule(()=>void poll(),500);return;
|
||||||
|
}
|
||||||
|
request=new AbortController();
|
||||||
|
const deadline=schedule(()=>request?.abort(),2500);
|
||||||
|
let failed=false;
|
||||||
|
try{
|
||||||
|
const params=new URLSearchParams(Object.entries(snapshot.options).map(([name,value])=>[name,String(value)]));
|
||||||
|
params.set('base',String(needsBase||!cursor||key!==nextKey));
|
||||||
|
if(cursor)params.set('cursor',cursor);
|
||||||
|
const response=await fetcher(`${deps.url}?${params}`,{cache:'no-store',signal:request.signal});
|
||||||
|
if(!response.ok)throw new Error('scene unavailable');
|
||||||
|
const bytes=response.status===204?null:new Uint8Array(await response.arrayBuffer());
|
||||||
|
if(stopped)return;
|
||||||
|
const presentation=response.headers.get('X-Planning-Presentation')==='live'?'live':'historical';
|
||||||
|
const cloud=Number(response.headers.get('X-Planning-Cloud-Age')??NaN);
|
||||||
|
const fit=Number(response.headers.get('X-Planning-Fit-Age')??NaN);
|
||||||
|
const elapsed=now()-started;
|
||||||
|
if(presentation==='live'){
|
||||||
|
// Include the entire request duration: a conservative upper bound, without
|
||||||
|
// comparing clocks on different hosts. Late bytes must not revive green.
|
||||||
|
if(!Number.isFinite(cloud)||!Number.isFinite(fit)||cloud<0||fit<0||cloud+elapsed/1000>=2||fit+elapsed/1000>=8)
|
||||||
|
throw new Error('scene expired during delivery');
|
||||||
|
}
|
||||||
|
const latest=deps.snapshot();
|
||||||
|
// Never apply a response from the previous display mode or live/ended state.
|
||||||
|
if(JSON.stringify(latest.options)!==nextKey||latest.active!==snapshot.active){needsBase=true;return;}
|
||||||
|
const nextCursor=response.headers.get('X-Planning-Scene-Cursor');
|
||||||
|
if(!nextCursor)throw new Error('scene cursor missing');
|
||||||
|
if(bytes)await deps.apply(bytes,{
|
||||||
|
presentation,cloudAgeMs:presentation==='live'?cloud*1000:0,
|
||||||
|
fitAgeMs:presentation==='live'?fit*1000:0,requestMs:elapsed,
|
||||||
|
cloudRevision:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Revision')),
|
||||||
|
cloudSequence:parseNonnegativeInteger(response.headers.get('X-Planning-Cloud-Sequence')),
|
||||||
|
displayEpoch:response.headers.get('X-Planning-Display-Epoch'),
|
||||||
|
heightMinM:parseFiniteNumber(response.headers.get('X-Planning-Height-Min')),
|
||||||
|
heightMaxM:parseFiniteNumber(response.headers.get('X-Planning-Height-Max')),
|
||||||
|
}); // Native admission only; the caller may record a separate presentation proxy.
|
||||||
|
cursor=nextCursor;key=nextKey;revision=snapshot.revision;needsBase=false;
|
||||||
|
}catch{
|
||||||
|
// Keep the last admitted cursor (including camera intent). A full geometry
|
||||||
|
// repair after a missed/expired response is not permission to reset the eye.
|
||||||
|
if(!stopped){failed=true;needsBase=true;deps.error();}
|
||||||
|
}finally{
|
||||||
|
cancel(deadline);request=undefined;
|
||||||
|
if(!stopped)timer=schedule(()=>void poll(),failed?500:Math.max(10,(snapshot.active?100:500)-(now()-started)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void poll();
|
||||||
|
return ()=>{stopped=true;request?.abort();if(timer!==undefined)cancel(timer);};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonnegativeInteger(value:string|null){
|
||||||
|
if(value===null||!/^\d+$/.test(value))return null;
|
||||||
|
const parsed=Number(value);
|
||||||
|
return Number.isSafeInteger(parsed)&&parsed>0?parsed:null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseFiniteNumber(value:string|null){
|
||||||
|
if(value===null||value.trim()==='')return null;
|
||||||
|
const parsed=Number(value);
|
||||||
|
return Number.isFinite(parsed)?parsed:null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
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 [sessions, setSessions] = useState<SessionOption[]>([]);
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const [drafts, setDrafts] = useState<Draft[]>([]);
|
||||||
|
const [saved, setSaved] = useState<Draft | null>(null);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [sessionId, setSessionId] = useState("");
|
||||||
|
const [source, setSource] = useState<PlanningSource | null>(null);
|
||||||
|
const [direction, setDirection] = useState<Direction>("forward");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sourceError, setSourceError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
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?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); })
|
||||||
|
.finally(() => { if (!abort.signal.aborted) setCatalogBusy(false); });
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [loadVersion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController(); setSource(null); setSourceError(null);
|
||||||
|
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, 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) => { 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}`);
|
||||||
|
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); }
|
||||||
|
};
|
||||||
|
const save = async () => {
|
||||||
|
if (!ready || !currentSource) return;
|
||||||
|
setBusy(true); setError(null); setCheck(null);
|
||||||
|
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, whole_recording: true, direction,
|
||||||
|
}) });
|
||||||
|
setSaved(next); setDrafts(items => [next, ...items.filter(item => item.id !== next.id)]);
|
||||||
|
return next;
|
||||||
|
} catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
const runCheck = async () => {
|
||||||
|
if (!saved || dirty || !ready) return;
|
||||||
|
setBusy(true); setError(null); setCheck(null);
|
||||||
|
try { setCheck(await plannerRequest<RouteCheck>(`${plannerBase}/drafts/${saved.id}/checks`, { method: "POST", body: JSON.stringify({ revision: saved.revision }) })); }
|
||||||
|
catch (reason) { setError((reason as Error).message); } finally { setBusy(false); }
|
||||||
|
};
|
||||||
|
const loadMore = async () => {
|
||||||
|
if (!cursor) return;
|
||||||
|
setCatalogBusy(true);
|
||||||
|
try {
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
description: canSelectSession(item) ? "Облако и траектория" : "Зона недоступна: нет исходного облака и траектории", disabled: !canSelectSession(item) }))] };
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { plannerBase, plannerRequest } from './planner';
|
||||||
|
import { defaultPlanningProject, deletePlanningProject, planningProjectPending, type PlanningProject, type PlanningProjectDetail } from './planningProjects';
|
||||||
|
|
||||||
|
const storageKey = 'missioncore.planning-project.selected.v1';
|
||||||
|
export function usePlanningProjects() {
|
||||||
|
const [items, setItems] = useState<PlanningProject[]>([]);
|
||||||
|
const [key, setKey] = useState('');
|
||||||
|
const [detail, setDetail] = useState<PlanningProjectDetail|null>(null);
|
||||||
|
const [loading, setLoading] = useState(true), [error, setError] = useState<string|null>(null);
|
||||||
|
const [version, setVersion] = useState(0);
|
||||||
|
const initialized = useRef(false);
|
||||||
|
const selectedKey = useRef('');
|
||||||
|
const removedKeys = useRef(new Set<string>());
|
||||||
|
const removing = useRef(false);
|
||||||
|
const select = useCallback((value: string) => {
|
||||||
|
initialized.current = true; selectedKey.current = value; setDetail(null); setKey(value);
|
||||||
|
try { if (value) sessionStorage.setItem(storageKey,value); else sessionStorage.removeItem(storageKey); } catch { /* selection is optional UI state */ }
|
||||||
|
}, []);
|
||||||
|
const refresh = useCallback(() => setVersion(n=>n+1), []);
|
||||||
|
const remove = useCallback(async (project: PlanningProject) => {
|
||||||
|
if (removing.current) throw new Error('Удаление уже выполняется.');
|
||||||
|
removing.current = true;
|
||||||
|
try {
|
||||||
|
await deletePlanningProject(project);
|
||||||
|
removedKeys.current.add(project.key);
|
||||||
|
setItems(items => items.filter(item => item.key !== project.key));
|
||||||
|
if (selectedKey.current === project.key) select('');
|
||||||
|
} finally { removing.current = false; }
|
||||||
|
}, [select]);
|
||||||
|
useEffect(()=>{
|
||||||
|
const abort = new AbortController(); setLoading(true); setError(null);
|
||||||
|
void plannerRequest<{items:PlanningProject[]}>(plannerBase+'/projects',{signal:abort.signal}).then(data=>{
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
const visible = data.items.filter(item => !removedKeys.current.has(item.key));
|
||||||
|
setItems(visible);
|
||||||
|
if (!initialized.current) {
|
||||||
|
let last=null; try {last=sessionStorage.getItem(storageKey);} catch { /* optional */ }
|
||||||
|
select(defaultPlanningProject(visible,last));
|
||||||
|
}
|
||||||
|
}).catch(e=>{if(!abort.signal.aborted)setError(e.message);}).finally(()=>{if(!abort.signal.aborted)setLoading(false);});
|
||||||
|
return ()=>abort.abort();
|
||||||
|
},[version,select]);
|
||||||
|
useEffect(()=>{
|
||||||
|
if (!key) return;
|
||||||
|
const abort=new AbortController(); let timer:ReturnType<typeof setTimeout>;
|
||||||
|
setDetail(null); setError(null);
|
||||||
|
const [kind,id]=key.split(':');
|
||||||
|
const poll=async()=>{
|
||||||
|
try {
|
||||||
|
const next=await plannerRequest<PlanningProjectDetail>(`${plannerBase}/projects/${kind}/${id}`,{signal:abort.signal});
|
||||||
|
if(abort.signal.aborted || removedKeys.current.has(key))return;
|
||||||
|
if(next.key!==key)throw new Error('Получен результат другого исследования.');
|
||||||
|
setDetail(next);setError(null);
|
||||||
|
setItems(items=>items.some(p=>p.key===key)?items.map(p=>p.key===key?next:p):[next,...items]);
|
||||||
|
if(planningProjectPending(next))timer=setTimeout(()=>void poll(),2000);
|
||||||
|
} catch(e) {if(!abort.signal.aborted)setError((e as Error).message);}
|
||||||
|
};
|
||||||
|
void poll(); return()=>{abort.abort();clearTimeout(timer);};
|
||||||
|
},[key,version]);
|
||||||
|
return {items,key,detail,loading,error,select,refresh,remove};
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
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;
|
||||||
|
revision: number; created_at_utc: string; evidence_relation: "same_recording" | "different_recordings";
|
||||||
|
scene_url?: string; elapsed_seconds?: number; reference?: { label: string }; query?: { label: string };
|
||||||
|
result?: { correspondence_colors?: "accepted-distance-v1"; status: "candidate" | "rejected"; reasons: string[]; overlap: number; inlier_rmse_m: number | null;
|
||||||
|
correction_m: number; correction_deg: number; registration_seconds: number; reference_points: number; query_points: number };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRegistrationTest(draft: Draft | null) {
|
||||||
|
const [sessionId, setSessionId] = useState("");
|
||||||
|
const [source, setSource] = useState<PlanningSource | null>(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 })
|
||||||
|
.then(value => { if (!abort.signal.aborted) { const data = validatePlanningSource(value, sessionId); setSource(data); setStart(0); setEnd(endAtDistance(data, 0, 20)); } })
|
||||||
|
.catch(e => { if (!abort.signal.aborted) setError(e.message); })
|
||||||
|
.finally(() => { if (!abort.signal.aborted) setLoading(false); });
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [sessionId]);
|
||||||
|
const busy = submitting;
|
||||||
|
const length = source ? (source.poses[end]?.distance_m ?? 0) - (source.poses[start]?.distance_m ?? 0) : 0;
|
||||||
|
const run = async (savedDraft = draft) => {
|
||||||
|
if (!source || busy || !savedDraft) return;
|
||||||
|
setSubmitting(true); setError(null);
|
||||||
|
try {
|
||||||
|
const data = await plannerRequest<RegistrationReport>(`${plannerBase}/drafts/${savedDraft.id}/registration-runs`, {
|
||||||
|
method: "POST", body: JSON.stringify({ revision: savedDraft.revision, session_id: sessionId, generation: source.generation, start_index: start, end_index: end }),
|
||||||
|
});
|
||||||
|
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, 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"
|
| "interrupted"
|
||||||
| "failed";
|
| "failed";
|
||||||
|
|
||||||
export type ObservationSessionScope = "all" | "source" | "laboratory";
|
export type ObservationSessionScope = "all" | "source" | "standalone" | "laboratory";
|
||||||
|
|
||||||
export interface ObservationLabInstance {
|
export interface ObservationLabInstance {
|
||||||
labId: string;
|
labId: string;
|
||||||
@@ -79,6 +79,7 @@ export interface ObservationSessionReplayLaunch {
|
|||||||
seekable: true;
|
seekable: true;
|
||||||
byteLength: number;
|
byteLength: number;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
|
mapGeneration?: string;
|
||||||
playback: {
|
playback: {
|
||||||
speed: number;
|
speed: number;
|
||||||
loop: boolean;
|
loop: boolean;
|
||||||
@@ -237,6 +238,7 @@ const REPLAY_LAUNCH_KEYS = new Set([
|
|||||||
"seekable",
|
"seekable",
|
||||||
"byte_length",
|
"byte_length",
|
||||||
"sha256",
|
"sha256",
|
||||||
|
"map_generation",
|
||||||
"playback",
|
"playback",
|
||||||
"media_sources",
|
"media_sources",
|
||||||
]);
|
]);
|
||||||
@@ -787,6 +789,9 @@ export function decodeObservationSessionReplay(
|
|||||||
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
if (typeof launch.sha256 !== "string" || !SHA256.test(launch.sha256)) {
|
||||||
throw new ObservationSessionContractError("Descriptor записи не содержит SHA-256.");
|
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(
|
const viewerSourceUrl = requireString(
|
||||||
launch.viewer_source_url,
|
launch.viewer_source_url,
|
||||||
"launch.viewer_source_url",
|
"launch.viewer_source_url",
|
||||||
@@ -845,6 +850,7 @@ export function decodeObservationSessionReplay(
|
|||||||
integer: true,
|
integer: true,
|
||||||
}),
|
}),
|
||||||
sha256: launch.sha256,
|
sha256: launch.sha256,
|
||||||
|
...(launch.map_generation === undefined ? {} : { mapGeneration: launch.map_generation as string }),
|
||||||
playback: { speed, loop: launch.playback.loop },
|
playback: { speed, loop: launch.playback.loop },
|
||||||
mediaSources,
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
export interface SessionOverviewMetrics {
|
||||||
|
point_frames: number; pose_frames: number; point_count: number; sample_points: number;
|
||||||
|
decode_errors: number; sequence_gaps: number; sequence_nonincreasing: number;
|
||||||
|
path_m: number | null; start_end_m: number | null; stream_seconds: number | null;
|
||||||
|
mean_hz: number | null; interval_p95_s: number | null; interval_max_s: number | null;
|
||||||
|
interval_statistics_complete: boolean; gaps_over_second: number | null;
|
||||||
|
arrival_backwards: number; chart: [number, number][]; chart_bucket_seconds: number;
|
||||||
|
spatial_available: boolean;
|
||||||
|
}
|
||||||
|
export interface SessionOverview {
|
||||||
|
schema_version: "missioncore.session-overview/v1";
|
||||||
|
state: "queued" | "preparing" | "ready" | "error";
|
||||||
|
session: { session_id: string; display_name: string; duration_seconds: number | null;
|
||||||
|
started_at_utc: string | null; total_bytes: number; modalities: string[]; status: string; };
|
||||||
|
metrics?: SessionOverviewMetrics | null;
|
||||||
|
scene_url?: string | null;
|
||||||
|
message?: string;
|
||||||
|
messages_processed?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchSessionOverview(sessionId: string, signal: AbortSignal, retry = false): Promise<SessionOverview> {
|
||||||
|
const response = await fetch(`/api/v1/observation-sessions/${encodeURIComponent(sessionId)}/overview${retry ? "/retry" : ""}`, {
|
||||||
|
signal, method: retry ? "POST" : "GET", cache: "no-store",
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Обзор записи недоступен.");
|
||||||
|
const data = await response.json() as SessionOverview;
|
||||||
|
if (data.schema_version !== "missioncore.session-overview/v1" || data.session?.session_id !== sessionId
|
||||||
|
|| !["queued", "preparing", "ready", "error"].includes(data.state)) throw new Error("Получены некорректные сведения о записи.");
|
||||||
|
if (data.scene_url) {
|
||||||
|
const url = new URL(data.scene_url, window.location.origin);
|
||||||
|
if (url.origin !== window.location.origin || url.pathname !== `/api/v1/observation-sessions/${sessionId}/overview/scene.rrd`) throw new Error("Облако записи недоступно.");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overviewChartPoints(chart: readonly [number, number][], width: number, height: number) {
|
||||||
|
const valid = chart.filter(([x, y]) => Number.isFinite(x) && Number.isFinite(y) && x >= 0 && y >= 0);
|
||||||
|
const xmax = Math.max(1, ...valid.map(p => p[0]));
|
||||||
|
const ymax = Math.max(.1, ...valid.map(p => p[1])) * 1.1;
|
||||||
|
return { xmax, ymax, points: valid.map(([x, y]) => `${x / xmax * width},${height - y / ymax * height}`).join(" ") };
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
export type OverviewViewMode = "3d" | "top";
|
||||||
|
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);
|
||||||
|
if (url.origin !== window.location.origin || !url.pathname.endsWith("/overview/scene.rrd")) throw new Error("Облако недоступно.");
|
||||||
|
url.pathname = url.pathname.replace(/scene\.rrd$/, "spatial");
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
export async function fetchOverviewSpatial(source: string, signal: AbortSignal): Promise<OverviewSpatialMetadata> {
|
||||||
|
const response = await fetch(endpoint(source), { signal, cache: "no-store" });
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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("Обзор превышает допустимый размер.");
|
||||||
|
return { bytes, visiblePoints: Number(response.headers.get("X-Overview-Visible-Points")),
|
||||||
|
eye: response.headers.get("X-Overview-Eye") ? JSON.parse(response.headers.get("X-Overview-Eye")!) : null };
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {useCallback, useEffect, useRef, useState} from 'react';
|
||||||
|
import {defaultSceneSettings, type SceneSettings} from '../../sceneSettings';
|
||||||
|
import {loadSessionDisplay, saveSessionDisplay} from './sessionDisplayProfile';
|
||||||
|
|
||||||
|
/** Session-bound read/write fencing. No viewer, playback or device ownership. */
|
||||||
|
export function useSessionDisplayProfile(sessionId: string | null, apply: (settings: SceneSettings) => void) {
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const applyRef = useRef(apply); applyRef.current = apply;
|
||||||
|
const current = useRef(sessionId); current.current = sessionId;
|
||||||
|
const writeTail = useRef(Promise.resolve());
|
||||||
|
const revision = useRef(0);
|
||||||
|
useEffect(() => {
|
||||||
|
setError(null);
|
||||||
|
if (!sessionId) return;
|
||||||
|
const abort = new AbortController();
|
||||||
|
const readRevision = revision.current;
|
||||||
|
applyRef.current({...defaultSceneSettings});
|
||||||
|
void writeTail.current.then(() => loadSessionDisplay(sessionId, abort.signal)).then(settings => {
|
||||||
|
if (abort.signal.aborted || current.current !== sessionId) return;
|
||||||
|
if (revision.current === readRevision) applyRef.current(settings);
|
||||||
|
}).catch(reason => {
|
||||||
|
if (!abort.signal.aborted && current.current === sessionId) setError(String(reason.message ?? reason));
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [sessionId]);
|
||||||
|
const edited = useCallback(() => { revision.current += 1; }, []);
|
||||||
|
const save = useCallback((settings: SceneSettings) => {
|
||||||
|
const id = current.current;
|
||||||
|
if (!id) return;
|
||||||
|
const snapshot = {...settings};
|
||||||
|
writeTail.current = writeTail.current.then(() => saveSessionDisplay(id, snapshot)).then(() => {
|
||||||
|
if (current.current === id) setError(null);
|
||||||
|
}).catch(reason => {
|
||||||
|
if (current.current === id) setError(String(reason.message ?? reason));
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
return {save, edited, error, dismissError: () => setError(null)};
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { fetchSessionOverview, type SessionOverview } from "./sessionOverview";
|
||||||
|
|
||||||
|
export function useSessionOverview(sessionId: string) {
|
||||||
|
const [data, setData] = useState<SessionOverview | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [retryGeneration, setRetryGeneration] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
setData(null); setError(null);
|
||||||
|
const load = async (retry = false) => {
|
||||||
|
try {
|
||||||
|
const next = await fetchSessionOverview(sessionId, abort.signal, retry);
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
setData(next);
|
||||||
|
if (next.state === "queued" || next.state === "preparing") timer = setTimeout(() => void load(), 1200);
|
||||||
|
} catch (reason) {
|
||||||
|
if (!abort.signal.aborted) setError(reason instanceof Error ? reason.message : "Обзор недоступен.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void load(retryGeneration > 0);
|
||||||
|
return () => { abort.abort(); clearTimeout(timer); };
|
||||||
|
}, [sessionId, retryGeneration]);
|
||||||
|
return { data: data?.session.session_id === sessionId ? data : null, error,
|
||||||
|
retry: useCallback(() => setRetryGeneration(n => n + 1), []) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionOverviewMode(enabled: boolean) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
useEffect(() => { if (!enabled) setOpen(false); }, [enabled]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const close = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === "Escape" && !event.defaultPrevented) { event.preventDefault(); event.stopPropagation(); setOpen(false); }
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", close, true);
|
||||||
|
return () => window.removeEventListener("keydown", close, true);
|
||||||
|
}, [open]);
|
||||||
|
return { open: enabled && open, close: useCallback(() => setOpen(false), []), toggle: useCallback(() => setOpen(v => !v), []) };
|
||||||
|
}
|
||||||
@@ -85,6 +85,13 @@ export interface RecordedSessionRerunProfile {
|
|||||||
lockPerceptionCameraInteraction: boolean;
|
lockPerceptionCameraInteraction: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A LAB owns its result presentation; it does not inherit session-view settings. */
|
||||||
|
export interface LaboratoryResultRerunProfile
|
||||||
|
extends Omit<RecordedSessionRerunProfile, "kind"> {
|
||||||
|
kind: "laboratory-result";
|
||||||
|
resultId: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
* Deprecated comparison-only contract for LAB artifacts that have not yet
|
||||||
* been republished as a native Rerun sidecar. It must never be selected by a
|
* been republished as a native Rerun sidecar. It must never be selected by a
|
||||||
@@ -101,7 +108,8 @@ export interface LaboratoryRecordedEvidenceViewerProfile {
|
|||||||
|
|
||||||
export type RerunViewerProfile =
|
export type RerunViewerProfile =
|
||||||
| LiveAcquisitionRerunProfile
|
| LiveAcquisitionRerunProfile
|
||||||
| RecordedSessionRerunProfile;
|
| RecordedSessionRerunProfile
|
||||||
|
| LaboratoryResultRerunProfile;
|
||||||
|
|
||||||
export type ObservationViewerProfile =
|
export type ObservationViewerProfile =
|
||||||
| RerunViewerProfile
|
| RerunViewerProfile
|
||||||
@@ -119,11 +127,18 @@ export const LABORATORY_RECORDED_EVIDENCE_VIEWER_PROFILE = Object.freeze({
|
|||||||
export function liveAcquisitionRerunProfile(
|
export function liveAcquisitionRerunProfile(
|
||||||
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
input: Omit<LiveAcquisitionRerunProfile, "kind" | "clock">,
|
||||||
): LiveAcquisitionRerunProfile {
|
): LiveAcquisitionRerunProfile {
|
||||||
return { kind: "live-acquisition", clock: "stream_time", ...input };
|
return { ...input, kind: "live-acquisition", clock: "stream_time" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function recordedSessionRerunProfile(
|
export function recordedSessionRerunProfile(
|
||||||
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
input: Omit<RecordedSessionRerunProfile, "kind" | "clock">,
|
||||||
): RecordedSessionRerunProfile {
|
): RecordedSessionRerunProfile {
|
||||||
return { kind: "recorded-session", clock: "session_time", ...input };
|
return { ...input, kind: "recorded-session", clock: "session_time" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function laboratoryResultRerunProfile(
|
||||||
|
input: Omit<LaboratoryResultRerunProfile, "kind" | "clock">,
|
||||||
|
): LaboratoryResultRerunProfile {
|
||||||
|
if (!input.resultId.trim()) throw new Error("LAB result identity is required");
|
||||||
|
return { ...input, kind: "laboratory-result", clock: "session_time" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
const body = await bodyOf(response);
|
||||||
if (!response.ok) throw apiError(body, response.status);
|
if (!response.ok) throw apiError(body, response.status);
|
||||||
|
return decodeAIModuleCatalog(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeAIModuleCatalog(body: unknown): AIModuleCatalog {
|
||||||
const root = record(body, "каталог AI-модулей");
|
const root = record(body, "каталог AI-модулей");
|
||||||
exactKeys(root, ["authority", "groups", "schema_version"]);
|
exactKeys(root, ["authority", "groups", "schema_version"]);
|
||||||
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
|
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;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user