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
@@ -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 })} ГБ`;
}