diff --git a/apps/control-station/.npmrc b/apps/control-station/.npmrc new file mode 100644 index 0000000..e2e3425 --- /dev/null +++ b/apps/control-station/.npmrc @@ -0,0 +1 @@ +@nvidia:registry=https://edge.urm.nvidia.com/artifactory/api/npm/omniverse-client-npm/ diff --git a/apps/control-station/package-lock.json b/apps/control-station/package-lock.json index 8b06169..454cfc4 100644 --- a/apps/control-station/package-lock.json +++ b/apps/control-station/package-lock.json @@ -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", diff --git a/apps/control-station/package.json b/apps/control-station/package.json index 6fc0a42..233a0f6 100644 --- a/apps/control-station/package.json +++ b/apps/control-station/package.json @@ -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", diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx index 30e6c14..f0ab8ca 100644 --- a/apps/control-station/src/App.tsx +++ b/apps/control-station/src/App.tsx @@ -822,7 +822,7 @@ export default function App() { /> ) : activeDefinition.kind === "missions" ? (
- ) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "datasets" ? ( + ) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? ( Offline evaluation ) : activeDefinition.kind === "lab-archive" ? ( laboratoryAnnotation.control diff --git a/apps/control-station/src/components/simulation/AiCompositionFields.tsx b/apps/control-station/src/components/simulation/AiCompositionFields.tsx new file mode 100644 index 0000000..bd8e9fc --- /dev/null +++ b/apps/control-station/src/components/simulation/AiCompositionFields.tsx @@ -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
+

Модули AI

+ {modules.catalog.groups.filter(({ modules: options }) => options.length > 0).map(({ group, modules: options }) => + + setFile(e.target.files?.[0] ?? null)} /> + + {busy ? : null} + {error ? setError(null)} /> : null} +
+ ; +} diff --git a/apps/control-station/src/components/simulation/AiWorldSetup.tsx b/apps/control-station/src/components/simulation/AiWorldSetup.tsx new file mode 100644 index 0000000..f635354 --- /dev/null +++ b/apps/control-station/src/components/simulation/AiWorldSetup.tsx @@ -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(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(null); + const field = (label: string, value: number, change: (value: number) => void) => + change(e.target.value === "" ? NaN : Number(e.target.value))} />; + return
+ {field("Метров на единицу сцены", draft.meters_per_unit, (v) => setDraft({ ...draft, meters_per_unit: v }))} + {([0, 1, 2] as const).map((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 }); + })} +
)} + {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 }))} + setRoute(e.target.value)} /> + + {error ? setError(null)} /> : null} +
; +} diff --git a/apps/control-station/src/components/simulation/AiWorldStream.tsx b/apps/control-station/src/components/simulation/AiWorldStream.tsx new file mode 100644 index 0000000..ad00c9b --- /dev/null +++ b/apps/control-station/src/components/simulation/AiWorldStream.tsx @@ -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 = 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(null); + const video = useRef(null); + const id = useId().replaceAll(":", ""); + const { expanded, toggle } = useSimulationFullscreen(frame); + const [state, setState] = useState<"connecting" | "live" | "failed" | "stalled">("connecting"); + const [fps, setFps] = useState(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((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
+ {!expanded && actionsHost ? createPortal(headerActions, actionsHost) : null} + {expanded ?
+
+ +

{world.name}

+
+
{headerActions}
+
: null} + {controls ?
{controls}
: null} +
+
+ label="Вид симуляции" size="dense" value={mode} onChange={changeCamera} + items={[{ value: "follow", label: "За ровером", disabled: terminal }, + { value: "overview", label: "Обзор", disabled: terminal }, { value: "camera", label: "Камера AI", disabled: terminal }]} /> +
+ {connectable ? + {state === "live" ? `Видео · ${fps?.toFixed(0) ?? "—"} FPS` : "Ожидаем видео"} + : null} + + + +
+
+ {cameraError ?

Не удалось переключить камеру.

: null} +
+ +
+
+
+ {terminal ?

{run?.state === "failed" ? run.message ?? "Симуляция прервана." : "Симуляция завершена."}

+ : connectable && (state === "failed" || state === "stalled") ?
+

Видеопоток прерван. Состояние симуляции показано выше.

+ +
: null} +
+
+
; +} diff --git a/apps/control-station/src/components/simulation/useSimulationFullscreen.ts b/apps/control-station/src/components/simulation/useSimulationFullscreen.ts new file mode 100644 index 0000000..5cd6cd7 --- /dev/null +++ b/apps/control-station/src/components/simulation/useSimulationFullscreen.ts @@ -0,0 +1,26 @@ +import { useEffect, useState, type RefObject } from "react"; + +export function useSimulationFullscreen(view: RefObject) { + 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 }; +} diff --git a/apps/control-station/src/core/environment/environmentSettings.ts b/apps/control-station/src/core/environment/environmentSettings.ts index 0c33104..02855f3 100644 --- a/apps/control-station/src/core/environment/environmentSettings.ts +++ b/apps/control-station/src/core/environment/environmentSettings.ts @@ -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`), }; } diff --git a/apps/control-station/src/core/observatory/aiComposition.ts b/apps/control-station/src/core/observatory/aiComposition.ts index 14739c0..adeae87 100644 --- a/apps/control-station/src/core/observatory/aiComposition.ts +++ b/apps/control-station/src/core/observatory/aiComposition.ts @@ -87,6 +87,10 @@ export async function fetchAIModuleCatalog(signal?: AbortSignal): Promise> }[]; +} +export interface AiModules { catalog: AIModuleCatalog; selection: AiSelection } +export async function fetchAiModules(): Promise { + 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["decision"] | null; +} +export interface AiCatalog { + sources: AiSource[]; worlds: AiWorld[]; runs: AiRun[]; + runtime: { available: boolean; active_run: AiRun | null }; +} + +export async function polygonRequest(path: string, init: RequestInit = {}): Promise { + 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; +} +export const runJournalUrl = (run: AiRun) => `${API}/runs/${run.run_id}/decisions`; +export const fetchAiCatalog = () => polygonRequest("/catalog"); +export const configureAiWorld = (world: AiWorld, settings: AiWorldSettings) => + polygonRequest(`/worlds/${world.world_id}/settings`, { + method: "PUT", body: JSON.stringify(settings), + }); +export const startAiRun = (world: AiWorld, composition?: AiSelection) => polygonRequest("/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(`/runs/${run.run_id}/view`, { method: "PUT", body: JSON.stringify({ camera }) }); +export const controlAiRun = (run: AiRun, command: "play" | "pause" | "step" | "stop") => + polygonRequest(`/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 { + 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(`/worlds/${resume.world_id}`, { signal }) + : await polygonRequest("/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(`/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(`/worlds/${world.world_id}/complete`, { method: "POST", signal }); +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index b5784d1..c8a2a70 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -155,6 +155,7 @@ export const workspaces: WorkspaceDefinition[] = [ description: "Профили конфигураций, лабораторные работы и их воспроизводимые результаты.", icon: "clipboard", kind: "lab-archive", + internalOnly: true, groups: [], }, { diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index a43b6fd..e70984f 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -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"; diff --git a/apps/control-station/src/styles/ai-polygon.css b/apps/control-station/src/styles/ai-polygon.css new file mode 100644 index 0000000..913f7ac --- /dev/null +++ b/apps/control-station/src/styles/ai-polygon.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; } diff --git a/apps/control-station/src/workspaces/simulation/AiPolygonWorkspace.tsx b/apps/control-station/src/workspaces/simulation/AiPolygonWorkspace.tsx new file mode 100644 index 0000000..2a3d4ae --- /dev/null +++ b/apps/control-station/src/workspaces/simulation/AiPolygonWorkspace.tsx @@ -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(null); + const [selectedId, setSelectedId] = useState(null); + const [runId, setRunId] = useState(null); + const [importing, setImporting] = useState<{ source: AiSource | null; resume?: AiWorld } | null>(null); + const [error, setError] = useState(null); + const [starting, setStarting] = useState(false); + const [settingsOpen, setSettingsOpen] = useState(false); + const [actionsHost, setActionsHost] = useState(null); + const [modules, setModules] = useState(null); + const [selection, setSelection] = useState(); + 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; + 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 = + {catalog?.runtime.available ? "Симулятор подключён" : "Симулятор не подключён"} + ; + const sceneActions = selected && catalog ? <> + + {!run || ["completed", "stopped", "failed"].includes(run.state) ? : null} + {run ? : null} + : null; + return
+
+
+ {selected ? + + : null} +

{selected?.name ?? "Доступные локации"}

+
+
+ {selected ? !run ? sceneActions : null : <>{connection} + } +
+
+ + {error ? setError(null)} /> : null} + {catalog ? !selected ?
+ {catalog.worlds.map((world) => +
{world.name}

{world.author} · {world.license}

+ {catalog.runtime.active_run?.world.world_id === world.world_id ? + {catalog.runtime.active_run.state === "running" && catalog.runtime.active_run.telemetry?.ai_ready + ? "AI запущен" : catalog.runtime.active_run.state === "paused" ? "На паузе" : "Сцена открыта"} + : null} + +
)} +
:
+ + {run ? } /> :
{connection}
} +
+ {settingsOpen ? : null} + {settingsOpen && modules && selection ? : null} +
: null} +
+ {importing ? setImporting(null)} onImported={acceptWorld} /> : null} +
; +} diff --git a/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx b/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx index 50deeb5..316b9bf 100644 --- a/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx +++ b/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx @@ -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
+