From d943ec853c13641b94096492f0ad8cd7dd355a25 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Wed, 26 Aug 2026 02:09:44 +0300 Subject: [PATCH] feat: add Gaussian simulation workspace --- apps/control-station/package-lock.json | 21 +- apps/control-station/package.json | 1 + .../simulation/PlayCanvasRuntime.ts | 211 ++++++ .../simulation/SimulationProjectWindow.tsx | 324 +++++++++ .../simulation/SimulationViewport.tsx | 105 +++ .../src/core/simulation/projects.ts | 399 +++++++++++ apps/control-station/src/productModel.ts | 14 +- apps/control-station/src/styles.css | 1 + .../control-station/src/styles/simulation.css | 442 ++++++++++++ .../src/types/playcanvas-scripts.d.ts | 14 + .../src/workspaces/Workspaces.tsx | 10 +- .../simulation/SimulationWorkspace.tsx | 280 ++++++++ .../test/polygonRunArchive.test.mjs | 2 +- .../test/simulationWorkspace.test.mjs | 60 ++ docs/20_SIMULATION_WORLD_SURFACE_BRIEF.md | 99 +++ .../simulation/gaussian_pipeline_gateway.py | 241 ++++++- src/k1link/simulation/projects.py | 635 ++++++++++++++++++ src/k1link/web/app.py | 11 + src/k1link/web/simulation_projects_api.py | 215 ++++++ tests/test_gaussian_pipeline_gateway.py | 92 +++ tests/test_simulation_projects.py | 260 +++++++ tests/test_simulation_world_provider_api.py | 2 + 22 files changed, 3422 insertions(+), 17 deletions(-) create mode 100644 apps/control-station/src/components/simulation/PlayCanvasRuntime.ts create mode 100644 apps/control-station/src/components/simulation/SimulationProjectWindow.tsx create mode 100644 apps/control-station/src/components/simulation/SimulationViewport.tsx create mode 100644 apps/control-station/src/core/simulation/projects.ts create mode 100644 apps/control-station/src/styles/simulation.css create mode 100644 apps/control-station/src/types/playcanvas-scripts.d.ts create mode 100644 apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx create mode 100644 apps/control-station/test/simulationWorkspace.test.mjs create mode 100644 docs/20_SIMULATION_WORLD_SURFACE_BRIEF.md create mode 100644 src/k1link/simulation/projects.py create mode 100644 src/k1link/web/simulation_projects_api.py create mode 100644 tests/test_simulation_projects.py diff --git a/apps/control-station/package-lock.json b/apps/control-station/package-lock.json index 1671467..4cf9e16 100644 --- a/apps/control-station/package-lock.json +++ b/apps/control-station/package-lock.json @@ -16,6 +16,7 @@ "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@rerun-io/web-viewer": "0.34.1", + "playcanvas": "2.21.4", "react": "^19.1.0", "react-dom": "^19.1.0", "three": "0.185.1" @@ -1375,7 +1376,6 @@ "version": "0.5.24", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", - "dev": true, "license": "MIT" }, "node_modules/@vitejs/plugin-react": { @@ -1399,6 +1399,12 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, + "node_modules/@webgpu/types": { + "version": "0.1.72", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz", + "integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==", + "license": "BSD-3-Clause" + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -1714,6 +1720,19 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playcanvas": { + "version": "2.21.4", + "resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-2.21.4.tgz", + "integrity": "sha512-L4UGy3z/YT8AaNBEY4ITsZup548UFMabF7loh0YQ0DRwQLdjIW8grmZPoQ1+B83+N6sejRS0/XlXV2Vfb11+fw==", + "license": "MIT", + "dependencies": { + "@types/webxr": "^0.5.24", + "@webgpu/types": "^0.1.70" + }, + "engines": { + "node": ">=18.3.0" + } + }, "node_modules/postcss": { "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", diff --git a/apps/control-station/package.json b/apps/control-station/package.json index e394f3e..b09d50c 100644 --- a/apps/control-station/package.json +++ b/apps/control-station/package.json @@ -19,6 +19,7 @@ "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@rerun-io/web-viewer": "0.34.1", + "playcanvas": "2.21.4", "react": "^19.1.0", "react-dom": "^19.1.0", "three": "0.185.1" diff --git a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts new file mode 100644 index 0000000..7ac9890 --- /dev/null +++ b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts @@ -0,0 +1,211 @@ +import { + Application, + Asset, + Color, + Entity, + Vec2, + Vec3, + type ScriptType, +} from "playcanvas"; +import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs"; + +import type { SimulationWorldManifest } from "../../core/simulation/projects"; + +export type SimulationViewMode = "visual" | "collision" | "combined"; +export type SimulationQuality = "auto" | "low" | "medium" | "high"; + +export interface SimulationRuntime { + mount(canvas: HTMLCanvasElement): Promise; + loadWorld(manifest: SimulationWorldManifest): Promise; + setViewMode(mode: SimulationViewMode): void; + setQuality(quality: SimulationQuality): void; + focusBounds(): void; + dispose(): void; +} + +type CameraController = ScriptType & Pick; + +export class PlayCanvasRuntime implements SimulationRuntime { + private app: Application | null = null; + private canvas: HTMLCanvasElement | null = null; + private camera: Entity | null = null; + private cameraController: CameraController | null = null; + private visualEntity: Entity | null = null; + private visualAsset: Asset | null = null; + private resizeObserver: ResizeObserver | null = null; + private viewMode: SimulationViewMode = "visual"; + private quality: SimulationQuality = "auto"; + + async mount(canvas: HTMLCanvasElement): Promise { + if (this.app) throw new Error("PlayCanvas runtime уже смонтирован."); + this.canvas = canvas; + canvas.tabIndex = 0; + canvas.addEventListener("contextmenu", preventContextMenu); + const app = new Application(canvas, { + graphicsDeviceOptions: { + antialias: true, + alpha: false, + preserveDrawingBuffer: false, + powerPreference: "high-performance", + }, + }); + this.app = app; + app.scene.ambientLight = new Color(0.35, 0.37, 0.42); + + const camera = new Entity("SimulationCamera"); + camera.addComponent("camera", { + clearColor: new Color(0.025, 0.03, 0.04), + nearClip: 0.02, + farClip: 20_000, + fov: 58, + }); + camera.setPosition(5, 3, 5); + camera.lookAt(0, 0, 0); + camera.addComponent("script"); + const controller = camera.script?.create(CameraControls, { + properties: { + enableOrbit: true, + enableFly: true, + focusPoint: new Vec3(0, 0, 0), + zoomRange: new Vec2(0.05, 20_000), + }, + }) as CameraController | null; + app.root.addChild(camera); + this.camera = camera; + this.cameraController = controller; + + const light = new Entity("SimulationSun"); + light.addComponent("light", { + type: "directional", + color: new Color(1, 0.97, 0.9), + intensity: 1.1, + castShadows: false, + }); + light.setEulerAngles(42, 28, 0); + app.root.addChild(light); + + this.resizeObserver = new ResizeObserver(() => this.resize()); + this.resizeObserver.observe(canvas.parentElement ?? canvas); + this.resize(); + app.start(); + await Promise.resolve(); + } + + async loadWorld(manifest: SimulationWorldManifest): Promise { + const app = this.requiredApp(); + this.unloadWorld(); + const sourceUrl = manifest.visual.streamedSogUrl ?? manifest.visual.previewSogUrl; + if (!sourceUrl) throw new Error("World manifest не содержит визуальный SOG."); + const asset = new Asset(`SimulationWorld:${manifest.projectId}`, "gsplat", { url: sourceUrl }); + app.assets.add(asset); + this.visualAsset = asset; + await new Promise((resolve, reject) => { + const ready = (loaded: Asset) => { + const visual = new Entity("GaussianWorld"); + visual.enabled = false; + app.root.addChild(visual); + visual.addComponent("gsplat", { + asset: loaded, + unified: true, + }); + visual.enabled = true; + this.visualEntity = visual; + this.applyQuality(); + this.applyViewMode(); + this.focusBounds(); + resolve(); + }; + const failed = (error: unknown) => { + reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил SOG.")); + }; + asset.ready(ready); + asset.once("error", failed); + app.assets.load(asset); + }); + } + + setViewMode(mode: SimulationViewMode): void { + this.viewMode = mode; + this.applyViewMode(); + } + + setQuality(quality: SimulationQuality): void { + this.quality = quality; + this.applyQuality(); + } + + focusBounds(): void { + const bounds = this.visualEntity?.gsplat?.customAabb; + const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0); + const radius = bounds?.halfExtents.length() ?? 3; + const distance = Math.max(1, radius * 2.4); + const position = new Vec3(1, 0.55, 1).normalize().mulScalar(distance).add(focus); + if (this.cameraController) { + this.cameraController.reset(focus, position); + } else if (this.camera) { + this.camera.setPosition(position); + this.camera.lookAt(focus); + } + } + + dispose(): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = null; + this.unloadWorld(); + if (this.app) this.app.destroy(); + if (this.canvas) this.canvas.removeEventListener("contextmenu", preventContextMenu); + this.app = null; + this.canvas = null; + this.camera = null; + this.cameraController = null; + } + + private requiredApp(): Application { + if (!this.app) throw new Error("PlayCanvas runtime не смонтирован."); + return this.app; + } + + private resize(): void { + if (!this.app || !this.canvas) return; + const bounds = this.canvas.parentElement?.getBoundingClientRect(); + const width = Math.max(1, Math.round(bounds?.width ?? this.canvas.clientWidth)); + const height = Math.max(1, Math.round(bounds?.height ?? this.canvas.clientHeight)); + this.app.resizeCanvas(width, height); + } + + private applyViewMode(): void { + if (this.visualEntity) { + this.visualEntity.enabled = this.viewMode !== "collision"; + } + } + + private applyQuality(): void { + const gsplat = this.visualEntity?.gsplat; + if (!gsplat) return; + const profiles: Record = { + auto: [5, 2], + low: [2.5, 1.7], + medium: [5, 2], + high: [9, 2.3], + }; + const [baseDistance, multiplier] = profiles[this.quality]; + gsplat.lodBaseDistance = baseDistance; + gsplat.lodMultiplier = multiplier; + } + + private unloadWorld(): void { + if (this.visualEntity) { + this.visualEntity.destroy(); + this.visualEntity = null; + } + if (this.visualAsset && this.app) { + this.app.assets.remove(this.visualAsset); + this.visualAsset.unload(); + this.visualAsset = null; + } + } +} + +function preventContextMenu(event: Event): void { + event.preventDefault(); +} diff --git a/apps/control-station/src/components/simulation/SimulationProjectWindow.tsx b/apps/control-station/src/components/simulation/SimulationProjectWindow.tsx new file mode 100644 index 0000000..6e19483 --- /dev/null +++ b/apps/control-station/src/components/simulation/SimulationProjectWindow.tsx @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useRef, useState, type DragEvent } from "react"; +import { + ActivityIndicator, + Button, + FieldFrame, + Icon, + Select, + TextField, + Window, + WindowFooterActions, +} from "@nodedc/ui-react"; + +import { + createAndUploadSimulationProject, + updateSimulationProject, + type SimulationProject, + type SimulationSceneType, + type SimulationUploadCandidate, + type SimulationUploadProgress, +} from "../../core/simulation/projects"; + +export function SimulationProjectWindow({ + open, + project, + onClose, + onSaved, +}: { + open: boolean; + project: SimulationProject | null; + onClose: () => void; + onSaved: (project: SimulationProject) => void; +}) { + const archiveInput = useRef(null); + const folderInput = useRef(null); + const [name, setName] = useState(""); + const [sceneType, setSceneType] = useState("outdoor"); + const [candidates, setCandidates] = useState([]); + const [dragging, setDragging] = useState(false); + const [pending, setPending] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + setName(project?.name ?? ""); + setSceneType(project?.sceneType ?? "outdoor"); + setCandidates([]); + setProgress(null); + setPending(false); + setError(null); + }, [open, project]); + + const sourceSummary = useMemo(() => summarizeCandidates(candidates), [candidates]); + const canSubmit = name.trim().length > 0 && (project !== null || candidates.length > 0) && !pending; + + const acceptCandidates = (next: SimulationUploadCandidate[]) => { + try { + validateCandidates(next); + setCandidates(next); + setError(null); + if (!name.trim() && next.length) { + const named = next.find((candidate) => /\.lcc2?$/i.test(candidate.logicalPath)) ?? next[0]; + const first = named?.logicalPath.split("/").filter(Boolean).at(-1) ?? "Новая сцена"; + setName(first.replace(/\.(lcc2?|zip|rar|7z)$/i, "")); + } + } catch (caught) { + setCandidates([]); + setError(caught instanceof Error ? caught.message : "Источник не принят."); + } + }; + + const submit = async () => { + if (!canSubmit) return; + setPending(true); + setError(null); + try { + const saved = project + ? await updateSimulationProject(project.projectId, name, sceneType) + : await createAndUploadSimulationProject( + name, + sceneType, + candidates, + setProgress, + ); + onSaved(saved); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось сохранить проект."); + setPending(false); + } + }; + + const handleDrop = async (event: DragEvent) => { + event.preventDefault(); + setDragging(false); + try { + acceptCandidates(await candidatesFromDrop(event.dataTransfer)); + } catch (caught) { + setError(caught instanceof Error ? caught.message : "Не удалось прочитать источник."); + } + }; + + return ( + + + {pending ? <>{project ? "Сохраняем" : "Загружаем источник"} : null} + + + + + + + )} + > +
+ setName(event.currentTarget.value)} + /> + + { + const file = event.currentTarget.files?.[0]; + if (file) acceptCandidates([{ file, logicalPath: file.name }]); + event.currentTarget.value = ""; + }} + /> + )} + onChange={(event) => { + const next = Array.from(event.currentTarget.files ?? []).map((file) => ({ + file, + logicalPath: file.webkitRelativePath || file.name, + })); + if (next.length) acceptCandidates(next); + event.currentTarget.value = ""; + }} + /> +
+ + ) : null} + {progress ? ( +
+ +
+ {formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)} + {progress.currentPath} +
+
+ ) : null} + {error ?

{error}

: null} + +
+ ); +} + +interface LegacyFileEntry { + isFile: boolean; + isDirectory: boolean; + name: string; + fullPath: string; + file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void; + createReader?: () => { + readEntries: ( + callback: (entries: LegacyFileEntry[]) => void, + error?: (error: DOMException) => void, + ) => void; + }; +} + +async function candidatesFromDrop(transfer: DataTransfer): Promise { + const candidates: SimulationUploadCandidate[] = []; + for (const item of Array.from(transfer.items)) { + const entry = (item as DataTransferItem & { + webkitGetAsEntry?: () => LegacyFileEntry | null; + }).webkitGetAsEntry?.(); + if (entry) { + await collectEntry(entry, candidates); + continue; + } + const file = item.getAsFile(); + if (file) candidates.push({ file, logicalPath: file.name }); + } + if (!candidates.length) { + for (const file of Array.from(transfer.files)) { + candidates.push({ file, logicalPath: file.name }); + } + } + return candidates; +} + +async function collectEntry(entry: LegacyFileEntry, target: SimulationUploadCandidate[]): Promise { + if (entry.isFile && entry.file) { + const file = await new Promise((resolve, reject) => entry.file?.(resolve, reject)); + target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name }); + return; + } + if (!entry.isDirectory || !entry.createReader) return; + const reader = entry.createReader(); + while (true) { + const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject)); + if (!batch.length) break; + for (const child of batch) await collectEntry(child, target); + } +} + +function validateCandidates(candidates: SimulationUploadCandidate[]): void { + if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2."); + const archives = candidates.filter((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath)); + if (archives.length) { + if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом."); + return; + } + const descriptors = candidates.filter((candidate) => /\.lcc2?$/i.test(candidate.logicalPath)); + if (descriptors.length !== 1) { + throw new Error("В папке должна быть ровно одна сцена .lcc или .lcc2."); + } + const paths = new Set(); + for (const candidate of candidates) { + const parts = candidate.logicalPath.split("/"); + if ( + !candidate.logicalPath || + candidate.logicalPath.startsWith("/") || + candidate.logicalPath.includes("\\") || + candidate.logicalPath.includes("\0") || + parts.some((part) => part === "" || part === "." || part === "..") + ) { + throw new Error("Папка содержит небезопасный путь."); + } + if (candidate.file.size <= 0) throw new Error(`Пустой файл: ${candidate.logicalPath}`); + if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`); + paths.add(candidate.logicalPath); + } +} + +function summarizeCandidates(candidates: SimulationUploadCandidate[]): { title: string; detail: string } { + if (!candidates.length) return { + title: "Перетащите архив или папку сюда", + detail: "ZIP, RAR, 7z либо parent folder с LCC/LCC2", + }; + const total = candidates.reduce((sum, candidate) => sum + candidate.file.size, 0); + const archive = candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? ""); + return { + title: archive ? candidates[0]?.logicalPath ?? "Архив" : "Папка принята", + detail: `${candidates.length.toLocaleString("ru-RU")} файл(ов) · ${formatBytes(total)}`, + }; +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} Б`; + if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`; + if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`; + return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`; +} diff --git a/apps/control-station/src/components/simulation/SimulationViewport.tsx b/apps/control-station/src/components/simulation/SimulationViewport.tsx new file mode 100644 index 0000000..40e4e80 --- /dev/null +++ b/apps/control-station/src/components/simulation/SimulationViewport.tsx @@ -0,0 +1,105 @@ +import { useEffect, useRef, useState } from "react"; +import { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react"; + +import type { SimulationProject } from "../../core/simulation/projects"; +import { + PlayCanvasRuntime, + type SimulationQuality, + type SimulationViewMode, +} from "./PlayCanvasRuntime"; + +export function SimulationViewport({ project }: { project: SimulationProject }) { + const canvasRef = useRef(null); + const runtimeRef = useRef(null); + const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting"); + const [error, setError] = useState(null); + const [viewMode, setViewMode] = useState("visual"); + const [quality, setQuality] = useState("auto"); + + useEffect(() => { + const canvas = canvasRef.current; + const manifest = project.worldManifest; + if (!canvas || !manifest) return; + const runtime = new PlayCanvasRuntime(); + runtimeRef.current = runtime; + let cancelled = false; + setState("mounting"); + setError(null); + void runtime.mount(canvas).then(async () => { + if (cancelled) return; + setState("loading"); + await runtime.loadWorld(manifest); + if (!cancelled) setState("ready"); + }).catch((caught: unknown) => { + if (cancelled) return; + setState("failed"); + setError(caught instanceof Error ? caught.message : "Не удалось открыть сцену."); + }); + return () => { + cancelled = true; + runtime.dispose(); + runtimeRef.current = null; + }; + }, [project.projectId, project.worldManifest]); + + const collisionAvailable = project.worldManifest?.collision.available ?? false; + + return ( +
+
+
+ + {state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"} + + PlayCanvas Engine 2.21.4 +
+ { + setViewMode(next); + runtimeRef.current?.setViewMode(next); + }} + items={[ + { value: "visual", label: "Визуал" }, + { value: "collision", label: "Коллизии", disabled: !collisionAvailable }, + { value: "combined", label: "Вместе", disabled: !collisionAvailable }, + ]} + /> + { + setQuality(next); + runtimeRef.current?.setQuality(next); + }} + items={[ + { value: "auto", label: "Auto" }, + { value: "low", label: "Low" }, + { value: "medium", label: "Med" }, + { value: "high", label: "High" }, + ]} + /> + +
+
+ + {state !== "ready" ? ( +
+ {state === "failed" ? null : } + {state === "failed" ? "Сцена не открылась" : "PlayCanvas загружает мир"} +

{error ?? "Streamed SOG догружается по мере готовности runtime."}

+
+ ) : null} +
+ {!collisionAvailable ? ( +
+ Collision недоступен + Worker 006 сейчас работает в CPU-режиме; визуальный слой настоящий, collision GLB не подменяется. +
+ ) : null} +
+ ); +} diff --git a/apps/control-station/src/core/simulation/projects.ts b/apps/control-station/src/core/simulation/projects.ts new file mode 100644 index 0000000..e3335b7 --- /dev/null +++ b/apps/control-station/src/core/simulation/projects.ts @@ -0,0 +1,399 @@ +export type SimulationSceneType = "interior" | "outdoor" | "object"; +export type SimulationProjectStatus = + | "uploading" + | "queued" + | "processing" + | "importing" + | "ready" + | "failed"; + +export interface SimulationSourceFile { + fileId: string; + logicalPath: string; + byteLength: number; + uploadedBytes: number; + sha256: string | null; +} + +export interface SimulationWorldManifest { + schemaVersion: "missioncore.simulation-world-manifest/v1"; + projectId: string; + visual: { + previewSogUrl: string | null; + streamedSogUrl: string | null; + }; + collision: { + meshUrl: string | null; + available: boolean; + }; + transforms: { + worldFromVisual: number[]; + worldFromCollision: number[]; + }; +} + +export interface SimulationProject { + schemaVersion: "missioncore.simulation-project/v1"; + projectId: string; + name: string; + sceneType: SimulationSceneType; + status: SimulationProjectStatus; + source: { + kind: "archive" | "folder"; + totalByteLength: number; + uploadedByteLength: number; + files: SimulationSourceFile[]; + bundleSha256: string | null; + }; + provider: { + providerId: "gaussian-pipeline"; + jobId: string | null; + state: string | null; + progress: { completed_steps?: number; total_steps?: number; stage?: string } | null; + runtime: { source_revision?: string; image_digest?: string } | null; + }; + artifacts: Array<{ + role: string; + logicalPath: string; + mediaType: string; + sha256: string; + byteLength: number; + }>; + worldManifest: SimulationWorldManifest | null; + error: string | null; + createdAtUtc: string; + updatedAtUtc: string; +} + +export interface SimulationUploadCandidate { + file: File; + logicalPath: string; +} + +export interface SimulationUploadProgress { + uploadedBytes: number; + totalBytes: number; + currentPath: string; +} + +const API_ROOT = "/api/v1/simulation-worlds/projects"; +const CHUNK_BYTES = 8 * 1024 * 1024; +const MAX_UPLOAD_ATTEMPTS = 3; + +export async function fetchSimulationProjects(signal?: AbortSignal): Promise { + const response = await fetch(API_ROOT, { signal, cache: "no-store" }); + const document = await jsonResponse(response); + const record = objectValue(document, "simulation project page"); + exactKeys(record, ["schema_version", "projects"], "simulation project page"); + if (record.schema_version !== "missioncore.simulation-project-page/v1" || !Array.isArray(record.projects)) { + throw new Error("Каталог симуляций вернул неподдерживаемый контракт."); + } + return record.projects.map(parseProject); +} + +export async function createAndUploadSimulationProject( + name: string, + sceneType: SimulationSceneType, + candidates: SimulationUploadCandidate[], + onProgress: (progress: SimulationUploadProgress) => void, +): Promise { + const sourceKind = sourceKindFor(candidates); + const createdResponse = await fetch(API_ROOT, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_version: "missioncore.simulation-project-create/v1", + name, + scene_type: sceneType, + source_kind: sourceKind, + files: candidates.map((candidate) => ({ + logical_path: candidate.logicalPath, + byte_length: candidate.file.size, + })), + }), + }); + let project = parseProject(await jsonResponse(createdResponse)); + const byPath = new Map(candidates.map((candidate) => [candidate.logicalPath, candidate.file])); + const totalBytes = candidates.reduce((total, candidate) => total + candidate.file.size, 0); + let confirmedBytes = 0; + for (const sourceFile of project.source.files) { + const file = byPath.get(sourceFile.logicalPath); + if (!file) throw new Error(`Сервер изменил состав источника: ${sourceFile.logicalPath}`); + const uploadUrl = `${API_ROOT}/${encodeURIComponent(project.projectId)}/source/${encodeURIComponent(sourceFile.fileId)}`; + let offset = await readUploadOffset(uploadUrl, file.size); + confirmedBytes += offset; + onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath }); + while (offset < file.size) { + const nextOffset = Math.min(file.size, offset + CHUNK_BYTES); + const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset); + confirmedBytes += confirmedOffset - offset; + offset = confirmedOffset; + onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath }); + } + } + const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(project.projectId)}/build`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + project = parseProject(await jsonResponse(buildResponse)); + return project; +} + +async function readUploadOffset(uploadUrl: string, fileSize: number): Promise { + const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store" }); + if (!head.ok) await jsonResponse(head); + const value = head.headers.get("Upload-Offset"); + if (!value || !/^\d+$/.test(value)) { + throw new Error("Сервер не вернул смещение resumable-загрузки."); + } + const offset = Number(value); + if (!Number.isSafeInteger(offset) || offset < 0 || offset > fileSize) { + throw new Error("Сервер вернул некорректное смещение загрузки."); + } + return offset; +} + +async function uploadChunk( + uploadUrl: string, + file: File, + offset: number, + nextOffset: number, +): Promise { + let lastFailure: unknown = null; + for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) { + let response: Response | null = null; + try { + response = await fetch(uploadUrl, { + method: "PATCH", + headers: { + "Content-Type": "application/offset+octet-stream", + "Upload-Offset": String(offset), + }, + body: file.slice(offset, nextOffset), + }); + } catch (caught) { + lastFailure = caught; + } + if (response?.ok) { + const confirmed = response.headers.get("Upload-Offset"); + if (confirmed && Number(confirmed) === nextOffset) return nextOffset; + throw new Error("Сервер не подтвердил непрерывность загрузки."); + } + if ( + response && + ((response.status < 500 && response.status !== 409) || + (response.status >= 500 && attempt === MAX_UPLOAD_ATTEMPTS)) + ) { + await jsonResponse(response); + throw new Error("Mission Core отклонил блок загрузки."); + } + if (response) { + lastFailure = new Error(`Mission Core временно вернул HTTP ${response.status}.`); + } + try { + const confirmed = await readUploadOffset(uploadUrl, file.size); + if (confirmed === nextOffset) return confirmed; + if (confirmed !== offset) { + throw new Error("Сервер подтвердил неожиданное смещение загрузки."); + } + } catch (caught) { + lastFailure = caught; + } + } + throw lastFailure instanceof Error + ? lastFailure + : new Error("Не удалось продолжить resumable-загрузку."); +} + +export async function updateSimulationProject( + projectId: string, + name: string, + sceneType: SimulationSceneType, +): Promise { + const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + schema_version: "missioncore.simulation-project-update/v1", + name, + scene_type: sceneType, + }), + }); + return parseProject(await jsonResponse(response)); +} + +export async function deleteSimulationProject(projectId: string): Promise { + const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { method: "DELETE" }); + if (!response.ok) await jsonResponse(response); +} + +function sourceKindFor(candidates: SimulationUploadCandidate[]): "archive" | "folder" { + if (candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "")) { + return "archive"; + } + if (candidates.some((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath))) { + throw new Error("Архив загружается отдельно; нельзя смешивать его с файлами папки."); + } + return "folder"; +} + +function parseProject(value: unknown): SimulationProject { + const record = objectValue(value, "simulation project"); + exactKeys(record, [ + "schema_version", "project_id", "name", "scene_type", "status", "source", "provider", + "artifacts", "world_manifest", "error", "created_at_utc", "updated_at_utc", + ], "simulation project"); + if (record.schema_version !== "missioncore.simulation-project/v1") { + throw new Error("Проект симуляции вернул неподдерживаемую версию."); + } + const source = objectValue(record.source, "simulation source"); + const provider = objectValue(record.provider, "simulation provider"); + if (!Array.isArray(source.files) || !Array.isArray(record.artifacts)) { + throw new Error("Проект симуляции вернул некорректный состав файлов."); + } + return { + schemaVersion: "missioncore.simulation-project/v1", + projectId: stringValue(record.project_id, "project id"), + name: stringValue(record.name, "project name"), + sceneType: sceneTypeValue(record.scene_type), + status: statusValue(record.status), + source: { + kind: source.kind === "archive" ? "archive" : source.kind === "folder" ? "folder" : invalid("source kind"), + totalByteLength: numberValue(source.total_byte_length, "source bytes"), + uploadedByteLength: numberValue(source.uploaded_byte_length, "uploaded bytes"), + files: source.files.map((item) => { + const file = objectValue(item, "source file"); + return { + fileId: stringValue(file.file_id, "source file id"), + logicalPath: stringValue(file.logical_path, "source logical path"), + byteLength: numberValue(file.byte_length, "source file bytes"), + uploadedBytes: numberValue(file.uploaded_bytes, "source uploaded bytes"), + sha256: nullableString(file.sha256, "source sha256"), + }; + }), + bundleSha256: nullableString(source.bundle_sha256, "bundle sha256"), + }, + provider: { + providerId: provider.provider_id === "gaussian-pipeline" ? "gaussian-pipeline" : invalid("provider id"), + jobId: nullableString(provider.job_id, "provider job id"), + state: nullableString(provider.state, "provider state"), + progress: provider.progress === null ? null : objectValue(provider.progress, "provider progress"), + runtime: provider.runtime === null ? null : objectValue(provider.runtime, "provider runtime"), + }, + artifacts: record.artifacts.map((item) => { + const artifact = objectValue(item, "simulation artifact"); + return { + role: stringValue(artifact.role, "artifact role"), + logicalPath: stringValue(artifact.logical_path, "artifact path"), + mediaType: stringValue(artifact.media_type, "artifact media type"), + sha256: stringValue(artifact.sha256, "artifact sha256"), + byteLength: numberValue(artifact.byte_length, "artifact bytes"), + }; + }), + worldManifest: record.world_manifest === null ? null : parseWorldManifest(record.world_manifest), + error: nullableString(record.error, "project error"), + createdAtUtc: stringValue(record.created_at_utc, "created at"), + updatedAtUtc: stringValue(record.updated_at_utc, "updated at"), + }; +} + +function parseWorldManifest(value: unknown): SimulationWorldManifest { + const record = objectValue(value, "world manifest"); + const visual = objectValue(record.visual, "visual manifest"); + const collision = objectValue(record.collision, "collision manifest"); + const transforms = objectValue(record.transforms, "world transforms"); + if (record.schema_version !== "missioncore.simulation-world-manifest/v1") { + throw new Error("World manifest вернул неподдерживаемую версию."); + } + return { + schemaVersion: "missioncore.simulation-world-manifest/v1", + projectId: stringValue(record.project_id, "manifest project id"), + visual: { + previewSogUrl: nullableString(visual.preview_sog_url, "preview URL"), + streamedSogUrl: nullableString(visual.streamed_sog_url, "streamed URL"), + }, + collision: { + meshUrl: nullableString(collision.mesh_url, "collision URL"), + available: booleanValue(collision.available, "collision availability"), + }, + transforms: { + worldFromVisual: matrixValue(transforms.world_from_visual, "visual transform"), + worldFromCollision: matrixValue(transforms.world_from_collision, "collision transform"), + }, + }; +} + +async function jsonResponse(response: Response): Promise { + let value: unknown; + try { + value = await response.json(); + } catch { + if (response.ok) return {}; + throw new Error(`Mission Core вернул HTTP ${response.status}.`); + } + if (!response.ok) { + const detail = objectValue(value, "error response").detail; + throw new Error(typeof detail === "string" ? detail : `Mission Core вернул HTTP ${response.status}.`); + } + return value; +} + +function objectValue(value: unknown, label: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new Error(`Некорректный ${label}.`); + } + return value as Record; +} + +function exactKeys(record: Record, keys: string[], label: string): void { + const actual = Object.keys(record).sort(); + const expected = [...keys].sort(); + if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) { + throw new Error(`Некорректные поля ${label}.`); + } +} + +function stringValue(value: unknown, label: string): string { + if (typeof value !== "string" || !value) throw new Error(`Некорректный ${label}.`); + return value; +} + +function nullableString(value: unknown, label: string): string | null { + return value === null ? null : stringValue(value, label); +} + +function numberValue(value: unknown, label: string): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) { + throw new Error(`Некорректный ${label}.`); + } + return value; +} + +function booleanValue(value: unknown, label: string): boolean { + if (typeof value !== "boolean") throw new Error(`Некорректный ${label}.`); + return value; +} + +function matrixValue(value: unknown, label: string): number[] { + if (!Array.isArray(value) || value.length !== 16 || value.some((item) => typeof item !== "number")) { + throw new Error(`Некорректный ${label}.`); + } + return [...value]; +} + +function sceneTypeValue(value: unknown): SimulationSceneType { + if (value === "interior" || value === "outdoor" || value === "object") return value; + return invalid("scene type"); +} + +function statusValue(value: unknown): SimulationProjectStatus { + if (["uploading", "queued", "processing", "importing", "ready", "failed"].includes(String(value))) { + return value as SimulationProjectStatus; + } + return invalid("project status"); +} + +function invalid(label: string): never { + throw new Error(`Некорректный ${label}.`); +} diff --git a/apps/control-station/src/productModel.ts b/apps/control-station/src/productModel.ts index b8aaabd..ddf2af2 100644 --- a/apps/control-station/src/productModel.ts +++ b/apps/control-station/src/productModel.ts @@ -22,7 +22,8 @@ export type WorkspaceKind = | "network-monitor" | "datasets" | "artifact-health" - | "lab-archive"; + | "lab-archive" + | "simulations"; export type CapabilityStatus = "active" | "ready" | "contract" | "later"; @@ -154,6 +155,17 @@ export const workspaces: WorkspaceDefinition[] = [ kind: "lab-archive", groups: [], }, + { + id: "simulations", + root: "polygon", + label: "Симуляции", + title: "Симуляции", + eyebrow: "ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ", + description: "Каталог Gaussian-миров, переносимые сборки и интерактивные PlayCanvas-сцены.", + icon: "globe", + kind: "simulations", + groups: [], + }, { id: "contour-health", root: "fleet", diff --git a/apps/control-station/src/styles.css b/apps/control-station/src/styles.css index 7d67b34..4fc9d60 100644 --- a/apps/control-station/src/styles.css +++ b/apps/control-station/src/styles.css @@ -10,6 +10,7 @@ @import "./styles/l3-pointpillars-visual-audit.css"; @import "./styles/l34-annotation.css"; @import "./styles/laboratory-reporting.css"; +@import "./styles/simulation.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/simulation.css b/apps/control-station/src/styles/simulation.css new file mode 100644 index 0000000..093cd5a --- /dev/null +++ b/apps/control-station/src/styles/simulation.css @@ -0,0 +1,442 @@ +.simulation-workspace { + display: grid; + min-width: 0; + gap: 0.9rem; + padding-bottom: 1rem; +} + +.simulation-workspace--scene { + height: 100%; + min-height: 38rem; + grid-template-rows: auto minmax(0, 1fr); +} + +.simulation-workspace__head, +.simulation-workspace__scene-head, +.simulation-viewport__toolbar, +.simulation-viewport__notice, +.simulation-catalog__summary, +.simulation-catalog__state, +.simulation-catalog__actions, +.simulation-project-window__footer-state, +.simulation-source-drop > div, +.simulation-upload-progress { + display: flex; + align-items: center; +} + +.simulation-workspace__head, +.simulation-workspace__scene-head { + justify-content: space-between; + gap: 1rem; + padding: 0.25rem 0.2rem; +} + +.simulation-workspace__head h2, +.simulation-workspace__head p, +.simulation-workspace__scene-head h2 { + margin: 0; +} + +.simulation-workspace__head h2, +.simulation-workspace__scene-head h2 { + margin-top: 0.3rem; + color: var(--nodedc-text-primary); + font-size: 1.18rem; + letter-spacing: -0.035em; +} + +.simulation-workspace__head p { + max-width: 46rem; + margin-top: 0.38rem; + color: var(--nodedc-text-muted); + font-size: 0.66rem; + line-height: 1.5; +} + +.simulation-workspace__scene-head { + display: grid; + grid-template-columns: minmax(8rem, auto) minmax(0, 1fr) auto; +} + +.simulation-catalog { + overflow: hidden; + border: 1px solid var(--station-hairline); + border-radius: 1rem; + background: rgb(255 255 255 / 0.022); +} + +.simulation-catalog__summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + border-bottom: 1px solid var(--station-hairline); + background: var(--station-hairline); +} + +.simulation-catalog__summary > div { + display: grid; + gap: 0.24rem; + background: var(--nodedc-canvas); + padding: 0.85rem 1rem; +} + +.simulation-catalog__summary span, +.simulation-catalog__table th { + color: var(--nodedc-text-muted); + font-size: 0.52rem; + font-weight: 680; + letter-spacing: 0.075em; + text-transform: uppercase; +} + +.simulation-catalog__summary strong { + color: var(--nodedc-text-primary); + font-size: 0.88rem; +} + +.simulation-catalog__table-wrap { + overflow: auto; +} + +.simulation-catalog__table { + width: 100%; + min-width: 62rem; + border-collapse: collapse; +} + +.simulation-catalog__table th, +.simulation-catalog__table td { + border-bottom: 1px solid var(--station-hairline); + padding: 0.72rem 0.8rem; + text-align: left; + vertical-align: middle; +} + +.simulation-catalog__table th:first-child, +.simulation-catalog__table td:first-child { + padding-left: 1rem; +} + +.simulation-catalog__table th:last-child, +.simulation-catalog__table td:last-child { + padding-right: 1rem; +} + +.simulation-catalog__table tbody tr { + transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard); +} + +.simulation-catalog__table tbody tr:hover { + background: rgb(255 255 255 / 0.025); +} + +.simulation-catalog__table tbody tr:last-child td { + border-bottom: 0; +} + +.simulation-catalog__table td { + color: var(--nodedc-text-secondary); + font-size: 0.64rem; +} + +.simulation-catalog__table td > small, +.simulation-catalog__table td > button small { + display: block; + margin-top: 0.18rem; + color: var(--nodedc-text-muted); + font-size: 0.54rem; +} + +.simulation-catalog__table td:first-child > button { + display: flex; + align-items: center; + gap: 0.65rem; + min-width: 14rem; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.simulation-catalog__table td:first-child > button strong { + display: block; + max-width: 20rem; + overflow: hidden; + color: var(--nodedc-text-primary); + font-size: 0.7rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simulation-catalog__project-icon, +.simulation-catalog__empty > span { + display: grid; + width: 2rem; + height: 2rem; + flex: 0 0 auto; + place-items: center; + border: 1px solid var(--station-hairline); + border-radius: 0.62rem; + background: rgb(var(--nodedc-accent-rgb) / 0.07); + color: var(--nodedc-text-secondary); +} + +.simulation-catalog__actions { + justify-content: flex-end; + gap: 0.28rem; +} + +.simulation-catalog__state, +.simulation-catalog__empty { + min-height: 20rem; + justify-content: center; + gap: 0.75rem; + color: var(--nodedc-text-muted); + font-size: 0.65rem; +} + +.simulation-catalog__state > div { + display: grid; + gap: 0.2rem; +} + +.simulation-catalog__state strong, +.simulation-catalog__empty strong { + color: var(--nodedc-text-primary); +} + +.simulation-catalog__empty { + display: flex; + flex-direction: column; + text-align: center; +} + +.simulation-catalog__empty p { + max-width: 30rem; + margin: 0; + line-height: 1.5; +} + +.simulation-project-window { + display: grid; + gap: 0.9rem; +} + +.simulation-project-window .nodedc-select-anchor, +.simulation-project-window .nodedc-select { + width: 100%; +} + +.simulation-source-drop { + display: grid; + min-height: 12rem; + place-items: center; + align-content: center; + gap: 0.55rem; + border: 1px dashed var(--nodedc-glass-outline); + border-radius: 0.85rem; + background: rgb(255 255 255 / 0.018); + color: var(--nodedc-text-muted); + text-align: center; + transition: border-color var(--nodedc-duration-fast) var(--nodedc-ease-standard), + background var(--nodedc-duration-fast) var(--nodedc-ease-standard); +} + +.simulation-source-drop[data-dragging="true"] { + border-color: rgb(var(--nodedc-accent-rgb) / 0.72); + background: rgb(var(--nodedc-accent-rgb) / 0.08); +} + +.simulation-source-drop > strong { + color: var(--nodedc-text-primary); + font-size: 0.72rem; +} + +.simulation-source-drop > span { + max-width: 30rem; + font-size: 0.58rem; +} + +.simulation-source-drop > div { + gap: 0.45rem; + margin-top: 0.25rem; +} + +.simulation-upload-progress { + gap: 0.7rem; + border-radius: 0.72rem; + background: rgb(255 255 255 / 0.025); + padding: 0.7rem; +} + +.simulation-upload-progress > span { + position: relative; + overflow: hidden; + width: 8rem; + height: 0.28rem; + border-radius: 999px; + background: rgb(255 255 255 / 0.08); +} + +.simulation-upload-progress > span i { + position: absolute; + inset: 0 auto 0 0; + border-radius: inherit; + background: rgb(var(--nodedc-accent-rgb)); +} + +.simulation-upload-progress > div { + display: grid; + min-width: 0; + gap: 0.14rem; +} + +.simulation-upload-progress strong { + color: var(--nodedc-text-primary); + font-size: 0.62rem; +} + +.simulation-upload-progress small { + overflow: hidden; + color: var(--nodedc-text-muted); + font-size: 0.54rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.simulation-project-window__footer-state { + gap: 0.4rem; + color: var(--nodedc-text-muted); + font-size: 0.58rem; +} + +.simulation-project-window__error { + margin: 0; + border-radius: 0.65rem; + background: rgb(var(--nodedc-danger-rgb) / 0.08); + color: var(--nodedc-danger); + padding: 0.65rem 0.75rem; + font-size: 0.6rem; + line-height: 1.45; +} + +.simulation-viewport { + display: grid; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--station-hairline); + border-radius: 1rem; + background: #07090d; +} + +.simulation-viewport__toolbar { + flex-wrap: wrap; + gap: 0.55rem; + border-bottom: 1px solid var(--station-hairline); + background: var(--nodedc-glass-panel-bg-soft); + padding: 0.55rem 0.65rem; +} + +.simulation-viewport__toolbar > div:first-child { + display: flex; + min-width: 12rem; + flex: 1; + align-items: center; + gap: 0.45rem; +} + +.simulation-viewport__toolbar > div:first-child > span:last-child, +.simulation-viewport__notice > span:last-child { + color: var(--nodedc-text-muted); + font-size: 0.55rem; +} + +.simulation-viewport__stage { + position: relative; + min-height: 30rem; +} + +.simulation-viewport__stage canvas { + display: block; + width: 100%; + height: 100%; + outline: none; +} + +.simulation-viewport__stage canvas:focus-visible { + box-shadow: inset 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.7); +} + +.simulation-viewport__state { + position: absolute; + inset: 0; + display: grid; + place-items: center; + align-content: center; + gap: 0.55rem; + background: rgb(7 9 13 / 0.84); + color: var(--nodedc-text-muted); + text-align: center; +} + +.simulation-viewport__state strong { + color: var(--nodedc-text-primary); + font-size: 0.75rem; +} + +.simulation-viewport__state p { + max-width: 34rem; + margin: 0; + font-size: 0.6rem; +} + +.simulation-viewport__notice { + gap: 0.55rem; + border-top: 1px solid var(--station-hairline); + padding: 0.48rem 0.65rem; +} + +.simulation-workspace__processing { + display: flex; + min-height: 18rem; + align-items: center; + justify-content: center; + gap: 0.8rem; +} + +.simulation-workspace__processing > div { + display: grid; + max-width: 40rem; + gap: 0.25rem; +} + +.simulation-workspace__processing strong { + color: var(--nodedc-text-primary); + font-size: 0.75rem; +} + +.simulation-workspace__processing p { + margin: 0; + color: var(--nodedc-text-muted); + font-size: 0.62rem; + line-height: 1.5; +} + +@media (max-width: 900px) { + .simulation-catalog__summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .simulation-workspace__scene-head { + grid-template-columns: 1fr auto; + } + + .simulation-workspace__scene-head > .nodedc-button { + grid-column: 1 / -1; + justify-self: start; + } +} diff --git a/apps/control-station/src/types/playcanvas-scripts.d.ts b/apps/control-station/src/types/playcanvas-scripts.d.ts new file mode 100644 index 0000000..71032b1 --- /dev/null +++ b/apps/control-station/src/types/playcanvas-scripts.d.ts @@ -0,0 +1,14 @@ +declare module "playcanvas/scripts/esm/camera-controls.mjs" { + import { Script, Vec2, Vec3 } from "playcanvas"; + + export class CameraControls extends Script { + static scriptName: "cameraControls"; + enableFly: boolean; + enableOrbit: boolean; + focusPoint: Vec3; + zoomRange: Vec2; + focus(focus: Vec3, resetZoom?: boolean): void; + look(focus: Vec3, resetZoom?: boolean): void; + reset(focus: Vec3, position: Vec3): void; + } +} diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index dd4d870..468f781 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -1,10 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - Button, - GlassSurface, - Icon, - StatusBadge, -} from "@nodedc/ui-react"; +import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react"; import { ObservationMedia, ObservationSourcePicker, @@ -41,6 +36,7 @@ import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace"; import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace"; import { ContourHealthWorkspace } from "./ContourHealthWorkspace"; import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace"; +import { SimulationWorkspace } from "./simulation/SimulationWorkspace"; import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace"; import { NetworkWorkspace } from "./system/NetworkWorkspace"; import { WorldMapWorkspace } from "./map/WorldMapWorkspace"; @@ -1193,6 +1189,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) { SpatialView={SpatialWorkspace} /> ); + case "simulations": + return ; case "device": return null; } diff --git a/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx b/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx new file mode 100644 index 0000000..7b00a2b --- /dev/null +++ b/apps/control-station/src/workspaces/simulation/SimulationWorkspace.tsx @@ -0,0 +1,280 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + ActivityIndicator, + Button, + ConfirmationModal, + GlassSurface, + Icon, + IconButton, + StatusBadge, +} from "@nodedc/ui-react"; + +import { SimulationProjectWindow } from "../../components/simulation/SimulationProjectWindow"; +import { SimulationViewport } from "../../components/simulation/SimulationViewport"; +import { + deleteSimulationProject, + fetchSimulationProjects, + type SimulationProject, + type SimulationProjectStatus, +} from "../../core/simulation/projects"; + +const ACTIVE_STATUSES = new Set(["queued", "processing", "importing"]); + +export function SimulationWorkspace() { + const [projects, setProjects] = useState([]); + const [selectedId, setSelectedId] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [windowOpen, setWindowOpen] = useState(false); + const [editing, setEditing] = useState(null); + const [deleting, setDeleting] = useState(null); + + const load = useCallback(async (signal?: AbortSignal) => { + try { + const next = await fetchSimulationProjects(signal); + setProjects(next); + setError(null); + } catch (caught) { + if (signal?.aborted) return; + setError(caught instanceof Error ? caught.message : "Каталог симуляций недоступен."); + } finally { + if (!signal?.aborted) setLoading(false); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + void load(controller.signal); + return () => controller.abort(); + }, [load]); + + useEffect(() => { + if (!projects.some((project) => ACTIVE_STATUSES.has(project.status))) return; + const timer = window.setInterval(() => void load(), 2_000); + return () => window.clearInterval(timer); + }, [load, projects]); + + const selected = useMemo( + () => projects.find((project) => project.projectId === selectedId) ?? null, + [projects, selectedId], + ); + + const openCreate = () => { + setEditing(null); + setWindowOpen(true); + }; + + const openEdit = (project: SimulationProject) => { + setEditing(project); + setWindowOpen(true); + }; + + const acceptSaved = (project: SimulationProject) => { + setProjects((current) => [project, ...current.filter((item) => item.projectId !== project.projectId)]); + setSelectedId(project.projectId); + setWindowOpen(false); + setEditing(null); + }; + + const confirmDelete = async () => { + if (!deleting) return; + await deleteSimulationProject(deleting.projectId); + setProjects((current) => current.filter((project) => project.projectId !== deleting.projectId)); + if (selectedId === deleting.projectId) setSelectedId(null); + setDeleting(null); + }; + + if (selected) { + return ( +
+
+ +
+ LAB / SIMULATION RUNTIME +

{selected.name}

+
+ + {statusPresentation(selected.status).label} + +
+ {selected.status === "ready" && selected.worldManifest ? ( + + ) : ( + + {selected.status === "failed" ? : } +
+ {selected.status === "failed" ? "Сборка остановлена" : "Worker 006 собирает мир"} +

{selected.error ?? processingMessage(selected)}

+
+ + {selected.provider.state ?? selected.status} + +
+ )} +
+ ); + } + + return ( +
+
+
+ ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ +

Gaussian-миры

+

Каталог исходников, сборок Worker 006 и полнофункциональных PlayCanvas-сцен.

+
+ +
+ +
+
+
Проектов{projects.length.toLocaleString("ru-RU")}
+
Готово{projects.filter((project) => project.status === "ready").length}
+
В работе{projects.filter((project) => ACTIVE_STATUSES.has(project.status)).length}
+
Исходники{formatBytes(projects.reduce((sum, project) => sum + project.source.totalByteLength, 0))}
+
+ {loading ? ( +
+ + Читаем каталог сцен +
+ ) : error ? ( +
+ +
Каталог недоступен{error}
+ +
+ ) : projects.length === 0 ? ( +
+ + Проектов пока нет +

Создайте первый мир из папки LCC/LCC2 или архива ZIP, RAR, 7z.

+ +
+ ) : ( +
+ + + + + + + + + + + + + {projects.map((project) => { + const status = statusPresentation(project.status); + return ( + + + + + + + + + + ); + })} + +
ПроектИсточникРазмерСплатыСтатусОбновлён +
+ + {project.source.kind === "archive" ? "Архив" : "Папка"}{project.source.files.length} файл(ов){formatBytes(project.source.totalByteLength)}{status.label}{formatDate(project.updatedAtUtc)} +
+ setDeleting(project)}> + + + openEdit(project)}> + + +
+
+
+ )} +
+ + { if (!windowOpen) return; setWindowOpen(false); setEditing(null); }} + onSaved={acceptSaved} + /> + + Проект {deleting?.name}, локальные производные артефакты и job на Worker 006 будут удалены. +

+ )} + confirmLabel="Удалить проект" + pendingLabel="Удаляем…" + danger + onClose={() => setDeleting(null)} + onConfirm={confirmDelete} + /> +
+ ); +} + +function statusPresentation(status: SimulationProjectStatus): { + label: string; + tone: "success" | "accent" | "warning" | "neutral"; +} { + if (status === "ready") return { label: "Готово", tone: "success" }; + if (status === "failed") return { label: "Ошибка", tone: "warning" }; + if (status === "uploading") return { label: "Загрузка", tone: "neutral" }; + if (status === "importing") return { label: "Импорт", tone: "accent" }; + return { label: "Сборка", tone: "accent" }; +} + +function processingMessage(project: SimulationProject): string { + const progress = project.provider.progress; + const completed = progress?.completed_steps; + const total = progress?.total_steps; + if (typeof completed === "number" && typeof total === "number") { + return `Этап ${completed.toLocaleString("ru-RU")} из ${total.toLocaleString("ru-RU")}. Каталог обновляется автоматически.`; + } + return "Исходник подтверждён; конвертация SOG и Streamed SOG выполняется в переносимом контейнере."; +} + +function sceneTypeLabel(value: SimulationProject["sceneType"]): string { + if (value === "interior") return "Интерьер"; + if (value === "object") return "Объект"; + return "Улица"; +} + +function formatDate(value: string): string { + const date = new Date(value); + return Number.isNaN(date.valueOf()) ? "—" : date.toLocaleString("ru-RU", { + day: "2-digit", + month: "short", + hour: "2-digit", + minute: "2-digit", + }); +} + +function formatBytes(value: number): string { + if (value < 1024) return `${value} Б`; + if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`; + if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`; + return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`; +} diff --git a/apps/control-station/test/polygonRunArchive.test.mjs b/apps/control-station/test/polygonRunArchive.test.mjs index 1874f80..99afad5 100644 --- a/apps/control-station/test/polygonRunArchive.test.mjs +++ b/apps/control-station/test/polygonRunArchive.test.mjs @@ -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"], + ["lab-archive", "simulations"], ); assert.equal( workspacesForRoot("system").some(({ id }) => id === "polygon-run"), diff --git a/apps/control-station/test/simulationWorkspace.test.mjs b/apps/control-station/test/simulationWorkspace.test.mjs new file mode 100644 index 0000000..8c84602 --- /dev/null +++ b/apps/control-station/test/simulationWorkspace.test.mjs @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { test } from "node:test"; + +const sourceRoot = new URL("../src/", import.meta.url); + +async function read(relativePath) { + return readFile(new URL(relativePath, sourceRoot), "utf8"); +} + +test("simulation is a dedicated Polygon workspace with a bounded feature slice", async () => { + const [productModel, workspaceHub, workspace, styles] = await Promise.all([ + read("productModel.ts"), + read("workspaces/Workspaces.tsx"), + read("workspaces/simulation/SimulationWorkspace.tsx"), + read("styles.css"), + ]); + + assert.match(productModel, /id: "simulations"[\s\S]*root: "polygon"[\s\S]*kind: "simulations"/); + assert.match(workspaceHub, /case "simulations":[\s\S]*/); + assert.match(workspace, /simulation-catalog__table/); + assert.match(workspace, / { + const [core, window] = await Promise.all([ + read("core/simulation/projects.ts"), + read("components/simulation/SimulationProjectWindow.tsx"), + ]); + + assert.match(core, /const API_ROOT = "\/api\/v1\/simulation-worlds\/projects"/); + assert.match(core, /method: "HEAD"/); + assert.match(core, /method: "PATCH"/); + assert.match(core, /"Upload-Offset"/); + assert.doesNotMatch(core, /Authorization|Bearer|18090|Worker 006/); + assert.match(window, /accept="\.zip,\.rar,\.7z"/); + assert.match(window, /webkitdirectory/); + assert.match(window, /webkitGetAsEntry/); + assert.match(window, /ровно одна сцена \.lcc или \.lcc2/); +}); + +test("PlayCanvas owns the realtime scene graph without an iframe or React entity tree", async () => { + const [runtime, viewport, packageDocument] = await Promise.all([ + read("components/simulation/PlayCanvasRuntime.ts"), + read("components/simulation/SimulationViewport.tsx"), + readFile(new URL("../package.json", import.meta.url), "utf8"), + ]); + + assert.match(packageDocument, /"playcanvas": "2\.21\.4"/); + assert.doesNotMatch(packageDocument, /@playcanvas\/react/); + assert.match(runtime, /new Application\(canvas/); + assert.match(runtime, /new Asset\([^,]+, "gsplat"/); + assert.match(runtime, /camera\.script\?\.create\(CameraControls/); + assert.match(runtime, /app\.root\.addChild/); + assert.match(runtime, /dispose\(\)/); + assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe| dict[str, object]: + return { + "upload_id": self.upload_id, + "archive_name": self.archive_name, + "format": self.format, + "sha256": self.sha256, + "byte_length": self.byte_length, + } + + class GaussianPipelineGateway: """TUS and JSON client with bounded responses and independent digest checks.""" @@ -124,6 +144,8 @@ class GaussianPipelineGateway: or document.get("api_version") != "gaussian-pipeline.api/v1" or document.get("upload_protocol") != "tus/1.0.0" or document.get("source_transport") != "tus-bundle/v1" + or document.get("archive_transport") != "tus-archive/v1" + or document.get("archive_formats") != ["zip", "rar", "7z"] ): raise GaussianPipelineGatewayError("Gaussian provider capabilities do not match v1") _validate_runtime_provenance(document) @@ -196,6 +218,66 @@ class GaussianPipelineGateway: members=members, ) + def upload_source_archive(self, archive_path: Path) -> GaussianArchiveUpload: + source_candidate = archive_path.expanduser().absolute() + if source_candidate.is_symlink() or not source_candidate.is_file(): + raise GaussianPipelineIntegrityError( + "Gaussian source archive must be one regular file" + ) + source = source_candidate.resolve() + archive_name = source.name + suffix = source.suffix.lower() + archive_formats = {".zip": "zip", ".rar": "rar", ".7z": "7z"} + archive_format = archive_formats.get(suffix) + if archive_format is None or "/" in archive_name or "\\" in archive_name: + raise GaussianPipelineIntegrityError( + "Gaussian source archive must be zip, rar or 7z" + ) + byte_length = source.stat().st_size + if byte_length <= 0: + raise GaussianPipelineIntegrityError("Gaussian source archive is empty") + digest = _sha256(source) + capabilities = self.capabilities() + max_source_bytes = capabilities.get("max_source_bytes") + if ( + not isinstance(max_source_bytes, int) + or isinstance(max_source_bytes, bool) + or byte_length > max_source_bytes + ): + raise GaussianPipelineIntegrityError( + "Gaussian source archive exceeds provider byte admission" + ) + upload_id = self._upload_file( + source, + {"archive_name": archive_name, "sha256": digest}, + byte_length, + ) + return GaussianArchiveUpload( + upload_id=upload_id, + archive_name=archive_name, + format=archive_format, + sha256=digest, + byte_length=byte_length, + ) + + def normalize_archive( + self, + archive: GaussianArchiveUpload, + ) -> GaussianSourceBundleUpload: + document = self._json( + "POST", + "/v1/ingests", + document={ + "schema_version": ARCHIVE_INGEST_REQUEST_SCHEMA, + "archive": archive.to_dict(), + }, + ) + if document.get("schema_version") != ARCHIVE_INGEST_SCHEMA: + raise GaussianPipelineGatewayError( + "Gaussian archive ingest response does not match v1" + ) + return _source_bundle_upload(document.get("source")) + def _upload_member( self, source: Path, @@ -204,9 +286,25 @@ class GaussianPipelineGateway: byte_length: int, ) -> GaussianSourceMemberUpload: - metadata = _tus_metadata( - {"logical_path": logical_path, "sha256": sha256} + upload_id = self._upload_file( + source, + {"logical_path": logical_path, "sha256": sha256}, + byte_length, ) + return GaussianSourceMemberUpload( + upload_id=upload_id, + logical_path=logical_path, + sha256=sha256, + byte_length=byte_length, + ) + + def _upload_file( + self, + source: Path, + metadata_values: Mapping[str, str], + byte_length: int, + ) -> str: + metadata = _tus_metadata(metadata_values) try: response = self._client.post( "/v1/uploads", @@ -230,12 +328,7 @@ class GaussianPipelineGateway: if SAFE_UPLOAD_ID.fullmatch(upload_id) is None: raise GaussianPipelineGatewayError("Gaussian upload id is invalid") self._send_file(source, upload_url, byte_length) - return GaussianSourceMemberUpload( - upload_id=upload_id, - logical_path=logical_path, - sha256=sha256, - byte_length=byte_length, - ) + return upload_id def submit_build(self, document: Mapping[str, object]) -> dict[str, Any]: if document.get("schema_version") != BUILD_REQUEST_SCHEMA: @@ -260,6 +353,14 @@ class GaussianPipelineGateway: _validate_runtime_provenance(result) return result + def delete_job(self, job_id: str) -> None: + _safe_id(job_id, "job id") + try: + response = self._client.delete(f"/v1/jobs/{quote(job_id, safe='')}") + response.raise_for_status() + except httpx.HTTPError as exc: + raise _unavailable("Gaussian job deletion failed", exc) from exc + def download_artifact( self, job_id: str, @@ -408,6 +509,35 @@ def configured_gaussian_pipeline_gateway() -> GaussianPipelineGateway | None: return GaussianPipelineGateway(endpoint, Path(token_file)) +def discover_gaussian_source_bundle(root_path: Path) -> tuple[str, str]: + root_candidate = root_path.expanduser().absolute() + if root_candidate.is_symlink() or not root_candidate.is_dir(): + raise GaussianPipelineIntegrityError( + "Gaussian source bundle root must be one regular directory" + ) + root = root_candidate.resolve() + descriptors: list[str] = [] + for candidate in root.rglob("*"): + if candidate.is_symlink(): + raise GaussianPipelineIntegrityError( + "Gaussian source bundle contains a symlink" + ) + if not candidate.is_file(): + continue + suffix = candidate.suffix.lower() + if suffix in {".lcc", ".lcc2"}: + descriptors.append(candidate.relative_to(root).as_posix()) + descriptors.sort(key=lambda value: value.encode("utf-8")) + if len(descriptors) != 1: + raise GaussianPipelineIntegrityError( + "Gaussian source folder must contain exactly one LCC or LCC2 descriptor" + ) + entrypoint = descriptors[0] + source_format = "lcc2" if entrypoint.lower().endswith(".lcc2") else "lcc" + _discover_bundle_members(root, entrypoint, source_format) + return entrypoint, source_format + + def _endpoint(value: str) -> str: parsed = urlparse(value) if ( @@ -492,6 +622,101 @@ def _bundle_sha256( return hashlib.sha256(canonical).hexdigest() +def _source_bundle_upload(value: object) -> GaussianSourceBundleUpload: + if not isinstance(value, dict) or set(value) != { + "format", + "entrypoint", + "bundle_sha256", + "total_byte_length", + "members", + }: + raise GaussianPipelineGatewayError( + "Gaussian archive ingest source contract is invalid" + ) + source_format = value.get("format") + entrypoint = value.get("entrypoint") + bundle_sha256 = value.get("bundle_sha256") + total_byte_length = value.get("total_byte_length") + raw_members = value.get("members") + if ( + source_format not in {"lcc", "lcc2"} + or not isinstance(entrypoint, str) + or not entrypoint.lower().endswith(f".{source_format}") + or not isinstance(bundle_sha256, str) + or SHA256_PATTERN.fullmatch(bundle_sha256) is None + or not isinstance(total_byte_length, int) + or isinstance(total_byte_length, bool) + or total_byte_length <= 0 + or not isinstance(raw_members, list) + or not raw_members + or len(raw_members) > 10_000 + ): + raise GaussianPipelineGatewayError( + "Gaussian archive ingest source fields are invalid" + ) + logical_entrypoint = _logical_path(entrypoint, "entrypoint") + members: list[GaussianSourceMemberUpload] = [] + for raw_member in raw_members: + if not isinstance(raw_member, dict) or set(raw_member) != { + "upload_id", + "logical_path", + "sha256", + "byte_length", + }: + raise GaussianPipelineGatewayError( + "Gaussian archive ingest member contract is invalid" + ) + upload_id = raw_member.get("upload_id") + logical_path = raw_member.get("logical_path") + sha256 = raw_member.get("sha256") + byte_length = raw_member.get("byte_length") + if ( + not isinstance(upload_id, str) + or SAFE_UPLOAD_ID.fullmatch(upload_id) is None + or not isinstance(logical_path, str) + or not isinstance(sha256, str) + or SHA256_PATTERN.fullmatch(sha256) is None + or not isinstance(byte_length, int) + or isinstance(byte_length, bool) + or byte_length <= 0 + ): + raise GaussianPipelineGatewayError( + "Gaussian archive ingest member fields are invalid" + ) + members.append( + GaussianSourceMemberUpload( + upload_id=upload_id, + logical_path=_logical_path(logical_path, "bundle member path"), + sha256=sha256, + byte_length=byte_length, + ) + ) + ordered = tuple(sorted(members, key=lambda item: item.logical_path.encode("utf-8"))) + if len({member.logical_path for member in ordered}) != len(ordered): + raise GaussianPipelineGatewayError( + "Gaussian archive ingest contains duplicate members" + ) + if not any(member.logical_path == logical_entrypoint for member in ordered): + raise GaussianPipelineGatewayError( + "Gaussian archive ingest omitted its entrypoint" + ) + if sum(member.byte_length for member in ordered) != total_byte_length: + raise GaussianPipelineGatewayError( + "Gaussian archive ingest byte total is invalid" + ) + if _bundle_sha256(source_format, logical_entrypoint, ordered) != bundle_sha256: + raise GaussianPipelineGatewayError( + "Gaussian archive ingest bundle digest is invalid" + ) + return GaussianSourceBundleUpload( + format=source_format, + entrypoint=logical_entrypoint, + bundle_sha256=bundle_sha256, + total_byte_length=total_byte_length, + members=ordered, + ) + + def _discover_bundle_members(root: Path, entrypoint: str, source_format: str) -> set[str]: descriptor = _bundle_member(root, entrypoint) if descriptor.stat().st_size > 16 * 1024 * 1024: diff --git a/src/k1link/simulation/projects.py b/src/k1link/simulation/projects.py new file mode 100644 index 0000000..3223e6a --- /dev/null +++ b/src/k1link/simulation/projects.py @@ -0,0 +1,635 @@ +"""Durable Mission Core catalog and orchestration for portable Gaussian worlds.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import shutil +import threading +import time +from contextlib import suppress +from pathlib import Path +from typing import Any, Final +from urllib.parse import quote +from uuid import uuid4 + +from k1link.artifacts import utc_now_iso +from k1link.simulation.gaussian_pipeline_gateway import ( + BUILD_REQUEST_SCHEMA, + GaussianPipelineGateway, + GaussianPipelineGatewayError, + configured_gaussian_pipeline_gateway, + discover_gaussian_source_bundle, +) + +PROJECT_SCHEMA: Final = "missioncore.simulation-project/v1" +WORLD_MANIFEST_SCHEMA: Final = "missioncore.simulation-world-manifest/v1" +PROJECT_ID_PATTERN: Final = re.compile(r"^sim-[a-f0-9]{32}$") +SOURCE_FILE_ID_PATTERN: Final = re.compile(r"^src-[0-9]{5}-[a-f0-9]{8}$") +PROVIDER_JOB_ID_PATTERN: Final = re.compile(r"^gsp-[0-9]{14}-[a-f0-9]{8}$") +MAX_SOURCE_FILES: Final = 10_000 +MAX_SOURCE_BYTES: Final = 16 * 1024 * 1024 * 1024 +MAX_UPLOAD_CHUNK_BYTES: Final = 16 * 1024 * 1024 +TERMINAL_STATES: Final = {"ready", "failed"} +ACTIVE_STATES: Final = {"queued", "processing", "importing"} +PROVIDER_JOB_STATES: Final = { + "queued", + "verifying_source", + "inspecting", + "building_preview", + "building_streamed_sog", + "building_collision", + "ready", + "failed", +} +PROVIDER_POLL_TIMEOUT_SECONDS: Final = 2 * 60 * 60 + 5 * 60 + + +class SimulationProjectError(RuntimeError): + """The project request violates its durable catalog contract.""" + + +class SimulationProjectNotFoundError(SimulationProjectError): + """The requested project or source member does not exist.""" + + +class SimulationProjectConflictError(SimulationProjectError): + """The requested transition conflicts with current project state.""" + + +class SimulationProjectStore: + def __init__(self, data_dir: Path) -> None: + self.root = data_dir.expanduser().resolve() / "simulation-worlds" + self.projects_root = self.root / "projects" + self.projects_root.mkdir(mode=0o700, parents=True, exist_ok=True) + self._lock = threading.RLock() + + def create( + self, + *, + name: str, + scene_type: str, + source_kind: str, + files: list[dict[str, object]], + ) -> dict[str, Any]: + display_name = _project_name(name) + if scene_type not in {"interior", "outdoor", "object"}: + raise SimulationProjectError("simulation scene type is invalid") + if source_kind not in {"archive", "folder"}: + raise SimulationProjectError("simulation source kind is invalid") + if not files or len(files) > MAX_SOURCE_FILES: + raise SimulationProjectError("simulation source file count is outside limits") + normalized: list[dict[str, object]] = [] + logical_paths: set[str] = set() + total_bytes = 0 + for index, raw in enumerate(files): + if set(raw) != {"logical_path", "byte_length"}: + raise SimulationProjectError("simulation source file contract is invalid") + logical_path = _logical_path(raw.get("logical_path")) + byte_length = raw.get("byte_length") + if ( + not isinstance(byte_length, int) + or isinstance(byte_length, bool) + or byte_length <= 0 + or byte_length > MAX_SOURCE_BYTES + ): + raise SimulationProjectError("simulation source file size is invalid") + if logical_path in logical_paths: + raise SimulationProjectError("simulation source paths must be unique") + logical_paths.add(logical_path) + total_bytes += byte_length + normalized.append({ + "file_id": f"src-{index:05d}-{uuid4().hex[:8]}", + "logical_path": logical_path, + "byte_length": byte_length, + "uploaded_bytes": 0, + "sha256": None, + }) + if total_bytes > MAX_SOURCE_BYTES: + raise SimulationProjectError("simulation source exceeds byte admission") + if source_kind == "archive": + if len(normalized) != 1 or "/" in str(normalized[0]["logical_path"]): + raise SimulationProjectError("archive source must be one top-level file") + if Path(str(normalized[0]["logical_path"])).suffix.lower() not in { + ".zip", + ".rar", + ".7z", + }: + raise SimulationProjectError("archive source must be zip, rar or 7z") + project_id = f"sim-{uuid4().hex}" + now = utc_now_iso() + document: dict[str, Any] = { + "schema_version": PROJECT_SCHEMA, + "project_id": project_id, + "name": display_name, + "scene_type": scene_type, + "status": "uploading", + "source": { + "kind": source_kind, + "total_byte_length": total_bytes, + "uploaded_byte_length": 0, + "files": normalized, + "bundle_sha256": None, + }, + "provider": { + "provider_id": "gaussian-pipeline", + "job_id": None, + "state": None, + "progress": None, + "runtime": None, + }, + "artifacts": [], + "world_manifest": None, + "error": None, + "created_at_utc": now, + "updated_at_utc": now, + } + with self._lock: + project_root = self._project_root(project_id) + project_root.mkdir(mode=0o700, parents=False, exist_ok=False) + (project_root / "source").mkdir(mode=0o700) + (project_root / "artifacts").mkdir(mode=0o700) + self._write(document) + return document + + def list(self) -> list[dict[str, Any]]: + with self._lock: + projects: list[dict[str, Any]] = [] + for entry in self.projects_root.iterdir(): + if not entry.is_dir() or PROJECT_ID_PATTERN.fullmatch(entry.name) is None: + continue + projects.append(self._read(entry.name)) + projects.sort( + key=lambda item: (str(item["created_at_utc"]), str(item["project_id"])), + reverse=True, + ) + return projects + + def get(self, project_id: str) -> dict[str, Any]: + with self._lock: + return self._read(project_id) + + def update_metadata( + self, + project_id: str, + *, + name: str, + scene_type: str, + ) -> dict[str, Any]: + display_name = _project_name(name) + if scene_type not in {"interior", "outdoor", "object"}: + raise SimulationProjectError("simulation scene type is invalid") + with self._lock: + document = self._read(project_id) + document["name"] = display_name + document["scene_type"] = scene_type + document["updated_at_utc"] = utc_now_iso() + self._write(document) + return document + + def upload_state(self, project_id: str, file_id: str) -> tuple[int, int]: + with self._lock: + document = self._read(project_id) + source_file = _source_file(document, file_id) + return int(source_file["uploaded_bytes"]), int(source_file["byte_length"]) + + def append_upload( + self, + project_id: str, + file_id: str, + *, + offset: int, + payload: bytes, + ) -> dict[str, Any]: + if not payload or len(payload) > MAX_UPLOAD_CHUNK_BYTES: + raise SimulationProjectError("simulation upload chunk is outside limits") + with self._lock: + document = self._read(project_id) + if document["status"] != "uploading": + raise SimulationProjectConflictError("simulation source upload is closed") + source_file = _source_file(document, file_id) + uploaded = int(source_file["uploaded_bytes"]) + byte_length = int(source_file["byte_length"]) + if offset != uploaded: + raise SimulationProjectConflictError("simulation upload offset does not match") + if uploaded + len(payload) > byte_length: + raise SimulationProjectError("simulation upload exceeds declared file size") + target = self._source_path(project_id, str(source_file["logical_path"])) + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + mode = "xb" if uploaded == 0 else "r+b" + with target.open(mode) as stream: + if uploaded: + stream.seek(uploaded) + stream.write(payload) + stream.flush() + os.fsync(stream.fileno()) + uploaded += len(payload) + source_file["uploaded_bytes"] = uploaded + if uploaded == byte_length: + source_file["sha256"] = _sha256(target) + document["source"]["uploaded_byte_length"] = sum( + int(item["uploaded_bytes"]) for item in document["source"]["files"] + ) + document["updated_at_utc"] = utc_now_iso() + self._write(document) + return document + + def begin_build(self, project_id: str) -> dict[str, Any]: + with self._lock: + document = self._read(project_id) + if document["status"] != "uploading": + raise SimulationProjectConflictError("simulation project cannot start a build") + if any( + int(item["uploaded_bytes"]) != int(item["byte_length"]) + for item in document["source"]["files"] + ): + raise SimulationProjectConflictError("simulation source upload is incomplete") + document["status"] = "queued" + document["error"] = None + document["updated_at_utc"] = utc_now_iso() + self._write(document) + return document + + def update_processing( + self, + project_id: str, + *, + status: str, + provider_job_id: str | None = None, + provider_state: str | None = None, + progress: object = None, + bundle_sha256: str | None = None, + ) -> dict[str, Any]: + if status not in {"queued", "processing", "importing"}: + raise SimulationProjectError("simulation processing status is invalid") + with self._lock: + document = self._read(project_id) + document["status"] = status + if provider_job_id is not None: + if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None: + raise SimulationProjectError("simulation provider job id is invalid") + document["provider"]["job_id"] = provider_job_id + if provider_state is not None: + document["provider"]["state"] = provider_state + if progress is not None: + document["provider"]["progress"] = progress + if bundle_sha256 is not None: + document["source"]["bundle_sha256"] = bundle_sha256 + document["updated_at_utc"] = utc_now_iso() + self._write(document) + return document + + def complete( + self, + project_id: str, + *, + result: dict[str, Any], + artifacts: list[dict[str, Any]], + world_manifest: dict[str, Any], + ) -> dict[str, Any]: + with self._lock: + document = self._read(project_id) + document["status"] = "ready" + document["provider"]["state"] = "ready" + document["provider"]["runtime"] = result.get("runtime") + document["artifacts"] = artifacts + document["world_manifest"] = world_manifest + document["error"] = None + document["updated_at_utc"] = utc_now_iso() + self._write(document) + return document + + def fail(self, project_id: str, message: str) -> dict[str, Any]: + with self._lock: + document = self._read(project_id) + document["status"] = "failed" + document["error"] = message[:2048] + document["updated_at_utc"] = utc_now_iso() + self._write(document) + return document + + def artifact_path(self, project_id: str, logical_path: str) -> tuple[Path, dict[str, Any]]: + safe = _logical_path(logical_path) + with self._lock: + document = self._read(project_id) + descriptor = next( + (item for item in document["artifacts"] if item.get("logical_path") == safe), + None, + ) + if descriptor is None: + raise SimulationProjectNotFoundError("simulation artifact is unavailable") + target = self._artifact_path(project_id, safe) + if not target.is_file() or target.is_symlink(): + raise SimulationProjectNotFoundError("simulation artifact is unavailable") + return target, descriptor + + def delete(self, project_id: str) -> None: + with self._lock: + document = self._read(project_id) + if document["status"] not in TERMINAL_STATES and document["status"] != "uploading": + raise SimulationProjectConflictError( + "active simulation project cannot be deleted" + ) + shutil.rmtree(self._project_root(project_id)) + + def source_root(self, project_id: str) -> Path: + self.get(project_id) + return self._project_root(project_id) / "source" + + def artifacts_root(self, project_id: str) -> Path: + self.get(project_id) + return self._project_root(project_id) / "artifacts" + + def _read(self, project_id: str) -> dict[str, Any]: + project_root = self._project_root(project_id) + try: + document = json.loads((project_root / "project.json").read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise SimulationProjectNotFoundError("simulation project is unavailable") from exc + if ( + not isinstance(document, dict) + or document.get("schema_version") != PROJECT_SCHEMA + or document.get("project_id") != project_id + ): + raise SimulationProjectError("persisted simulation project identity is invalid") + return document + + def _write(self, document: dict[str, Any]) -> None: + project_id = str(document["project_id"]) + destination = self._project_root(project_id) / "project.json" + temporary = destination.with_name(f".project-{uuid4().hex}.tmp") + with temporary.open("x", encoding="utf-8") as stream: + json.dump(document, stream, ensure_ascii=False, indent=2) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + temporary.replace(destination) + + def _project_root(self, project_id: str) -> Path: + if PROJECT_ID_PATTERN.fullmatch(project_id) is None: + raise SimulationProjectNotFoundError("simulation project is unavailable") + return self.projects_root / project_id + + def _source_path(self, project_id: str, logical_path: str) -> Path: + return _confined_path(self._project_root(project_id) / "source", logical_path) + + def _artifact_path(self, project_id: str, logical_path: str) -> Path: + return _confined_path(self._project_root(project_id) / "artifacts", logical_path) + + +class SimulationProjectService: + def __init__( + self, + store: SimulationProjectStore, + provider_factory=configured_gaussian_pipeline_gateway, + ) -> None: + self.store = store + self.provider_factory = provider_factory + + def recover_pending(self) -> int: + pending = [ + project for project in self.store.list() + if project.get("status") in ACTIVE_STATES + ] + for project in pending: + threading.Thread( + target=self.process, + args=(str(project["project_id"]),), + name=f"simulation-recovery-{str(project['project_id'])[-8:]}", + daemon=True, + ).start() + return len(pending) + + def process(self, project_id: str) -> None: + provider: GaussianPipelineGateway | None = None + try: + project = self.store.get(project_id) + provider = self.provider_factory() + if provider is None: + raise SimulationProjectError("Gaussian Pipeline не настроен.") + provider.capabilities() + existing_job_id = project["provider"].get("job_id") + if isinstance(existing_job_id, str): + job_id = existing_job_id + else: + if project["status"] != "queued": + raise SimulationProjectError( + "Активная Gaussian-сборка потеряла provider job id после перезапуска." + ) + source_root = self.store.source_root(project_id) + if project["source"]["kind"] == "archive": + source_file = project["source"]["files"][0] + archive = provider.upload_source_archive( + _confined_path(source_root, str(source_file["logical_path"])) + ) + source = provider.normalize_archive(archive) + else: + entrypoint, source_format = discover_gaussian_source_bundle(source_root) + source = provider.upload_source_bundle( + source_root, + entrypoint=entrypoint, + source_format=source_format, + ) + request = { + "schema_version": BUILD_REQUEST_SCHEMA, + "idempotency_key": f"missioncore-{project_id}", + "source": source.to_dict(), + "outputs": { + "preview_sog": True, + "streamed_sog": True, + "collision": False, + }, + "preview_lod": "coarsest", + "collision_profile": None, + } + submitted = provider.submit_build(request) + job_id = submitted.get("job_id") + if not isinstance(job_id, str): + raise SimulationProjectError("Gaussian Pipeline не вернул job id.") + self.store.update_processing( + project_id, + status="processing", + provider_job_id=job_id, + provider_state=str(submitted.get("state") or "queued"), + progress=submitted.get("progress"), + bundle_sha256=source.bundle_sha256, + ) + deadline = time.monotonic() + PROVIDER_POLL_TIMEOUT_SECONDS + while True: + if time.monotonic() >= deadline: + raise SimulationProjectError( + "Gaussian Pipeline превысил лимит ожидания сборки." + ) + job = provider.get_job(job_id) + state = job.get("state") + if not isinstance(state, str) or state not in PROVIDER_JOB_STATES: + raise SimulationProjectError( + "Gaussian Pipeline вернул неизвестное состояние сборки." + ) + self.store.update_processing( + project_id, + status="processing", + provider_state=str(state or "unknown"), + progress=job.get("progress"), + ) + if state == "failed": + error = job.get("error") + message = error.get("message") if isinstance(error, dict) else None + raise SimulationProjectError( + str(message or "Gaussian Pipeline завершил сборку с ошибкой.") + ) + if state == "ready": + break + time.sleep(2.0) + self.store.update_processing( + project_id, + status="importing", + provider_state="ready", + ) + result = provider.get_result(job_id) + artifacts = _artifact_descriptors(result.get("artifacts")) + artifacts_root = self.store.artifacts_root(project_id) + for descriptor in artifacts: + provider.download_artifact( + job_id, + descriptor, + _confined_path(artifacts_root, str(descriptor["logical_path"])), + ) + world_manifest = _world_manifest(project_id, artifacts) + self.store.complete( + project_id, + result=result, + artifacts=artifacts, + world_manifest=world_manifest, + ) + except (GaussianPipelineGatewayError, SimulationProjectError, OSError) as exc: + with suppress(SimulationProjectError): + self.store.fail(project_id, str(exc)) + finally: + if provider is not None: + provider.close() + + def delete(self, project_id: str) -> None: + project = self.store.get(project_id) + job_id = project["provider"].get("job_id") + if isinstance(job_id, str): + provider = self.provider_factory() + if provider is None: + raise SimulationProjectConflictError( + "Gaussian Pipeline недоступен для удаления серверных артефактов." + ) + try: + provider.delete_job(job_id) + finally: + provider.close() + self.store.delete(project_id) + + +def _artifact_descriptors(value: object) -> list[dict[str, Any]]: + if not isinstance(value, list) or not value: + raise SimulationProjectError("Gaussian Pipeline не вернул артефакты сцены.") + descriptors: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + raise SimulationProjectError("Gaussian artifact descriptor is invalid") + logical_path = _logical_path(item.get("logical_path")) + sha256 = item.get("sha256") + byte_length = item.get("byte_length") + media_type = item.get("media_type") + role = item.get("role") + if ( + not isinstance(sha256, str) + or re.fullmatch(r"[a-f0-9]{64}", sha256) is None + or not isinstance(byte_length, int) + or isinstance(byte_length, bool) + or byte_length < 0 + or not isinstance(media_type, str) + or not isinstance(role, str) + ): + raise SimulationProjectError("Gaussian artifact fields are invalid") + descriptors.append({ + "role": role, + "logical_path": logical_path, + "media_type": media_type, + "sha256": sha256, + "byte_length": byte_length, + }) + return descriptors + + +def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[str, Any]: + def url_for(role: str) -> str | None: + descriptor = next((item for item in artifacts if item["role"] == role), None) + if descriptor is None: + return None + encoded = "/".join( + quote(part, safe="") + for part in str(descriptor["logical_path"]).split("/") + ) + return f"/api/v1/simulation-worlds/projects/{project_id}/artifacts/{encoded}" + + return { + "schema_version": WORLD_MANIFEST_SCHEMA, + "project_id": project_id, + "visual": { + "preview_sog_url": url_for("preview"), + "streamed_sog_url": url_for("stream-manifest"), + }, + "collision": { + "mesh_url": url_for("collision-mesh"), + "available": url_for("collision-mesh") is not None, + }, + "transforms": { + "world_from_visual": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + "world_from_collision": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + }, + } + + +def _project_name(value: str) -> str: + normalized = " ".join(value.split()) + if not 1 <= len(normalized) <= 120: + raise SimulationProjectError("simulation project name is invalid") + return normalized + + +def _logical_path(value: object) -> str: + if not isinstance(value, str) or not value or len(value) > 1024: + raise SimulationProjectError("simulation logical path is invalid") + if value.startswith("/") or "\\" in value or "\x00" in value: + raise SimulationProjectError("simulation logical path is unsafe") + parts = value.split("/") + if any(part in {"", ".", ".."} for part in parts): + raise SimulationProjectError("simulation logical path is unsafe") + return value + + +def _source_file(document: dict[str, Any], file_id: str) -> dict[str, Any]: + if SOURCE_FILE_ID_PATTERN.fullmatch(file_id) is None: + raise SimulationProjectNotFoundError("simulation source file is unavailable") + source_file = next( + (item for item in document["source"]["files"] if item.get("file_id") == file_id), + None, + ) + if source_file is None: + raise SimulationProjectNotFoundError("simulation source file is unavailable") + return source_file + + +def _confined_path(root: Path, logical_path: str) -> Path: + safe = _logical_path(logical_path) + candidate = root.joinpath(*safe.split("/")).resolve() + resolved_root = root.resolve() + if not candidate.is_relative_to(resolved_root): + raise SimulationProjectError("simulation path escaped its project root") + return candidate + + +def _sha256(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/src/k1link/web/app.py b/src/k1link/web/app.py index 4024e7d..53bc9d2 100644 --- a/src/k1link/web/app.py +++ b/src/k1link/web/app.py @@ -48,6 +48,7 @@ from k1link.sessions import ( SessionRecordingPreparationManager, SessionStore, ) +from k1link.simulation.projects import SimulationProjectService, SimulationProjectStore from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router from k1link.web.artifact_health_api import build_artifact_health_router from k1link.web.compute_contour_api import build_compute_contour_router @@ -153,6 +154,7 @@ from k1link.web.runtime_readiness import ( ) from k1link.web.session_api import build_session_router from k1link.web.simulation_world_provider_api import build_simulation_world_provider_router +from k1link.web.simulation_projects_api import build_simulation_projects_router from k1link.web.system_telemetry_api import build_system_telemetry_router from k1link.web.viewer_diagnostics_api import build_viewer_diagnostics_router @@ -201,6 +203,8 @@ plugin_environment = load_installed_device_plugins(REPOSITORY_ROOT) plugin_catalog: DevicePluginCatalog = plugin_environment.catalog plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher session_store = SessionStore(REPOSITORY_ROOT) +simulation_project_store = SimulationProjectStore(session_store.data_dir) +simulation_project_service = SimulationProjectService(simulation_project_store) session_artifact_gateway = configured_artifact_gateway(session_store.data_dir) lidar_local_surface_read_service = K1LocalSurfaceReadService( session_store.data_dir / "lidar-read-cache" @@ -478,6 +482,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]: try: configure_scanner_diagnostics(session_store.data_dir / "logs") session_recording_preparation_manager.start() + simulation_project_service.recover_pending() # Recovery is intentionally a one-shot startup phase. The archive # helper owns a cross-process lease, while ordinary catalog requests # only perform discovery and therefore never touch a live writer. @@ -1310,6 +1315,12 @@ app.include_router( ) ) app.include_router(build_simulation_world_provider_router()) +app.include_router( + build_simulation_projects_router( + store=simulation_project_store, + service=simulation_project_service, + ) +) frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist" app.include_router( build_viewer_diagnostics_router( diff --git a/src/k1link/web/simulation_projects_api.py b/src/k1link/web/simulation_projects_api.py new file mode 100644 index 0000000..e7ca746 --- /dev/null +++ b/src/k1link/web/simulation_projects_api.py @@ -0,0 +1,215 @@ +"""Mission Core API for durable Gaussian simulation projects.""" + +from __future__ import annotations + +from typing import Any, Literal + +from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request, Response +from fastapi.responses import FileResponse +from pydantic import BaseModel, ConfigDict, Field + +from k1link.simulation.gaussian_pipeline_gateway import GaussianPipelineGatewayError +from k1link.simulation.projects import ( + MAX_UPLOAD_CHUNK_BYTES, + PROJECT_SCHEMA, + SimulationProjectConflictError, + SimulationProjectError, + SimulationProjectNotFoundError, + SimulationProjectService, + SimulationProjectStore, +) + + +class SimulationSourceFileCreate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + logical_path: str = Field(min_length=1, max_length=1024) + byte_length: int = Field(gt=0) + + +class SimulationProjectCreate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["missioncore.simulation-project-create/v1"] + name: str = Field(min_length=1, max_length=120) + scene_type: Literal["interior", "outdoor", "object"] + source_kind: Literal["archive", "folder"] + files: list[SimulationSourceFileCreate] = Field(min_length=1, max_length=10_000) + + +class SimulationProjectUpdate(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["missioncore.simulation-project-update/v1"] + name: str = Field(min_length=1, max_length=120) + scene_type: Literal["interior", "outdoor", "object"] + + +class SimulationProjectDocument(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal["missioncore.simulation-project/v1"] = PROJECT_SCHEMA + project_id: str + name: str + scene_type: Literal["interior", "outdoor", "object"] + status: Literal["uploading", "queued", "processing", "importing", "ready", "failed"] + source: dict[str, Any] + provider: dict[str, Any] + artifacts: list[dict[str, Any]] + world_manifest: dict[str, Any] | None + error: str | None + created_at_utc: str + updated_at_utc: str + + +class SimulationProjectPage(BaseModel): + model_config = ConfigDict(extra="forbid", frozen=True) + + schema_version: Literal["missioncore.simulation-project-page/v1"] = ( + "missioncore.simulation-project-page/v1" + ) + projects: list[SimulationProjectDocument] + + +def build_simulation_projects_router( + *, + store: SimulationProjectStore, + service: SimulationProjectService, +) -> APIRouter: + router = APIRouter(prefix="/api/v1/simulation-worlds", tags=["simulation-worlds"]) + + @router.get("/projects", response_model=SimulationProjectPage) + def list_projects() -> SimulationProjectPage: + return SimulationProjectPage(projects=store.list()) + + @router.post("/projects", response_model=SimulationProjectDocument, status_code=201) + def create_project(request: SimulationProjectCreate) -> dict[str, Any]: + try: + return store.create( + name=request.name, + scene_type=request.scene_type, + source_kind=request.source_kind, + files=[item.model_dump() for item in request.files], + ) + except SimulationProjectError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.get("/projects/{project_id}", response_model=SimulationProjectDocument) + def get_project(project_id: str) -> dict[str, Any]: + try: + return store.get(project_id) + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc + + @router.patch("/projects/{project_id}", response_model=SimulationProjectDocument) + def update_project( + project_id: str, + request: SimulationProjectUpdate, + ) -> dict[str, Any]: + try: + return store.update_metadata( + project_id, + name=request.name, + scene_type=request.scene_type, + ) + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc + except SimulationProjectError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.head("/projects/{project_id}/source/{file_id}") + def source_upload_state(project_id: str, file_id: str) -> Response: + try: + offset, byte_length = store.upload_state(project_id, file_id) + return Response(status_code=204, headers={ + "Upload-Offset": str(offset), + "Upload-Length": str(byte_length), + "Cache-Control": "no-store", + }) + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc + + @router.patch("/projects/{project_id}/source/{file_id}") + async def append_source_upload( + project_id: str, + file_id: str, + request: Request, + upload_offset: str | None = Header(default=None, alias="Upload-Offset"), + ) -> Response: + if upload_offset is None or not upload_offset.isdigit(): + raise HTTPException(status_code=400, detail="Некорректное смещение загрузки.") + content_length = request.headers.get("content-length") + if ( + content_length is None + or not content_length.isdigit() + or not 0 < int(content_length) <= MAX_UPLOAD_CHUNK_BYTES + ): + raise HTTPException(status_code=413, detail="Некорректный размер блока загрузки.") + payload = await request.body() + if len(payload) != int(content_length): + raise HTTPException(status_code=400, detail="Неполный блок загрузки.") + try: + project = store.append_upload( + project_id, + file_id, + offset=int(upload_offset), + payload=payload, + ) + source_file = next( + item for item in project["source"]["files"] if item["file_id"] == file_id + ) + return Response(status_code=204, headers={ + "Upload-Offset": str(source_file["uploaded_bytes"]), + "Upload-Length": str(source_file["byte_length"]), + "Cache-Control": "no-store", + }) + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Исходный файл не найден.") from exc + except SimulationProjectConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except SimulationProjectError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + @router.post("/projects/{project_id}/build", response_model=SimulationProjectDocument) + def build_project( + project_id: str, + background_tasks: BackgroundTasks, + ) -> dict[str, Any]: + try: + project = store.begin_build(project_id) + background_tasks.add_task(service.process, project_id) + return project + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc + except SimulationProjectConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + + @router.delete("/projects/{project_id}", status_code=204) + def delete_project(project_id: str) -> Response: + try: + service.delete(project_id) + return Response(status_code=204, headers={"Cache-Control": "no-store"}) + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Проект симуляции не найден.") from exc + except SimulationProjectConflictError as exc: + raise HTTPException(status_code=409, detail=str(exc)) from exc + except GaussianPipelineGatewayError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + @router.get("/projects/{project_id}/artifacts/{logical_path:path}") + def get_artifact(project_id: str, logical_path: str) -> FileResponse: + try: + filename, descriptor = store.artifact_path(project_id, logical_path) + return FileResponse( + filename, + media_type=str(descriptor["media_type"]), + headers={ + "Cache-Control": "private, max-age=31536000, immutable", + "ETag": f'"sha256-{descriptor["sha256"]}"', + "X-Content-Type-Options": "nosniff", + }, + ) + except SimulationProjectNotFoundError as exc: + raise HTTPException(status_code=404, detail="Артефакт сцены не найден.") from exc + + return router diff --git a/tests/test_gaussian_pipeline_gateway.py b/tests/test_gaussian_pipeline_gateway.py index 40ca698..99f1f1c 100644 --- a/tests/test_gaussian_pipeline_gateway.py +++ b/tests/test_gaussian_pipeline_gateway.py @@ -38,6 +38,8 @@ def test_gateway_uploads_lcc_bundle_with_tus_and_reads_provider_contract(tmp_pat "api_version": "gaussian-pipeline.api/v1", "upload_protocol": "tus/1.0.0", "source_transport": "tus-bundle/v1", + "archive_transport": "tus-archive/v1", + "archive_formats": ["zip", "rar", "7z"], "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, "runtime": { "source_revision": "a" * 40, @@ -114,6 +116,92 @@ def test_gateway_uploads_lcc_bundle_with_tus_and_reads_provider_contract(tmp_pat assert descriptor.total_byte_length == sum(member.byte_length for member in descriptor.members) +def test_gateway_uploads_and_normalizes_archive_with_tus(tmp_path: Path) -> None: + archive_bytes = b"portable-archive" + archive_sha = hashlib.sha256(archive_bytes).hexdigest() + descriptor_bytes = b'{}' + descriptor_sha = hashlib.sha256(descriptor_bytes).hexdigest() + source_identity = { + "format": "lcc2", + "entrypoint": "result/scene.lcc2", + "members": [{ + "logical_path": "result/scene.lcc2", + "sha256": descriptor_sha, + "byte_length": len(descriptor_bytes), + }], + } + bundle_sha = hashlib.sha256( + json.dumps(source_identity, ensure_ascii=False, separators=(",", ":")).encode() + ).hexdigest() + received = bytearray() + + def handler(request: httpx.Request) -> httpx.Response: + if request.method == "GET" and request.url.path == "/v1/capabilities": + return httpx.Response(200, json={ + "schema_version": "gaussian-pipeline.capabilities/v1", + "service": "ndc-gaussian-pipeline", + "api_version": "gaussian-pipeline.api/v1", + "upload_protocol": "tus/1.0.0", + "source_transport": "tus-bundle/v1", + "archive_transport": "tus-archive/v1", + "archive_formats": ["zip", "rar", "7z"], + "runtime": { + "source_revision": "a" * 40, + "image_digest": f"sha256:{'b' * 64}", + }, + "max_source_bytes": 1024, + "max_source_files": 10_000, + }) + if request.method == "POST" and request.url.path == "/v1/uploads": + metadata = request.headers["upload-metadata"] + assert "archive_name " in metadata + assert "logical_path " not in metadata + return httpx.Response(201, headers={"Location": "/v1/uploads/archive-001"}) + if request.method == "HEAD" and request.url.path.endswith("archive-001"): + return httpx.Response(200, headers={"Upload-Offset": str(len(received))}) + if request.method == "PATCH" and request.url.path.endswith("archive-001"): + received.extend(request.content) + return httpx.Response(204, headers={"Upload-Offset": str(len(received))}) + if request.method == "POST" and request.url.path == "/v1/ingests": + submitted = json.loads(request.content) + assert submitted["archive"] == { + "upload_id": "archive-001", + "archive_name": "source.rar", + "format": "rar", + "sha256": archive_sha, + "byte_length": len(archive_bytes), + } + return httpx.Response(201, json={ + "schema_version": "gaussian-pipeline.archive-ingest/v1", + "ingest_id": "gsi-20260826000000-deadbeef", + "source": { + **source_identity, + "bundle_sha256": bundle_sha, + "total_byte_length": len(descriptor_bytes), + "members": [{ + "upload_id": "ing-member-001", + **source_identity["members"][0], + }], + }, + }) + return httpx.Response(404) + + archive = tmp_path / "source.rar" + archive.write_bytes(archive_bytes) + with GaussianPipelineGateway( + "http://gaussian.test", + _token_file(tmp_path), + chunk_bytes=5, + transport=httpx.MockTransport(handler), + ) as gateway: + uploaded = gateway.upload_source_archive(archive) + source = gateway.normalize_archive(uploaded) + + assert bytes(received) == archive_bytes + assert source.entrypoint == "result/scene.lcc2" + assert source.bundle_sha256 == bundle_sha + + def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None: bundle = tmp_path / "bundle" bundle.mkdir() @@ -171,6 +259,8 @@ def test_gateway_rejects_upload_location_outside_provider(tmp_path: Path) -> Non "api_version": "gaussian-pipeline.api/v1", "upload_protocol": "tus/1.0.0", "source_transport": "tus-bundle/v1", + "archive_transport": "tus-archive/v1", + "archive_formats": ["zip", "rar", "7z"], "runtime": { "source_revision": "a" * 40, "image_digest": f"sha256:{'b' * 64}", @@ -295,6 +385,8 @@ def test_gateway_rejects_capabilities_without_runtime_provenance(tmp_path: Path) "api_version": "gaussian-pipeline.api/v1", "upload_protocol": "tus/1.0.0", "source_transport": "tus-bundle/v1", + "archive_transport": "tus-archive/v1", + "archive_formats": ["zip", "rar", "7z"], }, ) diff --git a/tests/test_simulation_projects.py b/tests/test_simulation_projects.py new file mode 100644 index 0000000..5526b3a --- /dev/null +++ b/tests/test_simulation_projects.py @@ -0,0 +1,260 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from k1link.simulation.gaussian_pipeline_gateway import ( + GaussianSourceBundleUpload, + GaussianSourceMemberUpload, +) +from k1link.simulation.projects import ( + SimulationProjectConflictError, + SimulationProjectService, + SimulationProjectStore, +) +from k1link.web.simulation_projects_api import build_simulation_projects_router + + +def _folder_files() -> list[dict[str, object]]: + return [ + {"logical_path": "export/scene.lcc", "byte_length": 23}, + {"logical_path": "export/index.bin", "byte_length": 5}, + {"logical_path": "export/data.bin", "byte_length": 4}, + ] + + +def _upload_all(store: SimulationProjectStore, project: dict[str, Any]) -> None: + payloads = { + "export/scene.lcc": b'{"fileType":"Portable"}', + "export/index.bin": b"index", + "export/data.bin": b"data", + } + for source_file in project["source"]["files"]: + store.append_upload( + project["project_id"], + source_file["file_id"], + offset=0, + payload=payloads[source_file["logical_path"]], + ) + + +def test_store_persists_resumable_folder_upload_and_state_transitions(tmp_path: Path) -> None: + store = SimulationProjectStore(tmp_path) + project = store.create( + name=" Smolensky Boulevard ", + scene_type="outdoor", + source_kind="folder", + files=_folder_files(), + ) + assert project["name"] == "Smolensky Boulevard" + first = project["source"]["files"][0] + store.append_upload( + project["project_id"], + first["file_id"], + offset=0, + payload=b'{"fileType":', + ) + with pytest.raises(SimulationProjectConflictError, match="offset"): + store.append_upload( + project["project_id"], + first["file_id"], + offset=0, + payload=b'"Portable"}', + ) + store.append_upload( + project["project_id"], + first["file_id"], + offset=len(b'{"fileType":'), + payload=b'"Portable"}', + ) + for source_file, payload in zip( + project["source"]["files"][1:], + (b"index", b"data"), + strict=True, + ): + store.append_upload( + project["project_id"], + source_file["file_id"], + offset=0, + payload=payload, + ) + queued = store.begin_build(project["project_id"]) + assert queued["status"] == "queued" + with pytest.raises(SimulationProjectConflictError, match="active"): + store.delete(project["project_id"]) + + +class _ReadyProvider: + def __init__(self) -> None: + self.deleted: list[str] = [] + self.upload_calls = 0 + self.submit_calls = 0 + + def capabilities(self) -> dict[str, object]: + return {"outputs": ["preview.sog", "streamed-sog"]} + + def upload_source_bundle( + self, + _root: Path, + *, + entrypoint: str, + source_format: str, + ) -> GaussianSourceBundleUpload: + self.upload_calls += 1 + members = ( + GaussianSourceMemberUpload("upload-1", entrypoint, "a" * 64, 23), + GaussianSourceMemberUpload("upload-2", "export/data.bin", "b" * 64, 4), + GaussianSourceMemberUpload("upload-3", "export/index.bin", "c" * 64, 5), + ) + return GaussianSourceBundleUpload( + format=source_format, + entrypoint=entrypoint, + bundle_sha256="d" * 64, + total_byte_length=32, + members=members, + ) + + def submit_build(self, _document: dict[str, object]) -> dict[str, object]: + self.submit_calls += 1 + return { + "schema_version": "gaussian-pipeline.job/v1", + "job_id": "gsp-20260826000000-deadbeef", + "state": "queued", + } + + def get_job(self, _job_id: str) -> dict[str, object]: + return { + "schema_version": "gaussian-pipeline.job/v1", + "job_id": "gsp-20260826000000-deadbeef", + "state": "ready", + "progress": {"completed_steps": 4, "total_steps": 4}, + } + + def get_result(self, _job_id: str) -> dict[str, object]: + return { + "schema_version": "gaussian-pipeline.build-result/v1", + "job_id": "gsp-20260826000000-deadbeef", + "runtime": { + "source_revision": "e" * 40, + "image_digest": f"sha256:{'f' * 64}", + }, + "artifacts": [ + { + "role": "preview", + "logical_path": "preview.sog", + "media_type": "application/octet-stream", + "sha256": "1" * 64, + "byte_length": 7, + }, + { + "role": "stream-manifest", + "logical_path": "streamed/lod-meta.json", + "media_type": "application/json", + "sha256": "2" * 64, + "byte_length": 2, + }, + ], + } + + def download_artifact( + self, + _job_id: str, + descriptor: dict[str, object], + destination: Path, + ) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(b"preview" if descriptor["role"] == "preview" else b"{}") + return destination + + def delete_job(self, job_id: str) -> None: + self.deleted.append(job_id) + + def close(self) -> None: + return None + + +def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: Path) -> None: + store = SimulationProjectStore(tmp_path) + project = store.create( + name="MOJJOKER", + scene_type="interior", + source_kind="folder", + files=_folder_files(), + ) + _upload_all(store, project) + store.begin_build(project["project_id"]) + provider = _ReadyProvider() + service = SimulationProjectService(store, provider_factory=lambda: provider) # type: ignore[arg-type] + service.process(project["project_id"]) + + ready = store.get(project["project_id"]) + assert ready["status"] == "ready" + assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog") + assert ready["world_manifest"]["collision"]["available"] is False + assert provider.upload_calls == 1 + assert provider.submit_calls == 1 + service.delete(project["project_id"]) + assert provider.deleted == ["gsp-20260826000000-deadbeef"] + + +def test_service_resumes_a_persisted_provider_job_without_reupload(tmp_path: Path) -> None: + store = SimulationProjectStore(tmp_path) + project = store.create( + name="Restart-safe scene", + scene_type="outdoor", + source_kind="folder", + files=_folder_files(), + ) + _upload_all(store, project) + store.begin_build(project["project_id"]) + store.update_processing( + project["project_id"], + status="processing", + provider_job_id="gsp-20260826000000-deadbeef", + provider_state="building_streamed_sog", + bundle_sha256="d" * 64, + ) + provider = _ReadyProvider() + service = SimulationProjectService(store, provider_factory=lambda: provider) # type: ignore[arg-type] + + service.process(project["project_id"]) + + assert store.get(project["project_id"])["status"] == "ready" + assert provider.upload_calls == 0 + assert provider.submit_calls == 0 + + +def test_api_exposes_same_origin_resumable_upload_contract(tmp_path: Path) -> None: + store = SimulationProjectStore(tmp_path) + service = SimulationProjectService(store, provider_factory=lambda: None) + app = FastAPI() + app.include_router(build_simulation_projects_router(store=store, service=service)) + client = TestClient(app) + created = client.post("/api/v1/simulation-worlds/projects", json={ + "schema_version": "missioncore.simulation-project-create/v1", + "name": "Archive scene", + "scene_type": "outdoor", + "source_kind": "archive", + "files": [{"logical_path": "scan.rar", "byte_length": 6}], + }) + assert created.status_code == 201 + project = created.json() + source_file = project["source"]["files"][0] + upload_url = ( + f"/api/v1/simulation-worlds/projects/{project['project_id']}" + f"/source/{source_file['file_id']}" + ) + assert client.head(upload_url).headers["upload-offset"] == "0" + first = client.patch(upload_url, headers={"Upload-Offset": "0"}, content=b"abc") + assert first.status_code == 204 + assert first.headers["upload-offset"] == "3" + mismatch = client.patch(upload_url, headers={"Upload-Offset": "0"}, content=b"def") + assert mismatch.status_code == 409 + second = client.patch(upload_url, headers={"Upload-Offset": "3"}, content=b"def") + assert second.status_code == 204 + catalog = client.get("/api/v1/simulation-worlds/projects").json() + assert catalog["projects"][0]["project_id"] == project["project_id"] diff --git a/tests/test_simulation_world_provider_api.py b/tests/test_simulation_world_provider_api.py index 72ecc9d..314a0e5 100644 --- a/tests/test_simulation_world_provider_api.py +++ b/tests/test_simulation_world_provider_api.py @@ -25,6 +25,8 @@ def _gateway(tmp_path: Path, status: int = 200) -> GaussianPipelineGateway: "api_version": "gaussian-pipeline.api/v1", "upload_protocol": "tus/1.0.0", "source_transport": "tus-bundle/v1", + "archive_transport": "tus-archive/v1", + "archive_formats": ["zip", "rar", "7z"], "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, "runtime": { "source_revision": "a" * 40,