feat(control-station): add AI polygon setup streaming and mission controls

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 16:40:45 +03:00
parent f01bd39037
commit e627500295
23 changed files with 713 additions and 9 deletions
+1
View File
@@ -0,0 +1 @@
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
+23
View File
@@ -15,6 +15,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
@@ -899,6 +900,19 @@
"resolved": "../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"link": true
},
"node_modules/@nvidia/ov-web-rtc": {
"version": "6.7.0",
"resolved": "https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/@nvidia/ov-web-rtc/-/@nvidia/ov-web-rtc-6.7.0.tgz",
"integrity": "sha512-Knt393yvuI1tFmQgJM7VuMlVKDh5GyuKq8fAIarNLyY7Ucxw+lSY/72ceWR+3HyCnNtL39J0DZ27dn9riqpGWw==",
"license": "SEE LICENSE IN LICENSE.txt",
"dependencies": {
"is-plain-object": "^5.0.0"
},
"engines": {
"node": ">=20.0.0",
"npm": ">=10.0.0"
}
},
"node_modules/@rerun-io/web-viewer": {
"version": "0.36.3",
"resolved": "https://registry.npmjs.org/@rerun-io/web-viewer/-/web-viewer-0.36.3.tgz",
@@ -1615,6 +1629,15 @@
"node": ">=6.9.0"
}
},
"node_modules/is-plain-object": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz",
"integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@nvidia/ov-web-rtc": "6.7.0",
"@rerun-io/web-viewer": "0.36.3",
"meshoptimizer": "1.1.1",
"playcanvas": "2.21.4",
+1 -1
View File
@@ -822,7 +822,7 @@ export default function App() {
/>
) : activeDefinition.kind === "missions" ? (
<div ref={setWorkspaceHeaderToolsHost} />
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? (
) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
@@ -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 };
}
@@ -43,7 +43,7 @@ const defaultQuickActions: Record<
missions: ["routes", null],
data: ["recordings", "datasets"],
system: ["modules", "integrations"],
polygon: ["lab-archive", "local-device"],
polygon: ["observatory", "local-device"],
};
export function defaultEnvironmentSettings(): EnvironmentSettings {
@@ -140,6 +140,10 @@ function decodeBackground(value: unknown, path: string): EnvironmentBackground {
};
}
function currentQuickAction(value: string | null): string | null {
return value === "lab-archive" ? "observatory" : value;
}
function decodePage(value: unknown, path: string): EnvironmentPage {
const record = requireRecord(value, path);
return {
@@ -147,16 +151,16 @@ function decodePage(value: unknown, path: string): EnvironmentPage {
eyebrow: requireString(record.eyebrow, `${path}.eyebrow`)!,
title: requireString(record.title, `${path}.title`)!,
description: requireString(record.description, `${path}.description`)!,
primaryWorkspaceId: requireString(
primaryWorkspaceId: currentQuickAction(requireString(
record.primary_workspace_id,
`${path}.primary_workspace_id`,
true,
),
secondaryWorkspaceId: requireString(
)),
secondaryWorkspaceId: currentQuickAction(requireString(
record.secondary_workspace_id,
`${path}.secondary_workspace_id`,
true,
),
)),
background: decodeBackground(record.background, `${path}.background`),
};
}
@@ -87,6 +87,10 @@ export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise<AIModu
});
const body = await bodyOf(response);
if (!response.ok) throw apiError(body, response.status);
return decodeAIModuleCatalog(body);
}
export function decodeAIModuleCatalog(body: unknown): AIModuleCatalog {
const root = record(body, "каталог AI-модулей");
exactKeys(root, ["authority", "groups", "schema_version"]);
if (root.schema_version !== CATALOG_SCHEMA) throw new Error("Версия каталога AI-модулей изменилась.");
@@ -0,0 +1,141 @@
import { decodeAIModuleCatalog, type AIModuleCatalog, type AIGroupId } from "../observatory/aiComposition";
const API = "/api/v1/ai-polygon";
export interface AiSelection {
schema_version: "missioncore.observatory-ai-composition/v1";
selections: { group: AIGroupId; module_id: string; module_sha256: string; parameters: Readonly<Record<string, unknown>> }[];
}
export interface AiModules { catalog: AIModuleCatalog; selection: AiSelection }
export async function fetchAiModules(): Promise<AiModules> {
const value = await polygonRequest<{ catalog: unknown; selection: AiSelection }>("/ai-modules");
return { catalog: decodeAIModuleCatalog(value.catalog), selection: value.selection };
}
export interface AiWorldSettings {
meters_per_unit: number;
rotation_degrees: [number, number, number];
ground_z: number;
spawn_xy: [number, number];
heading_degrees: number;
camera_height_m: number;
max_speed_mps: number;
prepared: boolean;
route_xy?: [number, number][];
}
export interface AiSource {
name: string; author: string; license: string; source_url: string | null;
description?: string;
}
export interface AiWorld extends AiSource {
world_id: string; filename: string; byte_length: number; uploaded_bytes: number;
status: "uploading" | "available"; sha256: string | null; splat_count: number | null;
settings: AiWorldSettings;
}
export interface AiRun {
run_id: string;
world: AiWorld;
request?: { composition?: AiSelection };
state: "starting" | "ready" | "running" | "paused" | "stopping" | "completed" | "stopped" | "failed" | "disconnected";
clock?: "lockstep" | "realtime";
control?: "play" | "pause" | "stop";
control_sequence?: number;
camera?: AiCamera;
worker?: { stream?: AiStreamEndpoint | null };
telemetry?: AiTelemetry | null;
samples: number;
phase?: string;
applied_steps?: number;
last_applied?: null | {
sequence: number; simulation_time_ns: number; pose_xy: [number, number];
pose_yaw?: number | null; cycle_ms?: number | null;
};
message: string | null;
last_sample: null | {
sequence: number; simulation_time_ns: number; inference_ms: number; pose_xy: [number, number];
decision: { speed_mps: number; yaw_rate_rps: number; reason: string;
road_fraction: number; obstacle_count: number };
};
}
export type AiCamera = "follow" | "overview" | "camera";
export interface AiStreamEndpoint {
server: string; signaling_port: number; media_port: number; width: number; height: number; fps: number;
}
export interface AiTelemetry {
sequence: number; control_sequence: number; simulation_time_ns: number;
rtf: number; render_fps: number; sensor_fps: number; ai_hz: number;
inference_ms: number | null; frame_age_ms: number | null; command_age_ms: number | null;
speed_mps: number; applied_speed_mps: number; stop_reason: string;
ai_ready: boolean; stream_ready: boolean; camera: AiCamera;
decision: NonNullable<AiRun["last_sample"]>["decision"] | null;
}
export interface AiCatalog {
sources: AiSource[]; worlds: AiWorld[]; runs: AiRun[];
runtime: { available: boolean; active_run: AiRun | null };
}
export async function polygonRequest<T>(path: string, init: RequestInit = {}): Promise<T> {
const response = await fetch(`${API}${path}`, { ...init, headers: {
...(typeof init.body === "string" ? { "Content-Type": "application/json" } : {}),
...init.headers,
} });
if (!response.ok) {
const body = await response.json().catch(() => null);
throw new Error(typeof body?.detail === "string" ? body.detail : "Не удалось выполнить запрос полигона.");
}
return response.json() as Promise<T>;
}
export const runJournalUrl = (run: AiRun) => `${API}/runs/${run.run_id}/decisions`;
export const fetchAiCatalog = () => polygonRequest<AiCatalog>("/catalog");
export const configureAiWorld = (world: AiWorld, settings: AiWorldSettings) =>
polygonRequest<AiWorld>(`/worlds/${world.world_id}/settings`, {
method: "PUT", body: JSON.stringify(settings),
});
export const startAiRun = (world: AiWorld, composition?: AiSelection) => polygonRequest<AiRun>("/runs", {
method: "POST", headers: { "Idempotency-Key": crypto.randomUUID() },
body: JSON.stringify({ world_id: world.world_id, clock: "realtime", start_paused: true, duration_seconds: 1800, composition }),
});
export const setAiCamera = (run: AiRun, camera: AiCamera) =>
polygonRequest<AiRun>(`/runs/${run.run_id}/view`, { method: "PUT", body: JSON.stringify({ camera }) });
export const controlAiRun = (run: AiRun, command: "play" | "pause" | "step" | "stop") =>
polygonRequest<AiRun>(`/runs/${run.run_id}/${command}`, { method: "POST" });
export async function importAiWorld(file: File, source: AiSource, progress: (value: number) => void,
signal: AbortSignal, resume?: AiWorld, onCreated?: (world: AiWorld) => void): Promise<AiWorld> {
if (!file.name.toLowerCase().endsWith(".ply")) throw new Error("Выберите Gaussian PLY из экспорта сцены.");
if (resume && (file.name !== resume.filename || file.size !== resume.byte_length)) {
throw new Error("Для продолжения выберите тот же файл.");
}
let world = resume ? await polygonRequest<AiWorld>(`/worlds/${resume.world_id}`, { signal })
: await polygonRequest<AiWorld>("/worlds", { method: "POST", signal,
body: JSON.stringify({ ...source, description: undefined, filename: file.name,
byte_length: file.size, source_url: source.source_url || null }) });
onCreated?.(world);
if (world.uploaded_bytes > 0) {
const prefix = await polygonRequest<{ uploaded_bytes: number; chunks: { byte_length: number; sha256: string }[] }>(
`/worlds/${world.world_id}/upload-prefix`, { signal });
let offset = 0;
for (const chunk of prefix.chunks) {
signal.throwIfAborted();
const bytes = await file.slice(offset, offset + chunk.byte_length).arrayBuffer();
const digest = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", bytes)))
.map((value) => value.toString(16).padStart(2, "0")).join("");
if (digest !== chunk.sha256) throw new Error("Этот файл отличается от начатой загрузки. Выберите исходный файл.");
offset += chunk.byte_length;
}
if (offset !== prefix.uploaded_bytes || offset !== world.uploaded_bytes) {
throw new Error("Загрузка изменилась в другом окне. Повторите продолжение импорта.");
}
}
const chunkSize = 4 * 1024 ** 2;
progress(world.uploaded_bytes / file.size);
while (world.uploaded_bytes < file.size) {
const offset = world.uploaded_bytes;
world = await polygonRequest<AiWorld>(`/worlds/${world.world_id}/source`, {
method: "PATCH", signal, headers: { "Upload-Offset": String(offset) },
body: file.slice(offset, Math.min(file.size, offset + chunkSize)),
});
progress(world.uploaded_bytes / file.size);
}
return polygonRequest<AiWorld>(`/worlds/${world.world_id}/complete`, { method: "POST", signal });
}
+1
View File
@@ -155,6 +155,7 @@ export const workspaces: WorkspaceDefinition[] = [
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
icon: "clipboard",
kind: "lab-archive",
internalOnly: true,
groups: [],
},
{
+1
View File
@@ -11,6 +11,7 @@
@import "./styles/l34-annotation.css";
@import "./styles/laboratory-reporting.css";
@import "./styles/simulation.css";
@import "./styles/ai-polygon.css";
@import "./styles/laboratory-evidence-report.css";
@import "./styles/e34-temporal-layer.css";
@import "./styles/m4-replay-threat.css";
@@ -0,0 +1,29 @@
.simulation-profiles { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; height: 100%; min-height: 0; }
.simulation-profiles > .nodedc-select-anchor { justify-self: start; }
.ai-polygon { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; min-width: 0; min-height: 0; overflow: hidden; }
.ai-polygon__content { min-height: 0; overflow: auto; overscroll-behavior: contain; padding-bottom: 1rem; }
.ai-polygon__head, .ai-polygon__row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
.ai-polygon__actions, .ai-polygon__facts { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
.ai-polygon__telemetry-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
.ai-polygon__telemetry-row > .ai-polygon__facts { flex: 1; min-width: 0; }
.ai-polygon__telemetry-row > :last-child { flex-shrink: 0; }
.ai-polygon__section, .ai-polygon__run, .ai-polygon__form { display: grid; gap: .75rem; }
.ai-polygon h2, .ai-polygon h3, .ai-polygon p { margin: 0; }
.ai-polygon h2 { font-size: var(--nodedc-font-size-title); }
.ai-polygon h3, .ai-polygon strong { font-size: var(--nodedc-font-size-md); }
.ai-polygon p, .ai-polygon span, .ai-polygon a { font-size: var(--nodedc-font-size-sm); }
.ai-polygon p { color: var(--nodedc-text-muted); line-height: 1.5; }
.ai-polygon__setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .75rem; }
.ai-polygon__title { display: flex; align-items: center; gap: 1rem; }
.ai-polygon__stage { display: flex; flex-direction: column; gap: .75rem; min-width: 0; }
.ai-polygon__viewer { display: flex; flex-direction: column; min-height: 24rem; height: min(65vh, 48rem); overflow: hidden; border-radius: var(--nodedc-radius-panel); background: var(--nodedc-canvas); }
.ai-polygon__viewer-toolbar { display: flex; justify-content: space-between; align-items: flex-start; padding: .5rem; gap: .5rem; }
.ai-polygon__viewport { flex: 1; position: relative; min-height: 0; overflow: hidden; }
.ai-polygon__viewport[hidden] { display: none; }
.ai-polygon__viewport .nodedc-loading-region { height: 100%; }
.ai-polygon__video { width: 100%; height: 100%; pointer-events: none; }
.ai-polygon__video video { display: block; width: 100%; height: 100%; object-fit: contain; }
.ai-polygon__preview-error { position: absolute; inset: 0; display: grid; place-content: center; gap: .75rem; background: var(--nodedc-canvas); }
.ai-polygon__stage:fullscreen { height: 100%; box-sizing: border-box; padding: .75rem; overflow: auto; background: var(--nodedc-canvas); }
.ai-polygon__stage:fullscreen .ai-polygon__viewer { flex: 1; height: auto; min-height: 12rem; }
@@ -0,0 +1,118 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Button, GlassSurface, Icon, IconButton, LoadingRegion, StatusBadge, ToastStack } from "@nodedc/ui-react";
import { AiWorldImport } from "../../components/simulation/AiWorldImport";
import { AiWorldStream } from "../../components/simulation/AiWorldStream";
import { AiWorldSetup } from "../../components/simulation/AiWorldSetup";
import { AiRunView } from "../../components/simulation/AiRunView";
import { AiRunActions } from "../../components/simulation/AiRunActions";
import { AiCompositionFields } from "../../components/simulation/AiCompositionFields";
import { fetchAiCatalog, fetchAiModules, startAiRun, type AiCatalog, type AiSource, type AiWorld, type AiRun, type AiModules, type AiSelection } from "../../core/simulation/aiPolygon";
export function AiPolygonWorkspace() {
const [catalog, setCatalog] = useState<AiCatalog | null>(null);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [runId, setRunId] = useState<string | null>(null);
const [importing, setImporting] = useState<{ source: AiSource | null; resume?: AiWorld } | null>(null);
const [error, setError] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [actionsHost, setActionsHost] = useState<HTMLDivElement | null>(null);
const [modules, setModules] = useState<AiModules | null>(null);
const [selection, setSelection] = useState<AiSelection | undefined>();
const fast = useRef(false);
const load = useCallback(async () => { const result = await fetchAiCatalog(); setCatalog(result); }, []);
useEffect(() => {
let active = true;
void fetchAiModules().then((value) => { if (active) { setModules(value); setSelection(value.selection); } })
.catch((caught: Error) => { if (active) setError(caught.message); });
return () => { active = false; };
}, []);
useEffect(() => {
let active = true; let timer: ReturnType<typeof setTimeout>;
const poll = async () => {
try { const result = await fetchAiCatalog(); if (active) {
setCatalog(result); fast.current = !!result.runtime.active_run;
} }
catch (caught) { if (active) setError(caught instanceof Error ? caught.message : "Локации недоступны."); }
if (active) timer = setTimeout(() => void poll(), fast.current ? 500 : 1500);
};
void poll(); return () => { active = false; clearTimeout(timer); };
}, []);
const selected = catalog?.worlds.find((world) => world.world_id === selectedId);
const activeRun = catalog?.runtime.active_run;
useEffect(() => {
if (activeRun?.world.world_id === selectedId) setRunId(activeRun.run_id);
}, [activeRun?.run_id, activeRun?.world.world_id, selectedId]);
const run = selected ? catalog?.runtime.active_run?.world.world_id === selected.world_id
? catalog.runtime.active_run : catalog?.runs.find((item) => item.run_id === runId && item.world.world_id === selected.world_id)
: undefined;
const acceptRun = (value: AiRun) => {
setSettingsOpen(false);
setRunId(value.run_id); fast.current = !["completed", "stopped", "failed"].includes(value.state);
setCatalog((prior) => prior ? { ...prior, runs: [value, ...prior.runs.filter((r) => r.run_id !== value.run_id)],
runtime: { ...prior.runtime, active_run: fast.current ? value : null } } : prior);
void load().catch(() => undefined);
};
const acceptWorld = (value: AiWorld) => {
setSelectedId(value.world_id); setImporting(null); setSettingsOpen(false); void load().catch(() => undefined);
};
const openWorld = (world: AiWorld) => {
if (world.status === "uploading") setImporting({ source: world, resume: world });
else {
setSelectedId(world.world_id); setRunId(null); setSettingsOpen(!world.settings.prepared);
}
};
const returnToLocations = () => { setSelectedId(null); setRunId(null); };
const connection = <StatusBadge tone={catalog?.runtime.available ? "success" : "neutral"}>
{catalog?.runtime.available ? "Симулятор подключён" : "Симулятор не подключён"}
</StatusBadge>;
const sceneActions = selected && catalog ? <>
<Button onClick={() => setSettingsOpen(!settingsOpen)}>Старт и масштаб</Button>
{!run || ["completed", "stopped", "failed"].includes(run.state) ? <Button variant="primary"
icon={<Icon name="play" />} loading={starting}
disabled={!catalog.runtime.available || !selected.settings.prepared || !!catalog.runtime.active_run}
onClick={() => { setStarting(true); void startAiRun(selected, selection).then(acceptRun)
.catch((caught: Error) => setError(caught.message)).finally(() => setStarting(false)); }}>Открыть симуляцию</Button> : null}
{run ? <AiRunActions run={run} onChange={acceptRun} /> : null}
</> : null;
return <div className="ai-polygon">
<header className="ai-polygon__head">
<div className="ai-polygon__title">
{selected ? <IconButton label="Назад к локациям" onClick={returnToLocations}>
<Icon name="chevron-left" />
</IconButton> : null}
<h2>{selected?.name ?? "Доступные локации"}</h2>
</div>
<div className="ai-polygon__actions" ref={setActionsHost}>
{selected ? !run ? sceneActions : null : <>{connection}
<Button icon={<Icon name="upload" />} onClick={() => setImporting({ source: null })}>Импорт локации</Button></>}
</div>
</header>
<LoadingRegion className="ai-polygon__content" loading={!catalog && !error} label="Загрузка локаций">
{error ? <ToastStack items={[{ id: "error", tone: "error", title: "Не удалось выполнить действие", description: error }]} onDismiss={() => setError(null)} /> : null}
{catalog ? !selected ? <section aria-label="Доступные локации" className="ai-polygon__section">
{catalog.worlds.map((world) => <GlassSurface key={world.world_id} padding="md" className="ai-polygon__row">
<div><strong>{world.name}</strong><p>{world.author} · {world.license}</p></div>
{catalog.runtime.active_run?.world.world_id === world.world_id ? <StatusBadge tone="neutral">
{catalog.runtime.active_run.state === "running" && catalog.runtime.active_run.telemetry?.ai_ready
? "AI запущен" : catalog.runtime.active_run.state === "paused" ? "На паузе" : "Сцена открыта"}
</StatusBadge> : null}
<Button onClick={() => openWorld(world)}>{world.status === "uploading" ? "Продолжить импорт" : "Открыть"}</Button>
</GlassSurface>)}
</section> : <section className="ai-polygon__section" aria-label={`Симуляция ${selected.name}`}>
<LoadingRegion loading={starting} label="Открываем симуляцию на Worker">
{run ? <AiWorldStream world={selected} run={run} onBack={returnToLocations} onChange={acceptRun}
actionsHost={actionsHost} headerActions={sceneActions}
controls={<AiRunView run={run} connection={connection} />} /> : <div className="ai-polygon__telemetry-row">{connection}</div>}
</LoadingRegion>
{settingsOpen ? <AiWorldSetup key={selected.world_id} world={selected}
disabled={!!catalog.runtime.active_run} onSaved={acceptWorld} /> : null}
{settingsOpen && modules && selection ? <AiCompositionFields modules={modules}
selection={activeRun?.world.world_id === selectedId ? activeRun.request?.composition ?? selection : selection}
disabled={!!catalog.runtime.active_run} onChange={setSelection} /> : null}
</section> : null}
</LoadingRegion>
{importing ? <AiWorldImport source={importing.source} resume={importing.resume}
onClose={() => setImporting(null)} onImported={acceptWorld} /> : null}
</div>;
}
@@ -13,9 +13,11 @@ import {
GlassSurface,
Icon,
StatusBadge,
Select,
} from "@nodedc/ui-react";
import { SimulationCatalog } from "../../components/simulation/SimulationCatalog";
import { AiPolygonWorkspace } from "./AiPolygonWorkspace";
import { SimulationProjectWindow } from "../../components/simulation/SimulationProjectWindow";
import { SimulationViewport } from "../../components/simulation/SimulationViewport";
import {
@@ -42,6 +44,21 @@ interface BrowserUpload {
}
export function SimulationWorkspace() {
const [profile, setProfile] = useState<"lcc" | "ai">(() => {
try { return localStorage.getItem("missioncore.simulation-profile") === "ai" ? "ai" : "lcc"; }
catch { return "lcc"; }
});
return <div className="simulation-profiles" data-simulation-profile={profile}>
<Select label="Профиль симуляции" value={profile} options={[
{ value: "lcc", label: "LCC Gaussian Mirror" }, { value: "ai", label: "AI-полигон" },
]} onChange={(value) => { setProfile(value);
try { localStorage.setItem("missioncore.simulation-profile", value); } catch { /* Private browsing. */ }
}} />
{profile === "lcc" ? <LccGaussianWorkspace /> : <AiPolygonWorkspace />}
</div>;
}
function LccGaussianWorkspace() {
const [projects, setProjects] = useState<SimulationProject[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
@@ -130,6 +130,15 @@ test("decoder fails closed on an unknown environment schema", () => {
);
});
test("retired laboratory quick actions resolve to AI Inference", () => {
const payload = serverDocument();
payload.pages.polygon.primary_workspace_id = "lab-archive";
const decoded = environment.decodeEnvironmentSettings(payload);
assert.equal(decoded.pages.polygon.primaryWorkspaceId, "observatory");
assert.equal(decoded.revision, payload.revision);
assert.deepEqual(decoded.pages.polygon.background.items, []);
});
test("editing a cloned page cannot mutate accepted settings", () => {
const accepted = environment.decodeEnvironmentSettings(serverDocument());
const draft = environment.cloneEnvironmentSettings(accepted);
@@ -27,7 +27,7 @@ async function read(relativePath) {
test("Observatory is the third independent Polygon workspace", () => {
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
["simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.deepEqual(
productModel.workspaceById("observatory"),
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
["simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -29,7 +29,7 @@ test("top navigation has no Center and Park owns contour health first", () => {
assert.equal(productModel.workspaceById("contour-health")?.root, "fleet");
assert.deepEqual(
productModel.workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive", "simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
["simulations", "observatory", "local-device", "spatial-scene", "mission-planner"],
);
assert.equal(productModel.workspaceById("spatial-scene")?.root, "polygon");
assert.equal(productModel.workspacesForRoot("observation").some(({ id }) => id === "spatial-scene"), false);