feat: add Gaussian simulation workspace

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 02:09:44 +03:00
parent b111406cf8
commit d943ec853c
22 changed files with 3422 additions and 17 deletions
+20 -1
View File
@@ -16,6 +16,7 @@
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"playcanvas": "2.21.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "0.185.1"
@@ -1375,7 +1376,6 @@
"version": "0.5.24",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
"integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
"dev": true,
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
@@ -1399,6 +1399,12 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@webgpu/types": {
"version": "0.1.72",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.72.tgz",
"integrity": "sha512-0cF7RFM2edNoiIS1ODJp0/Gzv4/xSXhwoR0YCza+OWpJWtn4wmo9DvK91aLlH9+uUnwIriP7ZiC3WitmyhuzBw==",
"license": "BSD-3-Clause"
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.43",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
@@ -1714,6 +1720,19 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playcanvas": {
"version": "2.21.4",
"resolved": "https://registry.npmjs.org/playcanvas/-/playcanvas-2.21.4.tgz",
"integrity": "sha512-L4UGy3z/YT8AaNBEY4ITsZup548UFMabF7loh0YQ0DRwQLdjIW8grmZPoQ1+B83+N6sejRS0/XlXV2Vfb11+fw==",
"license": "MIT",
"dependencies": {
"@types/webxr": "^0.5.24",
"@webgpu/types": "^0.1.70"
},
"engines": {
"node": ">=18.3.0"
}
},
"node_modules/postcss": {
"version": "8.5.19",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+1
View File
@@ -19,6 +19,7 @@
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"@rerun-io/web-viewer": "0.34.1",
"playcanvas": "2.21.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"three": "0.185.1"
@@ -0,0 +1,211 @@
import {
Application,
Asset,
Color,
Entity,
Vec2,
Vec3,
type ScriptType,
} from "playcanvas";
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
import type { SimulationWorldManifest } from "../../core/simulation/projects";
export type SimulationViewMode = "visual" | "collision" | "combined";
export type SimulationQuality = "auto" | "low" | "medium" | "high";
export interface SimulationRuntime {
mount(canvas: HTMLCanvasElement): Promise<void>;
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
setViewMode(mode: SimulationViewMode): void;
setQuality(quality: SimulationQuality): void;
focusBounds(): void;
dispose(): void;
}
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus">;
export class PlayCanvasRuntime implements SimulationRuntime {
private app: Application | null = null;
private canvas: HTMLCanvasElement | null = null;
private camera: Entity | null = null;
private cameraController: CameraController | null = null;
private visualEntity: Entity | null = null;
private visualAsset: Asset | null = null;
private resizeObserver: ResizeObserver | null = null;
private viewMode: SimulationViewMode = "visual";
private quality: SimulationQuality = "auto";
async mount(canvas: HTMLCanvasElement): Promise<void> {
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
this.canvas = canvas;
canvas.tabIndex = 0;
canvas.addEventListener("contextmenu", preventContextMenu);
const app = new Application(canvas, {
graphicsDeviceOptions: {
antialias: true,
alpha: false,
preserveDrawingBuffer: false,
powerPreference: "high-performance",
},
});
this.app = app;
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
const camera = new Entity("SimulationCamera");
camera.addComponent("camera", {
clearColor: new Color(0.025, 0.03, 0.04),
nearClip: 0.02,
farClip: 20_000,
fov: 58,
});
camera.setPosition(5, 3, 5);
camera.lookAt(0, 0, 0);
camera.addComponent("script");
const controller = camera.script?.create(CameraControls, {
properties: {
enableOrbit: true,
enableFly: true,
focusPoint: new Vec3(0, 0, 0),
zoomRange: new Vec2(0.05, 20_000),
},
}) as CameraController | null;
app.root.addChild(camera);
this.camera = camera;
this.cameraController = controller;
const light = new Entity("SimulationSun");
light.addComponent("light", {
type: "directional",
color: new Color(1, 0.97, 0.9),
intensity: 1.1,
castShadows: false,
});
light.setEulerAngles(42, 28, 0);
app.root.addChild(light);
this.resizeObserver = new ResizeObserver(() => this.resize());
this.resizeObserver.observe(canvas.parentElement ?? canvas);
this.resize();
app.start();
await Promise.resolve();
}
async loadWorld(manifest: SimulationWorldManifest): Promise<void> {
const app = this.requiredApp();
this.unloadWorld();
const sourceUrl = manifest.visual.streamedSogUrl ?? manifest.visual.previewSogUrl;
if (!sourceUrl) throw new Error("World manifest не содержит визуальный SOG.");
const asset = new Asset(`SimulationWorld:${manifest.projectId}`, "gsplat", { url: sourceUrl });
app.assets.add(asset);
this.visualAsset = asset;
await new Promise<void>((resolve, reject) => {
const ready = (loaded: Asset) => {
const visual = new Entity("GaussianWorld");
visual.enabled = false;
app.root.addChild(visual);
visual.addComponent("gsplat", {
asset: loaded,
unified: true,
});
visual.enabled = true;
this.visualEntity = visual;
this.applyQuality();
this.applyViewMode();
this.focusBounds();
resolve();
};
const failed = (error: unknown) => {
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил SOG."));
};
asset.ready(ready);
asset.once("error", failed);
app.assets.load(asset);
});
}
setViewMode(mode: SimulationViewMode): void {
this.viewMode = mode;
this.applyViewMode();
}
setQuality(quality: SimulationQuality): void {
this.quality = quality;
this.applyQuality();
}
focusBounds(): void {
const bounds = this.visualEntity?.gsplat?.customAabb;
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
const radius = bounds?.halfExtents.length() ?? 3;
const distance = Math.max(1, radius * 2.4);
const position = new Vec3(1, 0.55, 1).normalize().mulScalar(distance).add(focus);
if (this.cameraController) {
this.cameraController.reset(focus, position);
} else if (this.camera) {
this.camera.setPosition(position);
this.camera.lookAt(focus);
}
}
dispose(): void {
this.resizeObserver?.disconnect();
this.resizeObserver = null;
this.unloadWorld();
if (this.app) this.app.destroy();
if (this.canvas) this.canvas.removeEventListener("contextmenu", preventContextMenu);
this.app = null;
this.canvas = null;
this.camera = null;
this.cameraController = null;
}
private requiredApp(): Application {
if (!this.app) throw new Error("PlayCanvas runtime не смонтирован.");
return this.app;
}
private resize(): void {
if (!this.app || !this.canvas) return;
const bounds = this.canvas.parentElement?.getBoundingClientRect();
const width = Math.max(1, Math.round(bounds?.width ?? this.canvas.clientWidth));
const height = Math.max(1, Math.round(bounds?.height ?? this.canvas.clientHeight));
this.app.resizeCanvas(width, height);
}
private applyViewMode(): void {
if (this.visualEntity) {
this.visualEntity.enabled = this.viewMode !== "collision";
}
}
private applyQuality(): void {
const gsplat = this.visualEntity?.gsplat;
if (!gsplat) return;
const profiles: Record<SimulationQuality, [number, number]> = {
auto: [5, 2],
low: [2.5, 1.7],
medium: [5, 2],
high: [9, 2.3],
};
const [baseDistance, multiplier] = profiles[this.quality];
gsplat.lodBaseDistance = baseDistance;
gsplat.lodMultiplier = multiplier;
}
private unloadWorld(): void {
if (this.visualEntity) {
this.visualEntity.destroy();
this.visualEntity = null;
}
if (this.visualAsset && this.app) {
this.app.assets.remove(this.visualAsset);
this.visualAsset.unload();
this.visualAsset = null;
}
}
}
function preventContextMenu(event: Event): void {
event.preventDefault();
}
@@ -0,0 +1,324 @@
import { useEffect, useMemo, useRef, useState, type DragEvent } from "react";
import {
ActivityIndicator,
Button,
FieldFrame,
Icon,
Select,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
import {
createAndUploadSimulationProject,
updateSimulationProject,
type SimulationProject,
type SimulationSceneType,
type SimulationUploadCandidate,
type SimulationUploadProgress,
} from "../../core/simulation/projects";
export function SimulationProjectWindow({
open,
project,
onClose,
onSaved,
}: {
open: boolean;
project: SimulationProject | null;
onClose: () => void;
onSaved: (project: SimulationProject) => void;
}) {
const archiveInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const [name, setName] = useState("");
const [sceneType, setSceneType] = useState<SimulationSceneType>("outdoor");
const [candidates, setCandidates] = useState<SimulationUploadCandidate[]>([]);
const [dragging, setDragging] = useState(false);
const [pending, setPending] = useState(false);
const [progress, setProgress] = useState<SimulationUploadProgress | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setName(project?.name ?? "");
setSceneType(project?.sceneType ?? "outdoor");
setCandidates([]);
setProgress(null);
setPending(false);
setError(null);
}, [open, project]);
const sourceSummary = useMemo(() => summarizeCandidates(candidates), [candidates]);
const canSubmit = name.trim().length > 0 && (project !== null || candidates.length > 0) && !pending;
const acceptCandidates = (next: SimulationUploadCandidate[]) => {
try {
validateCandidates(next);
setCandidates(next);
setError(null);
if (!name.trim() && next.length) {
const named = next.find((candidate) => /\.lcc2?$/i.test(candidate.logicalPath)) ?? next[0];
const first = named?.logicalPath.split("/").filter(Boolean).at(-1) ?? "Новая сцена";
setName(first.replace(/\.(lcc2?|zip|rar|7z)$/i, ""));
}
} catch (caught) {
setCandidates([]);
setError(caught instanceof Error ? caught.message : "Источник не принят.");
}
};
const submit = async () => {
if (!canSubmit) return;
setPending(true);
setError(null);
try {
const saved = project
? await updateSimulationProject(project.projectId, name, sceneType)
: await createAndUploadSimulationProject(
name,
sceneType,
candidates,
setProgress,
);
onSaved(saved);
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось сохранить проект.");
setPending(false);
}
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
setDragging(false);
try {
acceptCandidates(await candidatesFromDrop(event.dataTransfer));
} catch (caught) {
setError(caught instanceof Error ? caught.message : "Не удалось прочитать источник.");
}
};
return (
<Window
open={open}
title={project ? "Редактировать проект" : "Новый проект симуляции"}
subtitle={project ? "Метаданные сцены" : "LCC/LCC2 folder или ZIP/RAR/7z"}
size="md"
closeOnBackdrop={!pending}
closeOnEscape={!pending}
onClose={onClose}
footer={(
<>
<span className="simulation-project-window__footer-state">
{pending ? <><ActivityIndicator /><span>{project ? "Сохраняем" : "Загружаем источник"}</span></> : null}
</span>
<WindowFooterActions>
<Button disabled={pending} onClick={onClose}>Отмена</Button>
<Button variant="primary" disabled={!canSubmit} onClick={() => void submit()}>
{project ? "Сохранить" : "Создать и запустить"}
</Button>
</WindowFooterActions>
</>
)}
>
<div className="simulation-project-window">
<TextField
label="Название"
value={name}
maxLength={120}
disabled={pending}
autoComplete="off"
onChange={(event) => setName(event.currentTarget.value)}
/>
<FieldFrame label="Тип сцены" description="Сохраняется как профиль будущей физики и навигации.">
<Select
label="Тип сцены"
value={sceneType}
disabled={pending}
onChange={(value) => setSceneType(value)}
options={[
{ value: "outdoor", label: "Улица" },
{ value: "interior", label: "Интерьер" },
{ value: "object", label: "Отдельный объект" },
]}
/>
</FieldFrame>
{!project ? (
<FieldFrame
label="Исходник"
description="Имя родительской папки не фиксировано. Внутри должна быть ровно одна сцена LCC или LCC2."
>
<div
className="simulation-source-drop"
data-dragging={dragging ? "true" : undefined}
onDragEnter={(event) => { event.preventDefault(); setDragging(true); }}
onDragOver={(event) => event.preventDefault()}
onDragLeave={(event) => {
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false);
}}
onDrop={(event) => void handleDrop(event)}
>
<Icon name={candidates.length ? "check" : "upload"} size={20} />
<strong>{sourceSummary.title}</strong>
<span>{sourceSummary.detail}</span>
<div>
<Button
size="compact"
icon={<Icon name="file" size={16} />}
disabled={pending}
onClick={() => archiveInput.current?.click()}
>
Выбрать архив
</Button>
<Button
size="compact"
icon={<Icon name="folder" size={16} />}
disabled={pending}
onClick={() => folderInput.current?.click()}
>
Выбрать папку
</Button>
</div>
<input
ref={archiveInput}
type="file"
accept=".zip,.rar,.7z"
hidden
onChange={(event) => {
const file = event.currentTarget.files?.[0];
if (file) acceptCandidates([{ file, logicalPath: file.name }]);
event.currentTarget.value = "";
}}
/>
<input
ref={folderInput}
type="file"
hidden
multiple
{...({ webkitdirectory: "" } as Record<string, string>)}
onChange={(event) => {
const next = Array.from(event.currentTarget.files ?? []).map((file) => ({
file,
logicalPath: file.webkitRelativePath || file.name,
}));
if (next.length) acceptCandidates(next);
event.currentTarget.value = "";
}}
/>
</div>
</FieldFrame>
) : null}
{progress ? (
<div className="simulation-upload-progress" role="status">
<span><i style={{ width: `${Math.min(100, progress.totalBytes ? progress.uploadedBytes / progress.totalBytes * 100 : 0)}%` }} /></span>
<div>
<strong>{formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)}</strong>
<small>{progress.currentPath}</small>
</div>
</div>
) : null}
{error ? <p className="simulation-project-window__error" role="alert">{error}</p> : null}
</div>
</Window>
);
}
interface LegacyFileEntry {
isFile: boolean;
isDirectory: boolean;
name: string;
fullPath: string;
file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void;
createReader?: () => {
readEntries: (
callback: (entries: LegacyFileEntry[]) => void,
error?: (error: DOMException) => void,
) => void;
};
}
async function candidatesFromDrop(transfer: DataTransfer): Promise<SimulationUploadCandidate[]> {
const candidates: SimulationUploadCandidate[] = [];
for (const item of Array.from(transfer.items)) {
const entry = (item as DataTransferItem & {
webkitGetAsEntry?: () => LegacyFileEntry | null;
}).webkitGetAsEntry?.();
if (entry) {
await collectEntry(entry, candidates);
continue;
}
const file = item.getAsFile();
if (file) candidates.push({ file, logicalPath: file.name });
}
if (!candidates.length) {
for (const file of Array.from(transfer.files)) {
candidates.push({ file, logicalPath: file.name });
}
}
return candidates;
}
async function collectEntry(entry: LegacyFileEntry, target: SimulationUploadCandidate[]): Promise<void> {
if (entry.isFile && entry.file) {
const file = await new Promise<File>((resolve, reject) => entry.file?.(resolve, reject));
target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name });
return;
}
if (!entry.isDirectory || !entry.createReader) return;
const reader = entry.createReader();
while (true) {
const batch = await new Promise<LegacyFileEntry[]>((resolve, reject) => reader.readEntries(resolve, reject));
if (!batch.length) break;
for (const child of batch) await collectEntry(child, target);
}
}
function validateCandidates(candidates: SimulationUploadCandidate[]): void {
if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2.");
const archives = candidates.filter((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath));
if (archives.length) {
if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом.");
return;
}
const descriptors = candidates.filter((candidate) => /\.lcc2?$/i.test(candidate.logicalPath));
if (descriptors.length !== 1) {
throw new Error("В папке должна быть ровно одна сцена .lcc или .lcc2.");
}
const paths = new Set<string>();
for (const candidate of candidates) {
const parts = candidate.logicalPath.split("/");
if (
!candidate.logicalPath ||
candidate.logicalPath.startsWith("/") ||
candidate.logicalPath.includes("\\") ||
candidate.logicalPath.includes("\0") ||
parts.some((part) => part === "" || part === "." || part === "..")
) {
throw new Error("Папка содержит небезопасный путь.");
}
if (candidate.file.size <= 0) throw new Error(`Пустой файл: ${candidate.logicalPath}`);
if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`);
paths.add(candidate.logicalPath);
}
}
function summarizeCandidates(candidates: SimulationUploadCandidate[]): { title: string; detail: string } {
if (!candidates.length) return {
title: "Перетащите архив или папку сюда",
detail: "ZIP, RAR, 7z либо parent folder с LCC/LCC2",
};
const total = candidates.reduce((sum, candidate) => sum + candidate.file.size, 0);
const archive = candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "");
return {
title: archive ? candidates[0]?.logicalPath ?? "Архив" : "Папка принята",
detail: `${candidates.length.toLocaleString("ru-RU")} файл(ов) · ${formatBytes(total)}`,
};
}
function formatBytes(value: number): string {
if (value < 1024) return `${value} Б`;
if (value < 1024 ** 2) return `${(value / 1024).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} КБ`;
if (value < 1024 ** 3) return `${(value / 1024 ** 2).toLocaleString("ru-RU", { maximumFractionDigits: 1 })} МБ`;
return `${(value / 1024 ** 3).toLocaleString("ru-RU", { maximumFractionDigits: 2 })} ГБ`;
}
@@ -0,0 +1,105 @@
import { useEffect, useRef, useState } from "react";
import { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
import type { SimulationProject } from "../../core/simulation/projects";
import {
PlayCanvasRuntime,
type SimulationQuality,
type SimulationViewMode,
} from "./PlayCanvasRuntime";
export function SimulationViewport({ project }: { project: SimulationProject }) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
const [quality, setQuality] = useState<SimulationQuality>("auto");
useEffect(() => {
const canvas = canvasRef.current;
const manifest = project.worldManifest;
if (!canvas || !manifest) return;
const runtime = new PlayCanvasRuntime();
runtimeRef.current = runtime;
let cancelled = false;
setState("mounting");
setError(null);
void runtime.mount(canvas).then(async () => {
if (cancelled) return;
setState("loading");
await runtime.loadWorld(manifest);
if (!cancelled) setState("ready");
}).catch((caught: unknown) => {
if (cancelled) return;
setState("failed");
setError(caught instanceof Error ? caught.message : "Не удалось открыть сцену.");
});
return () => {
cancelled = true;
runtime.dispose();
runtimeRef.current = null;
};
}, [project.projectId, project.worldManifest]);
const collisionAvailable = project.worldManifest?.collision.available ?? false;
return (
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
<header className="simulation-viewport__toolbar">
<div>
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
</StatusBadge>
<span>PlayCanvas Engine 2.21.4</span>
</div>
<SegmentedControl
label="Слой сцены"
value={viewMode}
onChange={(next) => {
setViewMode(next);
runtimeRef.current?.setViewMode(next);
}}
items={[
{ value: "visual", label: "Визуал" },
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
]}
/>
<SegmentedControl
label="Качество Streamed SOG"
value={quality}
onChange={(next) => {
setQuality(next);
runtimeRef.current?.setQuality(next);
}}
items={[
{ value: "auto", label: "Auto" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Med" },
{ value: "high", label: "High" },
]}
/>
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.focusBounds()}>
Вписать сцену
</Button>
</header>
<div className="simulation-viewport__stage">
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
{state !== "ready" ? (
<div className="simulation-viewport__state" role={state === "failed" ? "alert" : "status"}>
{state === "failed" ? null : <ActivityIndicator label="Загрузка сцены" />}
<strong>{state === "failed" ? "Сцена не открылась" : "PlayCanvas загружает мир"}</strong>
<p>{error ?? "Streamed SOG догружается по мере готовности runtime."}</p>
</div>
) : null}
</div>
{!collisionAvailable ? (
<footer className="simulation-viewport__notice">
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
<span>Worker 006 сейчас работает в CPU-режиме; визуальный слой настоящий, collision GLB не подменяется.</span>
</footer>
) : null}
</section>
);
}
@@ -0,0 +1,399 @@
export type SimulationSceneType = "interior" | "outdoor" | "object";
export type SimulationProjectStatus =
| "uploading"
| "queued"
| "processing"
| "importing"
| "ready"
| "failed";
export interface SimulationSourceFile {
fileId: string;
logicalPath: string;
byteLength: number;
uploadedBytes: number;
sha256: string | null;
}
export interface SimulationWorldManifest {
schemaVersion: "missioncore.simulation-world-manifest/v1";
projectId: string;
visual: {
previewSogUrl: string | null;
streamedSogUrl: string | null;
};
collision: {
meshUrl: string | null;
available: boolean;
};
transforms: {
worldFromVisual: number[];
worldFromCollision: number[];
};
}
export interface SimulationProject {
schemaVersion: "missioncore.simulation-project/v1";
projectId: string;
name: string;
sceneType: SimulationSceneType;
status: SimulationProjectStatus;
source: {
kind: "archive" | "folder";
totalByteLength: number;
uploadedByteLength: number;
files: SimulationSourceFile[];
bundleSha256: string | null;
};
provider: {
providerId: "gaussian-pipeline";
jobId: string | null;
state: string | null;
progress: { completed_steps?: number; total_steps?: number; stage?: string } | null;
runtime: { source_revision?: string; image_digest?: string } | null;
};
artifacts: Array<{
role: string;
logicalPath: string;
mediaType: string;
sha256: string;
byteLength: number;
}>;
worldManifest: SimulationWorldManifest | null;
error: string | null;
createdAtUtc: string;
updatedAtUtc: string;
}
export interface SimulationUploadCandidate {
file: File;
logicalPath: string;
}
export interface SimulationUploadProgress {
uploadedBytes: number;
totalBytes: number;
currentPath: string;
}
const API_ROOT = "/api/v1/simulation-worlds/projects";
const CHUNK_BYTES = 8 * 1024 * 1024;
const MAX_UPLOAD_ATTEMPTS = 3;
export async function fetchSimulationProjects(signal?: AbortSignal): Promise<SimulationProject[]> {
const response = await fetch(API_ROOT, { signal, cache: "no-store" });
const document = await jsonResponse(response);
const record = objectValue(document, "simulation project page");
exactKeys(record, ["schema_version", "projects"], "simulation project page");
if (record.schema_version !== "missioncore.simulation-project-page/v1" || !Array.isArray(record.projects)) {
throw new Error("Каталог симуляций вернул неподдерживаемый контракт.");
}
return record.projects.map(parseProject);
}
export async function createAndUploadSimulationProject(
name: string,
sceneType: SimulationSceneType,
candidates: SimulationUploadCandidate[],
onProgress: (progress: SimulationUploadProgress) => void,
): Promise<SimulationProject> {
const sourceKind = sourceKindFor(candidates);
const createdResponse = await fetch(API_ROOT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: "missioncore.simulation-project-create/v1",
name,
scene_type: sceneType,
source_kind: sourceKind,
files: candidates.map((candidate) => ({
logical_path: candidate.logicalPath,
byte_length: candidate.file.size,
})),
}),
});
let project = parseProject(await jsonResponse(createdResponse));
const byPath = new Map(candidates.map((candidate) => [candidate.logicalPath, candidate.file]));
const totalBytes = candidates.reduce((total, candidate) => total + candidate.file.size, 0);
let confirmedBytes = 0;
for (const sourceFile of project.source.files) {
const file = byPath.get(sourceFile.logicalPath);
if (!file) throw new Error(`Сервер изменил состав источника: ${sourceFile.logicalPath}`);
const uploadUrl = `${API_ROOT}/${encodeURIComponent(project.projectId)}/source/${encodeURIComponent(sourceFile.fileId)}`;
let offset = await readUploadOffset(uploadUrl, file.size);
confirmedBytes += offset;
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
while (offset < file.size) {
const nextOffset = Math.min(file.size, offset + CHUNK_BYTES);
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset);
confirmedBytes += confirmedOffset - offset;
offset = confirmedOffset;
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
}
}
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(project.projectId)}/build`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: "{}",
});
project = parseProject(await jsonResponse(buildResponse));
return project;
}
async function readUploadOffset(uploadUrl: string, fileSize: number): Promise<number> {
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store" });
if (!head.ok) await jsonResponse(head);
const value = head.headers.get("Upload-Offset");
if (!value || !/^\d+$/.test(value)) {
throw new Error("Сервер не вернул смещение resumable-загрузки.");
}
const offset = Number(value);
if (!Number.isSafeInteger(offset) || offset < 0 || offset > fileSize) {
throw new Error("Сервер вернул некорректное смещение загрузки.");
}
return offset;
}
async function uploadChunk(
uploadUrl: string,
file: File,
offset: number,
nextOffset: number,
): Promise<number> {
let lastFailure: unknown = null;
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
let response: Response | null = null;
try {
response = await fetch(uploadUrl, {
method: "PATCH",
headers: {
"Content-Type": "application/offset+octet-stream",
"Upload-Offset": String(offset),
},
body: file.slice(offset, nextOffset),
});
} catch (caught) {
lastFailure = caught;
}
if (response?.ok) {
const confirmed = response.headers.get("Upload-Offset");
if (confirmed && Number(confirmed) === nextOffset) return nextOffset;
throw new Error("Сервер не подтвердил непрерывность загрузки.");
}
if (
response &&
((response.status < 500 && response.status !== 409) ||
(response.status >= 500 && attempt === MAX_UPLOAD_ATTEMPTS))
) {
await jsonResponse(response);
throw new Error("Mission Core отклонил блок загрузки.");
}
if (response) {
lastFailure = new Error(`Mission Core временно вернул HTTP ${response.status}.`);
}
try {
const confirmed = await readUploadOffset(uploadUrl, file.size);
if (confirmed === nextOffset) return confirmed;
if (confirmed !== offset) {
throw new Error("Сервер подтвердил неожиданное смещение загрузки.");
}
} catch (caught) {
lastFailure = caught;
}
}
throw lastFailure instanceof Error
? lastFailure
: new Error("Не удалось продолжить resumable-загрузку.");
}
export async function updateSimulationProject(
projectId: string,
name: string,
sceneType: SimulationSceneType,
): Promise<SimulationProject> {
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
schema_version: "missioncore.simulation-project-update/v1",
name,
scene_type: sceneType,
}),
});
return parseProject(await jsonResponse(response));
}
export async function deleteSimulationProject(projectId: string): Promise<void> {
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { method: "DELETE" });
if (!response.ok) await jsonResponse(response);
}
function sourceKindFor(candidates: SimulationUploadCandidate[]): "archive" | "folder" {
if (candidates.length === 1 && /\.(zip|rar|7z)$/i.test(candidates[0]?.logicalPath ?? "")) {
return "archive";
}
if (candidates.some((candidate) => /\.(zip|rar|7z)$/i.test(candidate.logicalPath))) {
throw new Error("Архив загружается отдельно; нельзя смешивать его с файлами папки.");
}
return "folder";
}
function parseProject(value: unknown): SimulationProject {
const record = objectValue(value, "simulation project");
exactKeys(record, [
"schema_version", "project_id", "name", "scene_type", "status", "source", "provider",
"artifacts", "world_manifest", "error", "created_at_utc", "updated_at_utc",
], "simulation project");
if (record.schema_version !== "missioncore.simulation-project/v1") {
throw new Error("Проект симуляции вернул неподдерживаемую версию.");
}
const source = objectValue(record.source, "simulation source");
const provider = objectValue(record.provider, "simulation provider");
if (!Array.isArray(source.files) || !Array.isArray(record.artifacts)) {
throw new Error("Проект симуляции вернул некорректный состав файлов.");
}
return {
schemaVersion: "missioncore.simulation-project/v1",
projectId: stringValue(record.project_id, "project id"),
name: stringValue(record.name, "project name"),
sceneType: sceneTypeValue(record.scene_type),
status: statusValue(record.status),
source: {
kind: source.kind === "archive" ? "archive" : source.kind === "folder" ? "folder" : invalid("source kind"),
totalByteLength: numberValue(source.total_byte_length, "source bytes"),
uploadedByteLength: numberValue(source.uploaded_byte_length, "uploaded bytes"),
files: source.files.map((item) => {
const file = objectValue(item, "source file");
return {
fileId: stringValue(file.file_id, "source file id"),
logicalPath: stringValue(file.logical_path, "source logical path"),
byteLength: numberValue(file.byte_length, "source file bytes"),
uploadedBytes: numberValue(file.uploaded_bytes, "source uploaded bytes"),
sha256: nullableString(file.sha256, "source sha256"),
};
}),
bundleSha256: nullableString(source.bundle_sha256, "bundle sha256"),
},
provider: {
providerId: provider.provider_id === "gaussian-pipeline" ? "gaussian-pipeline" : invalid("provider id"),
jobId: nullableString(provider.job_id, "provider job id"),
state: nullableString(provider.state, "provider state"),
progress: provider.progress === null ? null : objectValue(provider.progress, "provider progress"),
runtime: provider.runtime === null ? null : objectValue(provider.runtime, "provider runtime"),
},
artifacts: record.artifacts.map((item) => {
const artifact = objectValue(item, "simulation artifact");
return {
role: stringValue(artifact.role, "artifact role"),
logicalPath: stringValue(artifact.logical_path, "artifact path"),
mediaType: stringValue(artifact.media_type, "artifact media type"),
sha256: stringValue(artifact.sha256, "artifact sha256"),
byteLength: numberValue(artifact.byte_length, "artifact bytes"),
};
}),
worldManifest: record.world_manifest === null ? null : parseWorldManifest(record.world_manifest),
error: nullableString(record.error, "project error"),
createdAtUtc: stringValue(record.created_at_utc, "created at"),
updatedAtUtc: stringValue(record.updated_at_utc, "updated at"),
};
}
function parseWorldManifest(value: unknown): SimulationWorldManifest {
const record = objectValue(value, "world manifest");
const visual = objectValue(record.visual, "visual manifest");
const collision = objectValue(record.collision, "collision manifest");
const transforms = objectValue(record.transforms, "world transforms");
if (record.schema_version !== "missioncore.simulation-world-manifest/v1") {
throw new Error("World manifest вернул неподдерживаемую версию.");
}
return {
schemaVersion: "missioncore.simulation-world-manifest/v1",
projectId: stringValue(record.project_id, "manifest project id"),
visual: {
previewSogUrl: nullableString(visual.preview_sog_url, "preview URL"),
streamedSogUrl: nullableString(visual.streamed_sog_url, "streamed URL"),
},
collision: {
meshUrl: nullableString(collision.mesh_url, "collision URL"),
available: booleanValue(collision.available, "collision availability"),
},
transforms: {
worldFromVisual: matrixValue(transforms.world_from_visual, "visual transform"),
worldFromCollision: matrixValue(transforms.world_from_collision, "collision transform"),
},
};
}
async function jsonResponse(response: Response): Promise<unknown> {
let value: unknown;
try {
value = await response.json();
} catch {
if (response.ok) return {};
throw new Error(`Mission Core вернул HTTP ${response.status}.`);
}
if (!response.ok) {
const detail = objectValue(value, "error response").detail;
throw new Error(typeof detail === "string" ? detail : `Mission Core вернул HTTP ${response.status}.`);
}
return value;
}
function objectValue(value: unknown, label: string): Record<string, any> {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
throw new Error(`Некорректный ${label}.`);
}
return value as Record<string, any>;
}
function exactKeys(record: Record<string, any>, keys: string[], label: string): void {
const actual = Object.keys(record).sort();
const expected = [...keys].sort();
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
throw new Error(`Некорректные поля ${label}.`);
}
}
function stringValue(value: unknown, label: string): string {
if (typeof value !== "string" || !value) throw new Error(`Некорректный ${label}.`);
return value;
}
function nullableString(value: unknown, label: string): string | null {
return value === null ? null : stringValue(value, label);
}
function numberValue(value: unknown, label: string): number {
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) {
throw new Error(`Некорректный ${label}.`);
}
return value;
}
function booleanValue(value: unknown, label: string): boolean {
if (typeof value !== "boolean") throw new Error(`Некорректный ${label}.`);
return value;
}
function matrixValue(value: unknown, label: string): number[] {
if (!Array.isArray(value) || value.length !== 16 || value.some((item) => typeof item !== "number")) {
throw new Error(`Некорректный ${label}.`);
}
return [...value];
}
function sceneTypeValue(value: unknown): SimulationSceneType {
if (value === "interior" || value === "outdoor" || value === "object") return value;
return invalid("scene type");
}
function statusValue(value: unknown): SimulationProjectStatus {
if (["uploading", "queued", "processing", "importing", "ready", "failed"].includes(String(value))) {
return value as SimulationProjectStatus;
}
return invalid("project status");
}
function invalid(label: string): never {
throw new Error(`Некорректный ${label}.`);
}
+13 -1
View File
@@ -22,7 +22,8 @@ export type WorkspaceKind =
| "network-monitor"
| "datasets"
| "artifact-health"
| "lab-archive";
| "lab-archive"
| "simulations";
export type CapabilityStatus = "active" | "ready" | "contract" | "later";
@@ -154,6 +155,17 @@ export const workspaces: WorkspaceDefinition[] = [
kind: "lab-archive",
groups: [],
},
{
id: "simulations",
root: "polygon",
label: "Симуляции",
title: "Симуляции",
eyebrow: "ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ",
description: "Каталог Gaussian-миров, переносимые сборки и интерактивные PlayCanvas-сцены.",
icon: "globe",
kind: "simulations",
groups: [],
},
{
id: "contour-health",
root: "fleet",
+1
View File
@@ -10,6 +10,7 @@
@import "./styles/l3-pointpillars-visual-audit.css";
@import "./styles/l34-annotation.css";
@import "./styles/laboratory-reporting.css";
@import "./styles/simulation.css";
@import "./styles/laboratory-evidence-report.css";
@import "./styles/e34-temporal-layer.css";
@import "./styles/m4-replay-threat.css";
@@ -0,0 +1,442 @@
.simulation-workspace {
display: grid;
min-width: 0;
gap: 0.9rem;
padding-bottom: 1rem;
}
.simulation-workspace--scene {
height: 100%;
min-height: 38rem;
grid-template-rows: auto minmax(0, 1fr);
}
.simulation-workspace__head,
.simulation-workspace__scene-head,
.simulation-viewport__toolbar,
.simulation-viewport__notice,
.simulation-catalog__summary,
.simulation-catalog__state,
.simulation-catalog__actions,
.simulation-project-window__footer-state,
.simulation-source-drop > div,
.simulation-upload-progress {
display: flex;
align-items: center;
}
.simulation-workspace__head,
.simulation-workspace__scene-head {
justify-content: space-between;
gap: 1rem;
padding: 0.25rem 0.2rem;
}
.simulation-workspace__head h2,
.simulation-workspace__head p,
.simulation-workspace__scene-head h2 {
margin: 0;
}
.simulation-workspace__head h2,
.simulation-workspace__scene-head h2 {
margin-top: 0.3rem;
color: var(--nodedc-text-primary);
font-size: 1.18rem;
letter-spacing: -0.035em;
}
.simulation-workspace__head p {
max-width: 46rem;
margin-top: 0.38rem;
color: var(--nodedc-text-muted);
font-size: 0.66rem;
line-height: 1.5;
}
.simulation-workspace__scene-head {
display: grid;
grid-template-columns: minmax(8rem, auto) minmax(0, 1fr) auto;
}
.simulation-catalog {
overflow: hidden;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background: rgb(255 255 255 / 0.022);
}
.simulation-catalog__summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1px;
border-bottom: 1px solid var(--station-hairline);
background: var(--station-hairline);
}
.simulation-catalog__summary > div {
display: grid;
gap: 0.24rem;
background: var(--nodedc-canvas);
padding: 0.85rem 1rem;
}
.simulation-catalog__summary span,
.simulation-catalog__table th {
color: var(--nodedc-text-muted);
font-size: 0.52rem;
font-weight: 680;
letter-spacing: 0.075em;
text-transform: uppercase;
}
.simulation-catalog__summary strong {
color: var(--nodedc-text-primary);
font-size: 0.88rem;
}
.simulation-catalog__table-wrap {
overflow: auto;
}
.simulation-catalog__table {
width: 100%;
min-width: 62rem;
border-collapse: collapse;
}
.simulation-catalog__table th,
.simulation-catalog__table td {
border-bottom: 1px solid var(--station-hairline);
padding: 0.72rem 0.8rem;
text-align: left;
vertical-align: middle;
}
.simulation-catalog__table th:first-child,
.simulation-catalog__table td:first-child {
padding-left: 1rem;
}
.simulation-catalog__table th:last-child,
.simulation-catalog__table td:last-child {
padding-right: 1rem;
}
.simulation-catalog__table tbody tr {
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard);
}
.simulation-catalog__table tbody tr:hover {
background: rgb(255 255 255 / 0.025);
}
.simulation-catalog__table tbody tr:last-child td {
border-bottom: 0;
}
.simulation-catalog__table td {
color: var(--nodedc-text-secondary);
font-size: 0.64rem;
}
.simulation-catalog__table td > small,
.simulation-catalog__table td > button small {
display: block;
margin-top: 0.18rem;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
}
.simulation-catalog__table td:first-child > button {
display: flex;
align-items: center;
gap: 0.65rem;
min-width: 14rem;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
}
.simulation-catalog__table td:first-child > button strong {
display: block;
max-width: 20rem;
overflow: hidden;
color: var(--nodedc-text-primary);
font-size: 0.7rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.simulation-catalog__project-icon,
.simulation-catalog__empty > span {
display: grid;
width: 2rem;
height: 2rem;
flex: 0 0 auto;
place-items: center;
border: 1px solid var(--station-hairline);
border-radius: 0.62rem;
background: rgb(var(--nodedc-accent-rgb) / 0.07);
color: var(--nodedc-text-secondary);
}
.simulation-catalog__actions {
justify-content: flex-end;
gap: 0.28rem;
}
.simulation-catalog__state,
.simulation-catalog__empty {
min-height: 20rem;
justify-content: center;
gap: 0.75rem;
color: var(--nodedc-text-muted);
font-size: 0.65rem;
}
.simulation-catalog__state > div {
display: grid;
gap: 0.2rem;
}
.simulation-catalog__state strong,
.simulation-catalog__empty strong {
color: var(--nodedc-text-primary);
}
.simulation-catalog__empty {
display: flex;
flex-direction: column;
text-align: center;
}
.simulation-catalog__empty p {
max-width: 30rem;
margin: 0;
line-height: 1.5;
}
.simulation-project-window {
display: grid;
gap: 0.9rem;
}
.simulation-project-window .nodedc-select-anchor,
.simulation-project-window .nodedc-select {
width: 100%;
}
.simulation-source-drop {
display: grid;
min-height: 12rem;
place-items: center;
align-content: center;
gap: 0.55rem;
border: 1px dashed var(--nodedc-glass-outline);
border-radius: 0.85rem;
background: rgb(255 255 255 / 0.018);
color: var(--nodedc-text-muted);
text-align: center;
transition: border-color var(--nodedc-duration-fast) var(--nodedc-ease-standard),
background var(--nodedc-duration-fast) var(--nodedc-ease-standard);
}
.simulation-source-drop[data-dragging="true"] {
border-color: rgb(var(--nodedc-accent-rgb) / 0.72);
background: rgb(var(--nodedc-accent-rgb) / 0.08);
}
.simulation-source-drop > strong {
color: var(--nodedc-text-primary);
font-size: 0.72rem;
}
.simulation-source-drop > span {
max-width: 30rem;
font-size: 0.58rem;
}
.simulation-source-drop > div {
gap: 0.45rem;
margin-top: 0.25rem;
}
.simulation-upload-progress {
gap: 0.7rem;
border-radius: 0.72rem;
background: rgb(255 255 255 / 0.025);
padding: 0.7rem;
}
.simulation-upload-progress > span {
position: relative;
overflow: hidden;
width: 8rem;
height: 0.28rem;
border-radius: 999px;
background: rgb(255 255 255 / 0.08);
}
.simulation-upload-progress > span i {
position: absolute;
inset: 0 auto 0 0;
border-radius: inherit;
background: rgb(var(--nodedc-accent-rgb));
}
.simulation-upload-progress > div {
display: grid;
min-width: 0;
gap: 0.14rem;
}
.simulation-upload-progress strong {
color: var(--nodedc-text-primary);
font-size: 0.62rem;
}
.simulation-upload-progress small {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.54rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.simulation-project-window__footer-state {
gap: 0.4rem;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.simulation-project-window__error {
margin: 0;
border-radius: 0.65rem;
background: rgb(var(--nodedc-danger-rgb) / 0.08);
color: var(--nodedc-danger);
padding: 0.65rem 0.75rem;
font-size: 0.6rem;
line-height: 1.45;
}
.simulation-viewport {
display: grid;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
border: 1px solid var(--station-hairline);
border-radius: 1rem;
background: #07090d;
}
.simulation-viewport__toolbar {
flex-wrap: wrap;
gap: 0.55rem;
border-bottom: 1px solid var(--station-hairline);
background: var(--nodedc-glass-panel-bg-soft);
padding: 0.55rem 0.65rem;
}
.simulation-viewport__toolbar > div:first-child {
display: flex;
min-width: 12rem;
flex: 1;
align-items: center;
gap: 0.45rem;
}
.simulation-viewport__toolbar > div:first-child > span:last-child,
.simulation-viewport__notice > span:last-child {
color: var(--nodedc-text-muted);
font-size: 0.55rem;
}
.simulation-viewport__stage {
position: relative;
min-height: 30rem;
}
.simulation-viewport__stage canvas {
display: block;
width: 100%;
height: 100%;
outline: none;
}
.simulation-viewport__stage canvas:focus-visible {
box-shadow: inset 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.7);
}
.simulation-viewport__state {
position: absolute;
inset: 0;
display: grid;
place-items: center;
align-content: center;
gap: 0.55rem;
background: rgb(7 9 13 / 0.84);
color: var(--nodedc-text-muted);
text-align: center;
}
.simulation-viewport__state strong {
color: var(--nodedc-text-primary);
font-size: 0.75rem;
}
.simulation-viewport__state p {
max-width: 34rem;
margin: 0;
font-size: 0.6rem;
}
.simulation-viewport__notice {
gap: 0.55rem;
border-top: 1px solid var(--station-hairline);
padding: 0.48rem 0.65rem;
}
.simulation-workspace__processing {
display: flex;
min-height: 18rem;
align-items: center;
justify-content: center;
gap: 0.8rem;
}
.simulation-workspace__processing > div {
display: grid;
max-width: 40rem;
gap: 0.25rem;
}
.simulation-workspace__processing strong {
color: var(--nodedc-text-primary);
font-size: 0.75rem;
}
.simulation-workspace__processing p {
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.62rem;
line-height: 1.5;
}
@media (max-width: 900px) {
.simulation-catalog__summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.simulation-workspace__scene-head {
grid-template-columns: 1fr auto;
}
.simulation-workspace__scene-head > .nodedc-button {
grid-column: 1 / -1;
justify-self: start;
}
}
+14
View File
@@ -0,0 +1,14 @@
declare module "playcanvas/scripts/esm/camera-controls.mjs" {
import { Script, Vec2, Vec3 } from "playcanvas";
export class CameraControls extends Script {
static scriptName: "cameraControls";
enableFly: boolean;
enableOrbit: boolean;
focusPoint: Vec3;
zoomRange: Vec2;
focus(focus: Vec3, resetZoom?: boolean): void;
look(focus: Vec3, resetZoom?: boolean): void;
reset(focus: Vec3, position: Vec3): void;
}
}
@@ -1,10 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
Button,
GlassSurface,
Icon,
StatusBadge,
} from "@nodedc/ui-react";
import { Button, GlassSurface, Icon, StatusBadge } from "@nodedc/ui-react";
import {
ObservationMedia,
ObservationSourcePicker,
@@ -41,6 +36,7 @@ import { DatasetGatewayWorkspace } from "./DatasetGatewayWorkspace";
import { ArtifactHealthWorkspace } from "./data/ArtifactHealthWorkspace";
import { ContourHealthWorkspace } from "./ContourHealthWorkspace";
import { LaboratoryArchiveWorkspace } from "./laboratory/LaboratoryArchiveWorkspace";
import { SimulationWorkspace } from "./simulation/SimulationWorkspace";
import { ComputeModulesWorkspace } from "./system/ComputeModulesWorkspace";
import { NetworkWorkspace } from "./system/NetworkWorkspace";
import { WorldMapWorkspace } from "./map/WorldMapWorkspace";
@@ -1193,6 +1189,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
SpatialView={SpatialWorkspace}
/>
);
case "simulations":
return <SimulationWorkspace />;
case "device":
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.deepEqual(
workspacesForRoot("polygon").map(({ id }) => id),
["lab-archive"],
["lab-archive", "simulations"],
);
assert.equal(
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 недоступен/);
});