feat(simulation): add Gaussian UGV runtime pipeline
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user