feat(control-station): add AI polygon setup streaming and mission controls
This commit is contained in:
@@ -0,0 +1 @@
|
||||
@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/
|
||||
Generated
+23
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -155,6 +155,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.",
|
||||
icon: "clipboard",
|
||||
kind: "lab-archive",
|
||||
internalOnly: true,
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
@import "./styles/l34-annotation.css";
|
||||
@import "./styles/laboratory-reporting.css";
|
||||
@import "./styles/simulation.css";
|
||||
@import "./styles/ai-polygon.css";
|
||||
@import "./styles/laboratory-evidence-report.css";
|
||||
@import "./styles/e34-temporal-layer.css";
|
||||
@import "./styles/m4-replay-threat.css";
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.simulation-profiles { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; height: 100%; min-height: 0; }
|
||||
.simulation-profiles > .nodedc-select-anchor { justify-self: start; }
|
||||
.ai-polygon { display: grid; grid-template-rows: auto minmax(0, 1fr); gap: 1rem; min-width: 0; min-height: 0; overflow: hidden; }
|
||||
.ai-polygon__content { min-height: 0; overflow: auto; overscroll-behavior: contain; padding-bottom: 1rem; }
|
||||
.ai-polygon__head, .ai-polygon__row { display: flex; justify-content: space-between; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.ai-polygon__actions, .ai-polygon__facts { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; }
|
||||
.ai-polygon__telemetry-row { display: flex; align-items: center; justify-content: space-between; gap: 1rem; }
|
||||
.ai-polygon__telemetry-row > .ai-polygon__facts { flex: 1; min-width: 0; }
|
||||
.ai-polygon__telemetry-row > :last-child { flex-shrink: 0; }
|
||||
.ai-polygon__section, .ai-polygon__run, .ai-polygon__form { display: grid; gap: .75rem; }
|
||||
.ai-polygon h2, .ai-polygon h3, .ai-polygon p { margin: 0; }
|
||||
.ai-polygon h2 { font-size: var(--nodedc-font-size-title); }
|
||||
.ai-polygon h3, .ai-polygon strong { font-size: var(--nodedc-font-size-md); }
|
||||
.ai-polygon p, .ai-polygon span, .ai-polygon a { font-size: var(--nodedc-font-size-sm); }
|
||||
.ai-polygon p { color: var(--nodedc-text-muted); line-height: 1.5; }
|
||||
.ai-polygon__setup { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: .75rem; }
|
||||
|
||||
.ai-polygon__title { display: flex; align-items: center; gap: 1rem; }
|
||||
.ai-polygon__stage { display: flex; flex-direction: column; gap: .75rem; min-width: 0; }
|
||||
.ai-polygon__viewer { display: flex; flex-direction: column; min-height: 24rem; height: min(65vh, 48rem); overflow: hidden; border-radius: var(--nodedc-radius-panel); background: var(--nodedc-canvas); }
|
||||
.ai-polygon__viewer-toolbar { display: flex; justify-content: space-between; align-items: flex-start; padding: .5rem; gap: .5rem; }
|
||||
.ai-polygon__viewport { flex: 1; position: relative; min-height: 0; overflow: hidden; }
|
||||
.ai-polygon__viewport[hidden] { display: none; }
|
||||
.ai-polygon__viewport .nodedc-loading-region { height: 100%; }
|
||||
.ai-polygon__video { width: 100%; height: 100%; pointer-events: none; }
|
||||
.ai-polygon__video video { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.ai-polygon__preview-error { position: absolute; inset: 0; display: grid; place-content: center; gap: .75rem; background: var(--nodedc-canvas); }
|
||||
.ai-polygon__stage:fullscreen { height: 100%; box-sizing: border-box; padding: .75rem; overflow: auto; background: var(--nodedc-canvas); }
|
||||
.ai-polygon__stage:fullscreen .ai-polygon__viewer { flex: 1; height: auto; min-height: 12rem; }
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user