feat: add Gaussian simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 02:09:44 +03:00
parent b111406cf8
commit d943ec853c
22 changed files with 3422 additions and 17 deletions
+20 -1
View File
@@ -16,6 +16,7 @@
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1", "@rerun-io/web-viewer": "0.34.1",
"playcanvas": "2.21.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"three": "0.185.1" "three": "0.185.1"
@@ -1375,7 +1376,6 @@
"version": "0.5.24", "version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@vitejs/plugin-react": { "node_modules/@vitejs/plugin-react": {
@@ -1399,6 +1399,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.43", "version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", "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" "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": { "node_modules/postcss": {
"version": "8.5.19", "version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core", "@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react", "@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1", "@rerun-io/web-viewer": "0.34.1",
"playcanvas": "2.21.4",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"three": "0.185.1" "three": "0.185.1"
@@ -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<void>;
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
setViewMode(mode: SimulationViewMode): void;
setQuality(quality: SimulationQuality): void;
focusBounds(): void;
dispose(): void;
}
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus">;
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<void> {
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<void> {
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<void>((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<SimulationQuality, [number, number]> = {
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();
}
@@ -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<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState("");
const [sceneType, setSceneType] = useState<SimulationSceneType>("outdoor");
const [candidates, setCandidates] = useState<SimulationUploadCandidate[]>([]);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState(false);
const [progress, setProgress] = useState<SimulationUploadProgress | null>(null);
const [error, setError] = useState<string | null>(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<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
try {
acceptCandidates(await candidatesFromDrop(event.dataTransfer));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось прочитать источник.");
}
};
return (
<Window
open={open}
title={project ? "Редактировать проект" : "Новый проект симуляции"}
subtitle={project ? "Метаданные сцены" : "LCC/LCC2 folder или ZIP/RAR/7z"}
size="md"
closeOnBackdrop={!pending}
closeOnEscape={!pending}
onClose={onClose}
footer={(
<>
<span className="simulation-project-window__footer-state">
{pending ? <><ActivityIndicator /><span>{project ? "Сохраняем" : "Загружаем источник"}</span></> : null}
</span>
<WindowFooterActions>
<Button disabled={pending} onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
{project ? "Сохранить" : "Создать и запустить"}
</Button>
</WindowFooterActions>
</>
)}
>
<div className="simulation-project-window">
<TextField
label="Название"
value={name}
maxLength={120}
disabled={pending}
autoComplete="off"
onChange={(event) => setName(event.currentTarget.value)}
/>
<FieldFrame label="Тип сцены" description="Сохраняется как профиль будущей физики и навигации.">
<Select
label="Тип сцены"
value={sceneType}
disabled={pending}
onChange={(value) => setSceneType(value)}
options={[
{ value: "outdoor", label: "Улица" },
{ value: "interior", label: "Интерьер" },
{ value: "object", label: "Отдельный объект" },
]}
/>
</FieldFrame>
{!project ? (
<FieldFrame
label="Исходник"
description="Имя родительской папки не фиксировано. Внутри должна быть ровно одна сцена LCC или LCC2."
>
<div
className="simulation-source-drop"
data-dragging={dragging ? "true" : undefined}
onDragEnter={(event) => { event.preventDefault(); setDragging(true); }}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
}}
onDrop={(event) => void handleDrop(event)}
>
<Icon name={candidates.length ? "check" : "upload"} size={20} />
<strong>{sourceSummary.title}</strong>
<span>{sourceSummary.detail}</span>
<div>
<Button
size="compact"
icon={<Icon name="file" size={16} />}
disabled={pending}
onClick={() => archiveInput.current?.click()}
>
Выбрать архив
</Button>
<Button
size="compact"
icon={<Icon name="folder" size={16} />}
disabled={pending}
onClick={() => folderInput.current?.click()}
>
Выбрать папку
</Button>
</div>
<input
ref={archiveInput}
type="file"
accept=".zip,.rar,.7z"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) acceptCandidates([{ file, logicalPath: file.name }]);
event.currentTarget.value = "";
}}
/>
<input
ref={folderInput}
type="file"
hidden
multiple
{...({ webkitdirectory: "" } as Record<string, string>)}
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 = "";
}}
/>
</div>
</FieldFrame>
) : null}
{progress ? (
<div className="simulation-upload-progress" role="status">
<span><i style={{ width: `${Math.min(100, progress.totalBytes ? progress.uploadedBytes / progress.totalBytes * 100 : 0)}%` }} /></span>
<div>
<strong>{formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)}</strong>
<small>{progress.currentPath}</small>
</div>
</div>
) : null}
{error ? <p className="simulation-project-window__error" role="alert">{error}</p> : null}
</div>
</Window>
);
}
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<SimulationUploadCandidate[]> {
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<void> {
if (entry.isFile && entry.file) {
const file = await new Promise<File>((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<LegacyFileEntry[]>((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<string>();
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 })} ГБ`;
}
@@ -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<HTMLCanvasElement>(null);
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
const [quality, setQuality] = useState<SimulationQuality>("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 (
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
<header className="simulation-viewport__toolbar">
<div>
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
</StatusBadge>
<span>PlayCanvas Engine 2.21.4</span>
</div>
<SegmentedControl
label="Слой сцены"
value={viewMode}
onChange={(next) => {
setViewMode(next);
runtimeRef.current?.setViewMode(next);
}}
items={[
{ value: "visual", label: "Визуал" },
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
]}
/>
<SegmentedControl
label="Качество Streamed SOG"
value={quality}
onChange={(next) => {
setQuality(next);
runtimeRef.current?.setQuality(next);
}}
items={[
{ value: "auto", label: "Auto" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Med" },
{ value: "high", label: "High" },
]}
/>
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.focusBounds()}>
Вписать сцену
</Button>
</header>
<div className="simulation-viewport__stage">
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
{state !== "ready" ? (
<div className="simulation-viewport__state" role={state === "failed" ? "alert" : "status"}>
{state === "failed" ? null : <ActivityIndicator label="Загрузка сцены" />}
<strong>{state === "failed" ? "Сцена не открылась" : "PlayCanvas загружает мир"}</strong>
<p>{error ?? "Streamed SOG догружается по мере готовности runtime."}</p>
</div>
) : null}
</div>
{!collisionAvailable ? (
<footer className="simulation-viewport__notice">
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
<span>Worker 006 сейчас работает в CPU-режиме; визуальный слой настоящий, collision GLB не подменяется.</span>
</footer>
) : null}
</section>
);
}
@@ -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<SimulationProject[]> {
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<SimulationProject> {
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<number> {
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<number> {
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<SimulationProject> {
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<void> {
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<unknown> {
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<string, any> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный ${label}.`);
}
return value as Record<string, any>;
}
function exactKeys(record: Record<string, any>, 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}.`);
}
+13 -1
View File
@@ -22,7 +22,8 @@ export type WorkspaceKind =
| "network-monitor" | "network-monitor"
| "datasets" | "datasets"
| "artifact-health" | "artifact-health"
| "lab-archive"; | "lab-archive"
| "simulations";
export type CapabilityStatus = "active" | "ready" | "contract" | "later"; export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -154,6 +155,17 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "lab-archive", kind: "lab-archive",
groups: [], groups: [],
}, },
{
id: "simulations",
root: "polygon",
label: "Симуляции",
title: "Симуляции",
eyebrow: "ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ",
description: "Каталог Gaussian-миров, переносимые сборки и интерактивные PlayCanvas-сцены.",
icon: "globe",
kind: "simulations",
groups: [],
},
{ {
id: "contour-health", id: "contour-health",
root: "fleet", root: "fleet",
+1
View File
@@ -10,6 +10,7 @@
@import "./styles/l3-pointpillars-visual-audit.css"; @import "./styles/l3-pointpillars-visual-audit.css";
@import "./styles/l34-annotation.css"; @import "./styles/l34-annotation.css";
@import "./styles/laboratory-reporting.css"; @import "./styles/laboratory-reporting.css";
@import "./styles/simulation.css";
@import "./styles/laboratory-evidence-report.css"; @import "./styles/laboratory-evidence-report.css";
@import "./styles/e34-temporal-layer.css"; @import "./styles/e34-temporal-layer.css";
@import "./styles/m4-replay-threat.css"; @import "./styles/m4-replay-threat.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;
}
}
+14
View File
@@ -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;
}
}
@@ -1,10 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
import { import {
ObservationMedia, ObservationMedia,
ObservationSourcePicker, ObservationSourcePicker,
@@ -41,6 +36,7 @@ import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace"; import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
import { ContourHealthWorkspace } from "./ContourHealthWorkspace"; import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace"; import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace"; import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace"; import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace"; import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
@@ -1193,6 +1189,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
SpatialView={SpatialWorkspace} SpatialView={SpatialWorkspace}
/> />
); );
case "simulations":
return <SimulationWorkspace />;
case "device": case "device":
return null; return null;
} }
@@ -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<SimulationProjectStatus>(["queued", "processing", "importing"]);
export function SimulationWorkspace() {
const [projects, setProjects] = useState<SimulationProject[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [windowOpen, setWindowOpen] = useState(false);
const [editing, setEditing] = useState<SimulationProject | null>(null);
const [deleting, setDeleting] = useState<SimulationProject | null>(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 (
<div className="simulation-workspace simulation-workspace--scene">
<header className="simulation-workspace__scene-head">
<Button
size="compact"
variant="ghost"
icon={<Icon name="chevron-left" size={16} />}
onClick={() => setSelectedId(null)}
>
Все проекты
</Button>
<div>
<span className="section-eyebrow">LAB / SIMULATION RUNTIME</span>
<h2>{selected.name}</h2>
</div>
<StatusBadge tone={statusPresentation(selected.status).tone}>
{statusPresentation(selected.status).label}
</StatusBadge>
</header>
{selected.status === "ready" && selected.worldManifest ? (
<SimulationViewport project={selected} />
) : (
<GlassSurface className="simulation-workspace__processing" padding="lg">
{selected.status === "failed" ? <Icon name="alert" size={20} /> : <ActivityIndicator label="Сборка мира" />}
<div>
<strong>{selected.status === "failed" ? "Сборка остановлена" : "Worker 006 собирает мир"}</strong>
<p>{selected.error ?? processingMessage(selected)}</p>
</div>
<StatusBadge tone={selected.status === "failed" ? "warning" : "accent"}>
{selected.provider.state ?? selected.status}
</StatusBadge>
</GlassSurface>
)}
</div>
);
}
return (
<div className="simulation-workspace">
<header className="simulation-workspace__head">
<div>
<span className="section-eyebrow">ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ</span>
<h2>Gaussian-миры</h2>
<p>Каталог исходников, сборок Worker 006 и полнофункциональных PlayCanvas-сцен.</p>
</div>
<Button variant="primary" icon={<Icon name="plus" size={16} />} onClick={openCreate}>
Новый проект
</Button>
</header>
<section className="simulation-catalog" aria-label="Проекты симуляции">
<header className="simulation-catalog__summary">
<div><span>Проектов</span><strong>{projects.length.toLocaleString("ru-RU")}</strong></div>
<div><span>Готово</span><strong>{projects.filter((project) => project.status === "ready").length}</strong></div>
<div><span>В работе</span><strong>{projects.filter((project) => ACTIVE_STATUSES.has(project.status)).length}</strong></div>
<div><span>Исходники</span><strong>{formatBytes(projects.reduce((sum, project) => sum + project.source.totalByteLength, 0))}</strong></div>
</header>
{loading ? (
<div className="simulation-catalog__state" role="status">
<ActivityIndicator label="Загрузка каталога" />
<span>Читаем каталог сцен</span>
</div>
) : error ? (
<div className="simulation-catalog__state" role="alert">
<Icon name="alert" size={20} />
<div><strong>Каталог недоступен</strong><span>{error}</span></div>
<Button size="compact" onClick={() => void load()}>Повторить</Button>
</div>
) : projects.length === 0 ? (
<div className="simulation-catalog__empty">
<span><Icon name="globe" size={20} /></span>
<strong>Проектов пока нет</strong>
<p>Создайте первый мир из папки LCC/LCC2 или архива ZIP, RAR, 7z.</p>
<Button variant="primary" icon={<Icon name="upload" size={16} />} onClick={openCreate}>
Загрузить исходник
</Button>
</div>
) : (
<div className="simulation-catalog__table-wrap">
<table className="simulation-catalog__table">
<thead>
<tr>
<th>Проект</th>
<th>Источник</th>
<th>Размер</th>
<th>Сплаты</th>
<th>Статус</th>
<th>Обновлён</th>
<th aria-label="Действия" />
</tr>
</thead>
<tbody>
{projects.map((project) => {
const status = statusPresentation(project.status);
return (
<tr key={project.projectId} data-status={project.status}>
<td>
<button type="button" onClick={() => setSelectedId(project.projectId)}>
<span className="simulation-catalog__project-icon"><Icon name="globe" size={18} /></span>
<span><strong>{project.name}</strong><small>{sceneTypeLabel(project.sceneType)}</small></span>
</button>
</td>
<td>{project.source.kind === "archive" ? "Архив" : "Папка"}<small>{project.source.files.length} файл(ов)</small></td>
<td>{formatBytes(project.source.totalByteLength)}</td>
<td></td>
<td><StatusBadge tone={status.tone}>{status.label}</StatusBadge></td>
<td>{formatDate(project.updatedAtUtc)}</td>
<td>
<div className="simulation-catalog__actions">
<IconButton label={`Удалить ${project.name}`} disabled={ACTIVE_STATUSES.has(project.status)} onClick={() => setDeleting(project)}>
<Icon name="trash" size={16} />
</IconButton>
<IconButton label={`Редактировать ${project.name}`} onClick={() => openEdit(project)}>
<Icon name="edit" size={16} />
</IconButton>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</section>
<SimulationProjectWindow
open={windowOpen}
project={editing}
onClose={() => { if (!windowOpen) return; setWindowOpen(false); setEditing(null); }}
onSaved={acceptSaved}
/>
<ConfirmationModal
open={deleting !== null}
title="Удалить проект симуляции?"
description={(
<p>
Проект <strong>{deleting?.name}</strong>, локальные производные артефакты и job на Worker 006 будут удалены.
</p>
)}
confirmLabel="Удалить проект"
pendingLabel="Удаляем…"
danger
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
/>
</div>
);
}
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 })} ГБ`;
}
@@ -324,7 +324,7 @@ test("Polygon exposes one dataset surface and keeps legacy links compatible", ()
assert.equal(workspaceById("datasets").kind, "datasets"); assert.equal(workspaceById("datasets").kind, "datasets");
assert.deepEqual( assert.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id), workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive"], ["lab-archive", "simulations"],
); );
assert.equal( assert.equal(
workspacesForRoot("system").some(({ id }) => id === "polygon-run"), workspacesForRoot("system").some(({ id }) => id === "polygon-run"),
@@ -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]*<SimulationWorkspace \/>/);
assert.match(workspace, /simulation-catalog__table/);
assert.match(workspace, /<SimulationViewport project=\{selected\}/);
assert.match(workspace, /<ConfirmationModal/);
assert.match(styles, /styles\/simulation\.css/);
});
test("browser upload remains same-origin, resumable and token-free", async () => {
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|<GSplat/);
assert.match(viewport, /Collision недоступен/);
});
+99
View File
@@ -0,0 +1,99 @@
# Simulation Worlds — product-surface brief
Status: accepted for the first working vertical slice on 2026-08-26.
## Job story
When an operator has a computed XGRIDS export, they need to create a named simulation project from
one LCC/LCC2 folder or one ZIP/RAR/7z archive, observe the real Worker 006 build state, and open the
result as an interactive PlayCanvas scene. The scene is a future simulation runtime, not a terminal
Gaussian viewer: vehicles, scripts, sensors, physics and debug layers must be able to join the same
`pc.Application` later without replacing the product surface.
## Placement decision
The explicit product decision is `Тестировочный контур → Симуляции`, as a sibling of
`Лабораторные контуры`.
Alternatives considered:
1. Extend `Лабораторные контуры`. Rejected because that surface owns reproducible experiment
evidence and selectors, while a simulation world has its own project, upload, build and deletion
lifecycle.
2. Add a dedicated workspace under `Тестировочный контур`. Selected because it preserves the Lab
root while allowing table-first catalog and scene-first runtime compositions.
3. Place the feature under `Система`. Rejected because Worker/provider health is a system concern,
but creating and operating worlds is an operator task.
The workspace is a real `productModel` entry. It is not an internal route, iframe or generic
capability placeholder.
## Composition and component ownership
The catalog is table-first: project identity, source kind, byte size, splat count when actually
available, build status, updated time, edit and delete. A selected ready project switches to a
scene-first composition with the PlayCanvas canvas and bounded runtime controls.
Canonical Design Guideline entities are reused for buttons, icon buttons, status badges, fields,
selects, segmented controls, confirmation, window, activity and glass surfaces. The semantic
project table, upload-source target, progress row and PlayCanvas stage are feature-owned renderers;
they are not introduced as parallel generic UI primitives.
## State and ownership boundaries
```text
React workspace
├─ project catalog and modal state
├─ same-origin resumable upload
└─ selected immutable WorldManifest
└─ PlayCanvasRuntime
└─ pc.Application / realtime entity graph
Mission Core backend
├─ durable project metadata and source staging
├─ provider orchestration and artifact import
└─ same-origin immutable artifact delivery
DC Gaussian Pipeline
├─ TUS bundle or archive admission
├─ secure ZIP/RAR/7z normalization
└─ SplatTransform build on Worker 006
```
The browser never receives the Worker token. Mission Core does not embed archive-format behavior
in its product domain. The portable pipeline normalizes archives into the same immutable LCC/LCC2
bundle contract used by folder uploads.
## Runtime decision
Mission Core pins the direct `playcanvas` package. `PlayCanvasRuntime` owns one `pc.Application`,
camera controls, GSplat asset lifecycle and later physical/debug entities. React owns no per-frame
entity state. A new world is data and does not require a new frontend build.
The first Worker 006 provider is CPU-only and therefore does not advertise collision outputs. The
UI disables collision/combined modes and states this explicitly; it does not synthesize a proxy.
The manifest already keeps visual and collision URLs and their independent world transforms so the
GPU collision build can be admitted without changing the project surface.
Primary implementation references:
- [PlayCanvas Engine](https://github.com/playcanvas/engine)
- [GSplatComponent API](https://api.playcanvas.com/engine/classes/GSplatComponent.html)
- [Streamed SOG runtime](https://developer.playcanvas.com/user-manual/gaussian-splatting/building/lod-streaming/)
- [SplatTransform](https://developer.playcanvas.com/user-manual/splat-transform/)
- [SplatTransform Docker](https://developer.playcanvas.com/user-manual/splat-transform/docker/)
- [SplatTransform collision](https://developer.playcanvas.com/user-manual/splat-transform/collision/)
## First-slice acceptance
- archive or folder selection does not depend on the parent folder name;
- folder input contains exactly one `.lcc` or `.lcc2` descriptor;
- ZIP, RAR and 7z are uploaded resumably and normalized only inside the pipeline container;
- encrypted, linked, traversing, duplicate and over-limit archive entries fail closed;
- project status is durable and reflects provider state without fabricated percentages;
- ready artifacts are imported digest-bound and served from Mission Core same-origin URLs;
- edit changes project metadata; delete removes both the Mission Core project and terminal provider
job;
- a ready project mounts direct PlayCanvas Engine and loads Streamed SOG with preview fallback;
- visual, collision and combined remain distinct runtime modes even when collision is not yet
available on the CPU provider.
@@ -20,6 +20,8 @@ BUILD_REQUEST_SCHEMA: Final = "gaussian-pipeline.build-request/v1"
JOB_SCHEMA: Final = "gaussian-pipeline.job/v1" JOB_SCHEMA: Final = "gaussian-pipeline.job/v1"
RESULT_SCHEMA: Final = "gaussian-pipeline.build-result/v1" RESULT_SCHEMA: Final = "gaussian-pipeline.build-result/v1"
CAPABILITIES_SCHEMA: Final = "gaussian-pipeline.capabilities/v1" CAPABILITIES_SCHEMA: Final = "gaussian-pipeline.capabilities/v1"
ARCHIVE_INGEST_REQUEST_SCHEMA: Final = "gaussian-pipeline.archive-ingest-request/v1"
ARCHIVE_INGEST_SCHEMA: Final = "gaussian-pipeline.archive-ingest/v1"
SAFE_UPLOAD_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") SAFE_UPLOAD_ID: Final = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$")
SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$") SHA256_PATTERN: Final = re.compile(r"^[a-f0-9]{64}$")
SOURCE_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$") SOURCE_REVISION_PATTERN: Final = re.compile(r"^[a-f0-9]{40}$")
@@ -79,6 +81,24 @@ class GaussianSourceBundleUpload:
} }
@dataclass(frozen=True, slots=True)
class GaussianArchiveUpload:
upload_id: str
archive_name: str
format: str
sha256: str
byte_length: int
def to_dict(self) -> 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: class GaussianPipelineGateway:
"""TUS and JSON client with bounded responses and independent digest checks.""" """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("api_version") != "gaussian-pipeline.api/v1"
or document.get("upload_protocol") != "tus/1.0.0" or document.get("upload_protocol") != "tus/1.0.0"
or document.get("source_transport") != "tus-bundle/v1" 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") raise GaussianPipelineGatewayError("Gaussian provider capabilities do not match v1")
_validate_runtime_provenance(document) _validate_runtime_provenance(document)
@@ -196,6 +218,66 @@ class GaussianPipelineGateway:
members=members, 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( def _upload_member(
self, self,
source: Path, source: Path,
@@ -204,9 +286,25 @@ class GaussianPipelineGateway:
byte_length: int, byte_length: int,
) -> GaussianSourceMemberUpload: ) -> GaussianSourceMemberUpload:
metadata = _tus_metadata( upload_id = self._upload_file(
{"logical_path": logical_path, "sha256": sha256} 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: try:
response = self._client.post( response = self._client.post(
"/v1/uploads", "/v1/uploads",
@@ -230,12 +328,7 @@ class GaussianPipelineGateway:
if SAFE_UPLOAD_ID.fullmatch(upload_id) is None: if SAFE_UPLOAD_ID.fullmatch(upload_id) is None:
raise GaussianPipelineGatewayError("Gaussian upload id is invalid") raise GaussianPipelineGatewayError("Gaussian upload id is invalid")
self._send_file(source, upload_url, byte_length) self._send_file(source, upload_url, byte_length)
return GaussianSourceMemberUpload( return upload_id
upload_id=upload_id,
logical_path=logical_path,
sha256=sha256,
byte_length=byte_length,
)
def submit_build(self, document: Mapping[str, object]) -> dict[str, Any]: def submit_build(self, document: Mapping[str, object]) -> dict[str, Any]:
if document.get("schema_version") != BUILD_REQUEST_SCHEMA: if document.get("schema_version") != BUILD_REQUEST_SCHEMA:
@@ -260,6 +353,14 @@ class GaussianPipelineGateway:
_validate_runtime_provenance(result) _validate_runtime_provenance(result)
return 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( def download_artifact(
self, self,
job_id: str, job_id: str,
@@ -408,6 +509,35 @@ def configured_gaussian_pipeline_gateway() -> GaussianPipelineGateway | None:
return GaussianPipelineGateway(endpoint, Path(token_file)) 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: def _endpoint(value: str) -> str:
parsed = urlparse(value) parsed = urlparse(value)
if ( if (
@@ -492,6 +622,101 @@ def _bundle_sha256(
return hashlib.sha256(canonical).hexdigest() 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]: def _discover_bundle_members(root: Path, entrypoint: str, source_format: str) -> set[str]:
descriptor = _bundle_member(root, entrypoint) descriptor = _bundle_member(root, entrypoint)
if descriptor.stat().st_size > 16 * 1024 * 1024: if descriptor.stat().st_size > 16 * 1024 * 1024:
+635
View File
@@ -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()
+11
View File
@@ -48,6 +48,7 @@ from k1link.sessions import (
SessionRecordingPreparationManager, SessionRecordingPreparationManager,
SessionStore, SessionStore,
) )
from k1link.simulation.projects import SimulationProjectService, SimulationProjectStore
from k1link.web.advanced_laboratory_api import build_advanced_laboratory_router 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.artifact_health_api import build_artifact_health_router
from k1link.web.compute_contour_api import build_compute_contour_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.session_api import build_session_router
from k1link.web.simulation_world_provider_api import build_simulation_world_provider_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.system_telemetry_api import build_system_telemetry_router
from k1link.web.viewer_diagnostics_api import build_viewer_diagnostics_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_catalog: DevicePluginCatalog = plugin_environment.catalog
plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher plugin_dispatcher: DevicePluginDispatcher = plugin_environment.dispatcher
session_store = SessionStore(REPOSITORY_ROOT) 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) session_artifact_gateway = configured_artifact_gateway(session_store.data_dir)
lidar_local_surface_read_service = K1LocalSurfaceReadService( lidar_local_surface_read_service = K1LocalSurfaceReadService(
session_store.data_dir / "lidar-read-cache" session_store.data_dir / "lidar-read-cache"
@@ -478,6 +482,7 @@ async def app_lifespan(_: FastAPI) -> AsyncIterator[None]:
try: try:
configure_scanner_diagnostics(session_store.data_dir / "logs") configure_scanner_diagnostics(session_store.data_dir / "logs")
session_recording_preparation_manager.start() session_recording_preparation_manager.start()
simulation_project_service.recover_pending()
# Recovery is intentionally a one-shot startup phase. The archive # Recovery is intentionally a one-shot startup phase. The archive
# helper owns a cross-process lease, while ordinary catalog requests # helper owns a cross-process lease, while ordinary catalog requests
# only perform discovery and therefore never touch a live writer. # 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_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" frontend_dist = REPOSITORY_ROOT / "apps" / "control-station" / "dist"
app.include_router( app.include_router(
build_viewer_diagnostics_router( build_viewer_diagnostics_router(
+215
View File
@@ -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
+92
View File
@@ -38,6 +38,8 @@ def test_gateway_uploads_lcc_bundle_with_tus_and_reads_provider_contract(tmp_pat
"api_version": "gaussian-pipeline.api/v1", "api_version": "gaussian-pipeline.api/v1",
"upload_protocol": "tus/1.0.0", "upload_protocol": "tus/1.0.0",
"source_transport": "tus-bundle/v1", "source_transport": "tus-bundle/v1",
"archive_transport": "tus-archive/v1",
"archive_formats": ["zip", "rar", "7z"],
"provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"},
"runtime": { "runtime": {
"source_revision": "a" * 40, "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) 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: def test_gateway_rejects_incomplete_lcc_bundle(tmp_path: Path) -> None:
bundle = tmp_path / "bundle" bundle = tmp_path / "bundle"
bundle.mkdir() bundle.mkdir()
@@ -171,6 +259,8 @@ def test_gateway_rejects_upload_location_outside_provider(tmp_path: Path) -> Non
"api_version": "gaussian-pipeline.api/v1", "api_version": "gaussian-pipeline.api/v1",
"upload_protocol": "tus/1.0.0", "upload_protocol": "tus/1.0.0",
"source_transport": "tus-bundle/v1", "source_transport": "tus-bundle/v1",
"archive_transport": "tus-archive/v1",
"archive_formats": ["zip", "rar", "7z"],
"runtime": { "runtime": {
"source_revision": "a" * 40, "source_revision": "a" * 40,
"image_digest": f"sha256:{'b' * 64}", "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", "api_version": "gaussian-pipeline.api/v1",
"upload_protocol": "tus/1.0.0", "upload_protocol": "tus/1.0.0",
"source_transport": "tus-bundle/v1", "source_transport": "tus-bundle/v1",
"archive_transport": "tus-archive/v1",
"archive_formats": ["zip", "rar", "7z"],
}, },
) )
+260
View File
@@ -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"]
@@ -25,6 +25,8 @@ def _gateway(tmp_path: Path, status: int = 200) -> GaussianPipelineGateway:
"api_version": "gaussian-pipeline.api/v1", "api_version": "gaussian-pipeline.api/v1",
"upload_protocol": "tus/1.0.0", "upload_protocol": "tus/1.0.0",
"source_transport": "tus-bundle/v1", "source_transport": "tus-bundle/v1",
"archive_transport": "tus-archive/v1",
"archive_formats": ["zip", "rar", "7z"],
"provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"}, "provider": {"id": "playcanvas-splat-transform", "version": "3.3.3"},
"runtime": { "runtime": {
"source_revision": "a" * 40, "source_revision": "a" * 40,