feat(simulation): add Gaussian UGV runtime pipeline

This commit is contained in:
DCCONSTRUCTIONS
2026-08-28 11:50:47 +03:00
parent c4c2392c79
commit 722e60e5ef
21 changed files with 4685 additions and 628 deletions
@@ -1,25 +1,45 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type DragEvent,
} from "react";
import {
ActivityIndicator,
Button,
ConfirmationModal,
GlassSurface,
Icon,
IconButton,
StatusBadge,
} from "@nodedc/ui-react";
import { SimulationCatalog } from "../../components/simulation/SimulationCatalog";
import { SimulationProjectWindow } from "../../components/simulation/SimulationProjectWindow";
import { SimulationViewport } from "../../components/simulation/SimulationViewport";
import {
createSimulationProject,
deleteSimulationProject,
fetchSimulationProjects,
retrySimulationProject,
uploadSimulationProjectSource,
type SimulationProject,
type SimulationProjectStatus,
type SimulationUploadProgress,
} from "../../core/simulation/projects";
import {
archiveProjectName,
archivesFromDrop,
hasDroppedFiles,
} from "../../core/simulation/sourceFiles";
const ACTIVE_STATUSES = new Set<SimulationProjectStatus>(["queued", "processing", "importing"]);
const BUILD_STATUSES = new Set<SimulationProjectStatus>(["queued", "processing", "importing"]);
interface BrowserUpload {
project: SimulationProject;
file: File;
}
export function SimulationWorkspace() {
const [projects, setProjects] = useState<SimulationProject[]>([]);
@@ -30,6 +50,15 @@ export function SimulationWorkspace() {
const [editing, setEditing] = useState<SimulationProject | null>(null);
const [deleting, setDeleting] = useState<SimulationProject | null>(null);
const [retryingId, setRetryingId] = useState<string | null>(null);
const [pendingUploads, setPendingUploads] = useState<BrowserUpload[]>([]);
const [uploadProgress, setUploadProgress] = useState<Map<string, SimulationUploadProgress>>(
() => new Map(),
);
const [queueError, setQueueError] = useState<string | null>(null);
const [dropping, setDropping] = useState(false);
const activeUploadRef = useRef<{ projectId: string; controller: AbortController } | null>(null);
const intakeChainRef = useRef<Promise<void>>(Promise.resolve());
const dragDepthRef = useRef(0);
const load = useCallback(async (signal?: AbortSignal) => {
try {
@@ -51,11 +80,60 @@ export function SimulationWorkspace() {
}, [load]);
useEffect(() => {
if (!projects.some((project) => ACTIVE_STATUSES.has(project.status))) return;
if (!projects.some((project) => BUILD_STATUSES.has(project.status))) return;
const timer = window.setInterval(() => void load(), 2_000);
return () => window.clearInterval(timer);
}, [load, projects]);
useEffect(() => {
const next = pendingUploads[0];
if (!next || activeUploadRef.current) return;
const controller = new AbortController();
activeUploadRef.current = { projectId: next.project.projectId, controller };
void uploadSimulationProjectSource(
next.project,
[{ file: next.file, logicalPath: next.file.name }],
(progress) => {
setUploadProgress((current) => {
const updated = new Map(current);
updated.set(next.project.projectId, progress);
return updated;
});
setProjects((current) => current.map((project) => (
project.projectId === next.project.projectId
? {
...project,
source: { ...project.source, uploadedByteLength: progress.uploadedBytes },
}
: project
)));
},
controller.signal,
).then((queued) => {
setProjects((current) => current.map((project) => (
project.projectId === queued.projectId ? queued : project
)));
setQueueError(null);
}).catch((caught) => {
if (controller.signal.aborted) return;
setQueueError(caught instanceof Error
? `${next.file.name}: ${caught.message}`
: `${next.file.name}: загрузка остановлена.`);
}).finally(() => {
setPendingUploads((current) => current.filter(
(item) => item.project.projectId !== next.project.projectId,
));
setUploadProgress((current) => {
const updated = new Map(current);
updated.delete(next.project.projectId);
return updated;
});
activeUploadRef.current = null;
});
}, [pendingUploads]);
useEffect(() => () => activeUploadRef.current?.controller.abort(), []);
const selected = useMemo(
() => projects.find((project) => project.projectId === selectedId) ?? null,
[projects, selectedId],
@@ -72,8 +150,9 @@ export function SimulationWorkspace() {
};
const acceptSaved = (project: SimulationProject) => {
const wasEditing = editing !== null;
setProjects((current) => [project, ...current.filter((item) => item.projectId !== project.projectId)]);
setSelectedId(project.projectId);
if (!wasEditing) setSelectedId(project.projectId);
setWindowOpen(false);
setEditing(null);
};
@@ -86,10 +165,77 @@ export function SimulationWorkspace() {
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);
const pending = pendingUploads.find((item) => item.project.projectId === deleting.projectId);
if (activeUploadRef.current?.projectId === deleting.projectId) {
activeUploadRef.current.controller.abort();
}
setPendingUploads((current) => current.filter(
(item) => item.project.projectId !== deleting.projectId,
));
try {
await deleteSimulationProject(deleting.projectId);
setProjects((current) => current.filter((project) => project.projectId !== deleting.projectId));
setUploadProgress((current) => {
const updated = new Map(current);
updated.delete(deleting.projectId);
return updated;
});
if (selectedId === deleting.projectId) setSelectedId(null);
setDeleting(null);
} catch (caught) {
if (pending) setPendingUploads((current) => [pending, ...current]);
const message = caught instanceof Error ? caught.message : "Не удалось удалить проект.";
setQueueError(message);
throw caught;
}
};
const enqueueDroppedArchives = useCallback((files: File[]) => {
intakeChainRef.current = intakeChainRef.current.then(async () => {
for (const file of files) {
try {
const project = await createSimulationProject(
archiveProjectName(file.name),
"outdoor",
[{ file, logicalPath: file.name }],
);
setProjects((current) => [
project,
...current.filter((item) => item.projectId !== project.projectId),
]);
setPendingUploads((current) => [...current, { project, file }]);
} catch (caught) {
setQueueError(caught instanceof Error
? `${file.name}: ${caught.message}`
: `${file.name}: не удалось создать проект.`);
}
}
});
}, []);
const handleDrop = async (transfer: DataTransfer) => {
dragDepthRef.current = 0;
setDropping(false);
setQueueError(null);
try {
enqueueDroppedArchives(await archivesFromDrop(transfer));
} catch (caught) {
setQueueError(caught instanceof Error ? caught.message : "Архивы не приняты.");
}
};
const handleDragEnter = (event: DragEvent<HTMLDivElement>) => {
if (!hasDroppedFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepthRef.current += 1;
setDropping(true);
};
const handleDragLeave = (event: DragEvent<HTMLDivElement>) => {
if (!hasDroppedFiles(event.dataTransfer)) return;
event.preventDefault();
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
if (dragDepthRef.current === 0) setDropping(false);
};
const retry = async (project: SimulationProject) => {
@@ -141,7 +287,7 @@ export function SimulationWorkspace() {
</div>
<div className="simulation-workspace__processing-actions">
<StatusBadge tone={selected.status === "failed" ? "warning" : "accent"}>
{selected.provider.state ?? selected.status}
{providerStateLabel(selected.provider.state, selected.status)}
</StatusBadge>
{selected.status === "failed" ? (
<Button
@@ -164,93 +310,45 @@ export function SimulationWorkspace() {
}
return (
<div className="simulation-workspace">
<div
className="simulation-workspace"
onDragEnter={handleDragEnter}
onDragOver={(event) => {
if (!hasDroppedFiles(event.dataTransfer)) return;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
}}
onDragLeave={handleDragLeave}
onDrop={(event) => {
if (!hasDroppedFiles(event.dataTransfer)) return;
event.preventDefault();
void handleDrop(event.dataTransfer);
}}
>
<header className="simulation-workspace__head">
<div>
<span className="section-eyebrow">ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ</span>
<h2>Gaussian-миры</h2>
<p>Каталог исходников, сборок Worker 006 и полнофункциональных PlayCanvas-сцен.</p>
<p>Каталог исходников, сборок Worker 006 и полнофункциональных интерактивных сцен.</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>
<SimulationCatalog
projects={projects}
uploadProgress={uploadProgress}
loading={loading}
loadError={error}
queueError={queueError}
dropping={dropping}
onCreate={openCreate}
onRetryLoad={() => void load()}
onSelect={(project) => setSelectedId(project.projectId)}
onEdit={openEdit}
onDelete={setDeleting}
/>
<SimulationProjectWindow
open={windowOpen}
@@ -280,7 +378,7 @@ function statusPresentation(status: SimulationProjectStatus): {
label: string;
tone: "success" | "accent" | "warning" | "neutral";
} {
if (status === "ready") return { label: "Готово", tone: "success" };
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" };
@@ -294,28 +392,20 @@ function processingMessage(project: SimulationProject): string {
if (typeof completed === "number" && typeof total === "number") {
return `Этап ${completed.toLocaleString("ru-RU")} из ${total.toLocaleString("ru-RU")}. Каталог обновляется автоматически.`;
}
return "Исходник подтверждён; конвертация SOG и Streamed SOG выполняется в переносимом контейнере.";
return "Исходник подтверждён; локация и её LOD-уровни собираются в переносимом контейнере.";
}
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 })} ГБ`;
function providerStateLabel(
state: string | null,
projectStatus: SimulationProjectStatus,
): string {
if (state === "queued") return "Ожидание";
if (state === "verifying_source") return "Проверка исходника";
if (state === "inspecting") return "Анализ локации";
if (state === "building_preview") return "Подготовка локации";
if (state === "building_streamed_sog") return "Сборка LOD";
if (state === "building_collision") return "Сборка коллизий";
if (state === "ready") return "Визуал готов";
if (state === "failed") return "Ошибка";
return statusPresentation(projectStatus).label;
}