feat: add Gaussian simulation workspace
This commit is contained in:
Generated
+20
-1
@@ -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",
|
||||||
|
|||||||
@@ -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}.`);
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 недоступен/);
|
||||||
|
});
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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"],
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user