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,34 @@
import {
Application,
Asset,
BLEND_NONE,
BLEND_NORMAL,
Color,
CULLFACE_NONE,
Entity,
FILLMODE_NONE,
Mat4,
Quat,
RESOLUTION_AUTO,
StandardMaterial,
Vec2,
Vec3,
WasmModule,
type ContainerResource,
type RenderComponent,
type ModelComponent,
type ScriptType,
} from "playcanvas";
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
import {
prepareSimulationPhysics,
SimulationUgvController,
type AmmoApi,
} from "./SimulationUgvController";
import type {
SimulationTransformAxis,
SimulationEulerRotation,
SimulationUgvPreset,
SimulationViewerQuality,
SimulationWorldManifest,
} from "../../core/simulation/projects";
@@ -28,46 +37,18 @@ export type SimulationViewMode = "visual" | "collision" | "combined";
export type SimulationQuality = SimulationViewerQuality;
export type SimulationLayer = "visual" | "collision";
export type SimulationCameraAxis = "horizontal" | "vertical";
export type SimulationControlMode = "free" | "ugv";
export const PLAYCANVAS_IDENTITY_TRANSFORM = [
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
export const PLAYCANVAS_X_180_TRANSFORM = [
1, 0, 0, 0,
0, -1, 0, 0,
0, 0, -1, 0,
0, 0, 0, 1,
];
export const PLAYCANVAS_Y_180_TRANSFORM = [
-1, 0, 0, 0,
0, 1, 0, 0,
0, 0, -1, 0,
0, 0, 0, 1,
];
export const PLAYCANVAS_Z_180_TRANSFORM = [
-1, 0, 0, 0,
0, -1, 0, 0,
0, 0, 1, 0,
0, 0, 0, 1,
];
export function playCanvasAxisTransform(axis: SimulationTransformAxis, inverted: boolean): number[] {
if (!inverted) return PLAYCANVAS_IDENTITY_TRANSFORM;
if (axis === "x") return PLAYCANVAS_X_180_TRANSFORM;
if (axis === "y") return PLAYCANVAS_Y_180_TRANSFORM;
return PLAYCANVAS_Z_180_TRANSFORM;
export function playCanvasEulerTransform(rotation: SimulationEulerRotation): number[] {
return Array.from(new Mat4().setFromEulerAngles(rotation.x, rotation.y, rotation.z).data);
}
export interface SimulationRuntime {
mount(canvas: HTMLCanvasElement): Promise<void>;
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
setViewMode(mode: SimulationViewMode): Promise<void>;
setControlMode(mode: SimulationControlMode): Promise<void>;
setUgvPreset(preset: SimulationUgvPreset): void;
setQuality(quality: SimulationQuality): void;
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void;
@@ -78,10 +59,6 @@ export interface SimulationRuntime {
type DesktopCameraInput = {
read(): { mouse: number[] } & Record<string, number[]>;
_pointerId?: number;
_button?: number[];
_keyNow?: number[];
_keyPrev?: number[];
};
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus"> & {
@@ -123,6 +100,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
private visualEntity: Entity | null = null;
private visualAsset: Asset | null = null;
private collisionEntity: Entity | null = null;
private collisionVisuals: ModelComponent[] = [];
private collisionAsset: Asset | null = null;
private collisionMaterial: StandardMaterial | null = null;
private collisionSource: { meshUrl: string; projectId: string; worldTransform: number[] } | null = null;
@@ -131,23 +109,41 @@ export class PlayCanvasRuntime implements SimulationRuntime {
private viewMode: SimulationViewMode = "visual";
private quality: SimulationQuality = "high";
private cameraInversion = { horizontal: true, vertical: false };
private controlMode: SimulationControlMode = "free";
private controlRequestVersion = 0;
private ugvController: SimulationUgvController | null = null;
private readonly lastCameraPosition = new Vec3();
private readonly lastCameraRotation = new Quat();
private cameraSnapshotReady = false;
private renderIntervalMs = 1000 / 60;
private renderAccumulatorMs = 0;
private disposed = false;
private scheduleFrame = (deltaMs: number): void => {
if (!this.app || this.disposed) return;
this.renderAccumulatorMs += deltaMs;
private updateFrame = (deltaSeconds: number): void => {
if (!this.app || !this.camera || this.disposed) return;
this.ugvController?.update(deltaSeconds);
const position = this.camera.getPosition();
const rotation = this.camera.getRotation();
const cameraChanged = !this.cameraSnapshotReady
|| !position.equals(this.lastCameraPosition)
|| !rotation.equals(this.lastCameraRotation);
if (!cameraChanged && this.controlMode === "free") return;
this.lastCameraPosition.copy(position);
this.lastCameraRotation.copy(rotation);
this.cameraSnapshotReady = true;
this.renderAccumulatorMs += deltaSeconds * 1000;
if (this.renderAccumulatorMs < this.renderIntervalMs) return;
this.renderAccumulatorMs %= this.renderIntervalMs;
this.app.renderNextFrame = true;
};
async mount(canvas: HTMLCanvasElement): Promise<void> {
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
if (this.app) throw new Error("Runtime сцены уже запущен.");
this.disposed = false;
this.renderAccumulatorMs = 0;
this.canvas = canvas;
configureDracoDecoder();
const ammo: AmmoApi = await prepareSimulationPhysics();
if (this.disposed) return;
canvas.tabIndex = 0;
canvas.addEventListener("contextmenu", preventContextMenu);
canvas.addEventListener("pointerdown", focusCanvas, true);
@@ -161,7 +157,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
});
this.app = app;
app.autoRender = false;
app.on("frameupdate", this.scheduleFrame);
app.on("update", this.updateFrame);
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
app.setCanvasResolution(RESOLUTION_AUTO);
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
@@ -188,6 +184,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
app.root.addChild(camera);
this.camera = camera;
this.cameraController = controller;
this.ugvController = new SimulationUgvController(app, canvas, camera, ammo);
const light = new Entity("SimulationSun");
light.addComponent("light", {
@@ -210,7 +207,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
const app = this.requiredApp();
this.unloadWorld();
const sourceUrl = manifest.visual.streamedSogUrl ?? manifest.visual.previewSogUrl;
if (!sourceUrl) throw new Error("World manifest не содержит визуальный SOG.");
if (!sourceUrl) throw new Error("Манифест локации не содержит визуальный слой.");
const asset = new Asset(`SimulationWorld:${manifest.projectId}`, "gsplat", { url: sourceUrl });
app.assets.add(asset);
this.visualAsset = asset;
@@ -233,10 +230,11 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.visualEntity = visual;
this.applyQuality();
this.applyViewMode();
this.requestRender();
resolve();
};
const failed = (error: unknown) => {
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил SOG."));
const failed = (_error: unknown) => {
reject(new Error("Визуальный слой не загрузился."));
};
asset.ready(ready);
asset.once("error", failed);
@@ -254,7 +252,6 @@ export class PlayCanvasRuntime implements SimulationRuntime {
}
async setViewMode(mode: SimulationViewMode): Promise<void> {
this.resetCameraInteraction();
this.viewMode = mode;
this.applyViewMode();
try {
@@ -263,13 +260,58 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.applyViewMode();
}
} finally {
this.requestRender();
this.canvas?.focus({ preventScroll: true });
}
}
async setControlMode(mode: SimulationControlMode): Promise<void> {
const requestVersion = ++this.controlRequestVersion;
const controller = this.ugvController;
if (!controller) throw new Error("Управление UGV ещё не готово.");
if (mode === "free") {
controller.disable();
this.controlMode = "free";
if (this.cameraController) this.cameraController.enabled = true;
this.home();
return;
}
if (this.controlMode === "ugv" && controller.enabled) return;
try {
await this.ensureCollision();
if (requestVersion !== this.controlRequestVersion || this.disposed) return;
const collisionWorld = this.collisionEntity;
if (!collisionWorld) throw new Error("Физический слой UGV отсутствует.");
if (this.cameraController) this.cameraController.enabled = false;
await controller.enable(collisionWorld);
if (requestVersion !== this.controlRequestVersion || this.disposed) {
controller.disable();
if (this.cameraController) this.cameraController.enabled = true;
return;
}
this.controlMode = "ugv";
this.cameraSnapshotReady = false;
this.requestRender();
} catch (error) {
controller.disable();
this.controlMode = "free";
if (this.cameraController) this.cameraController.enabled = true;
throw error;
}
}
setQuality(quality: SimulationQuality): void {
this.quality = quality;
this.applyQuality();
this.requestRender();
}
setUgvPreset(preset: SimulationUgvPreset): void {
this.ugvController?.configure(preset);
this.cameraSnapshotReady = false;
this.requestRender();
}
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void {
@@ -278,10 +320,13 @@ export class PlayCanvasRuntime implements SimulationRuntime {
}
if (layer === "visual") {
if (this.visualEntity) applyWorldTransform(this.visualEntity, transform);
this.requestRender();
return;
}
if (this.collisionSource) this.collisionSource.worldTransform = [...transform];
if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform);
this.ugvController?.syncCollisionBodies();
this.requestRender();
}
setCameraInversion(axis: SimulationCameraAxis, inverted: boolean): void {
@@ -289,6 +334,12 @@ export class PlayCanvasRuntime implements SimulationRuntime {
}
home(): void {
if (this.controlMode === "ugv" && this.ugvController?.enabled) {
this.ugvController.reset();
this.cameraSnapshotReady = false;
this.requestRender();
return;
}
this.resetCameraInteraction();
if (this.cameraController) {
this.cameraController.reset(HOME_FOCUS, HOME_POSITION);
@@ -296,6 +347,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.camera.setPosition(HOME_POSITION);
this.camera.lookAt(HOME_FOCUS);
}
this.requestRender();
this.canvas?.focus({ preventScroll: true });
}
@@ -312,6 +364,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.camera.setPosition(position);
this.camera.lookAt(focus);
}
this.requestRender();
}
dispose(): void {
@@ -321,10 +374,12 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.resizeObserver = null;
const webgl = this.canvas?.getContext("webgl2") ?? this.canvas?.getContext("webgl");
this.unloadWorld();
this.ugvController?.dispose();
this.ugvController = null;
if (this.app) {
this.app.autoRender = false;
this.app.renderNextFrame = false;
this.app.off("frameupdate", this.scheduleFrame);
this.app.off("update", this.updateFrame);
this.app.destroy();
}
webgl?.getExtension("WEBGL_lose_context")?.loseContext();
@@ -340,19 +395,26 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.canvas = null;
this.camera = null;
this.cameraController = null;
this.controlMode = "free";
this.cameraSnapshotReady = false;
}
private requiredApp(): Application {
if (!this.app) throw new Error("PlayCanvas runtime не смонтирован.");
if (!this.app) throw new Error("Runtime сцены не запущен.");
return this.app;
}
private requestRender(): void {
if (this.app && !this.disposed) this.app.renderNextFrame = true;
}
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);
this.requestRender();
}
private applyViewMode(): void {
@@ -360,36 +422,27 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.visualEntity.enabled = this.viewMode !== "collision" || !this.collisionEntity;
}
if (this.collisionEntity) {
this.collisionEntity.enabled = this.viewMode !== "visual";
// Physics bodies must stay active while their green debug geometry is hidden.
this.collisionEntity.enabled = true;
const visible = this.viewMode !== "visual";
for (const model of this.collisionVisuals) model.enabled = visible;
}
if (this.collisionMaterial) {
const combined = this.viewMode === "combined";
this.collisionMaterial.opacity = combined ? 0.34 : 0.82;
this.collisionMaterial.opacity = combined ? 0.34 : 1;
this.collisionMaterial.blendType = combined ? BLEND_NORMAL : BLEND_NONE;
this.collisionMaterial.depthWrite = !combined;
this.collisionMaterial.update();
}
this.requestRender();
}
private resetCameraInteraction(): void {
const controller = this.cameraController;
const canvas = this.canvas;
if (!controller || !canvas) return;
const input = controller._desktopInput;
const pointerId = input?._pointerId ?? -1;
if (pointerId >= 0 && canvas.hasPointerCapture(pointerId)) {
try {
canvas.releasePointerCapture(pointerId);
} catch {
// The browser may already have released capture after a trackpad gesture.
}
}
if (input) {
input._pointerId = -1;
input._button?.fill(0);
input._keyNow?.fill(0);
input._keyPrev?.fill(0);
input.read();
}
if (!controller) return;
// CameraControls owns pointer capture and its input-source lifecycle. Mutating
// those private fields here left the source unable to claim the next drag
// after a layer switch. The documented state event drains pending deltas.
controller.fire("state");
const state = controller._state;
if (state) {
@@ -430,45 +483,52 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.collisionAsset = asset;
await new Promise<void>((resolve, reject) => {
asset.ready((loaded) => {
if (this.disposed || !this.app) {
loaded.unload();
resolve();
return;
}
const resource = loaded.resource as ContainerResource | null;
if (!resource) {
reject(new Error("PlayCanvas не открыл collision GLB."));
return;
}
const entity = resource.instantiateRenderEntity({
castShadows: false,
receiveShadows: false,
});
entity.name = "CollisionWorld";
entity.enabled = false;
applyWorldTransform(entity, worldTransform);
app.root.addChild(entity);
const material = new StandardMaterial();
material.name = "SimulationCollisionMaterial";
material.diffuse = new Color(0.2, 0.95, 0.42);
material.emissive = new Color(0.08, 0.36, 0.14);
material.opacity = 0.34;
material.blendType = BLEND_NORMAL;
material.depthWrite = false;
material.cull = CULLFACE_NONE;
material.update();
for (const render of entity.findComponents("render") as RenderComponent[]) {
for (const meshInstance of render.meshInstances) {
meshInstance.material = material;
try {
if (this.disposed || !this.app) {
loaded.unload();
resolve();
return;
}
const resource = loaded.resource as ContainerResource | null;
if (!resource) throw new Error("Контейнер GLB не содержит ресурса.");
const entity = resource.instantiateModelEntity({
castShadows: false,
receiveShadows: false,
});
entity.name = "CollisionWorld";
entity.enabled = false;
applyWorldTransform(entity, worldTransform);
app.root.addChild(entity);
const material = new StandardMaterial();
material.name = "SimulationCollisionMaterial";
material.diffuse = new Color(0.2, 0.95, 0.42);
material.emissive = new Color(0.08, 0.36, 0.14);
material.opacity = 0.34;
material.blendType = BLEND_NORMAL;
material.depthWrite = false;
material.cull = CULLFACE_NONE;
material.update();
const models = entity.findComponents("model") as ModelComponent[];
for (const model of models) {
for (const meshInstance of model.meshInstances ?? []) {
meshInstance.material = material;
}
}
this.collisionEntity = entity;
this.collisionVisuals = models;
this.collisionMaterial = material;
this.applyViewMode();
this.requestRender();
resolve();
} catch (error) {
reject(new Error(
`Слой коллизий не открылся: ${error instanceof Error ? error.message : String(error)}`,
));
}
this.collisionEntity = entity;
this.collisionMaterial = material;
resolve();
});
asset.once("error", (error: unknown) => {
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил collision GLB."));
reject(new Error(`Слой коллизий не загрузился: ${String(error)}`));
});
app.assets.load(asset);
});
@@ -477,7 +537,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
private async ensureCollision(): Promise<void> {
if (this.collisionEntity) return;
if (!this.collisionSource) {
throw new Error("Collision GLB отсутствует в world manifest.");
throw new Error("Слой коллизий отсутствует в манифесте локации.");
}
if (!this.collisionLoadPromise) {
this.collisionLoadPromise = this.loadCollision(
@@ -492,6 +552,10 @@ export class PlayCanvasRuntime implements SimulationRuntime {
}
private unloadWorld(): void {
this.controlRequestVersion += 1;
this.ugvController?.disable();
this.controlMode = "free";
if (this.cameraController) this.cameraController.enabled = true;
if (this.visualEntity) {
this.visualEntity.destroy();
this.visualEntity = null;
@@ -505,6 +569,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.collisionEntity.destroy();
this.collisionEntity = null;
}
this.collisionVisuals = [];
if (this.collisionAsset && this.app) {
this.app.assets.remove(this.collisionAsset);
this.collisionAsset.unload();
@@ -0,0 +1,333 @@
import {
ActivityIndicator,
Button,
Icon,
IconButton,
StatusBadge,
} from "@nodedc/ui-react";
import type {
SimulationProject,
SimulationProjectStatus,
SimulationUploadProgress,
} from "../../core/simulation/projects";
import { estimateSimulationProcessing } from "../../core/simulation/projects";
const ACTIVE_STATUSES = new Set<SimulationProjectStatus>([
"uploading",
"queued",
"processing",
"importing",
]);
export function SimulationCatalog({
projects,
uploadProgress,
loading,
loadError,
queueError,
dropping,
onCreate,
onRetryLoad,
onSelect,
onEdit,
onDelete,
}: {
projects: SimulationProject[];
uploadProgress: ReadonlyMap<string, SimulationUploadProgress>;
loading: boolean;
loadError: string | null;
queueError: string | null;
dropping: boolean;
onCreate: () => void;
onRetryLoad: () => void;
onSelect: (project: SimulationProject) => void;
onEdit: (project: SimulationProject) => void;
onDelete: (project: SimulationProject) => void;
}) {
const sourceBytes = projects.reduce(
(sum, project) => sum + project.source.totalByteLength,
0,
);
return (
<section
className="simulation-catalog"
data-dropping={dropping ? "true" : undefined}
aria-label="Проекты симуляции"
>
<header className="simulation-catalog__summary">
<div><span>Проектов</span><strong>{projects.length.toLocaleString("ru-RU")}</strong></div>
<div><span>Визуал готов</span><strong>{countStatus(projects, "ready")}</strong></div>
<div><span>В работе</span><strong>{projects.filter((project) => ACTIVE_STATUSES.has(project.status)).length}</strong></div>
<div><span>Исходники</span><strong>{formatBytes(sourceBytes)}</strong></div>
</header>
{queueError ? (
<div className="simulation-catalog__notice" role="alert">
<Icon name="alert" size={18} />
<span>{queueError}</span>
</div>
) : null}
{loading && projects.length === 0 ? (
<div className="simulation-catalog__state" role="status">
<ActivityIndicator label="Загрузка каталога" />
<span>Читаем каталог сцен</span>
</div>
) : loadError && projects.length === 0 ? (
<div className="simulation-catalog__state" role="alert">
<Icon name="alert" size={20} />
<div><strong>Каталог недоступен</strong><span>{loadError}</span></div>
<Button size="compact" onClick={onRetryLoad}>Повторить</Button>
</div>
) : projects.length === 0 ? (
<div className="simulation-catalog__empty">
<span><Icon name="globe" size={20} /></span>
<strong>Перетащите архивы в это окно</strong>
<p>Каждый ZIP, RAR или 7z сразу станет отдельным проектом и встанет в очередь.</p>
<Button variant="primary" icon={<Icon name="plus" size={16} />} onClick={onCreate}>
Новый проект
</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) => (
<SimulationCatalogRow
key={project.projectId}
project={project}
uploadProgress={uploadProgress.get(project.projectId)}
onSelect={() => onSelect(project)}
onEdit={() => onEdit(project)}
onDelete={() => onDelete(project)}
/>
))}
</tbody>
</table>
</div>
)}
{dropping ? (
<div className="simulation-catalog__drop-overlay" role="status">
<span className="simulation-catalog__drop-mark"><Icon name="plus" size={28} /></span>
<strong>Отпустите архивы</strong>
<span>ZIP · RAR · 7z · до 16 ГБ на проект</span>
<small>Внутри сцена LCC или LCC2 и связанные файлы</small>
</div>
) : null}
</section>
);
}
function SimulationCatalogRow({
project,
uploadProgress,
onSelect,
onEdit,
onDelete,
}: {
project: SimulationProject;
uploadProgress?: SimulationUploadProgress;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
}) {
const status = statusPresentation(project.status);
const active = ACTIVE_STATUSES.has(project.status);
return (
<tr data-status={project.status}>
<td>
<button type="button" onClick={onSelect}>
<span className="simulation-catalog__project-icon"><Icon name="globe" size={18} /></span>
<span>
<strong>{project.name}</strong>
<small>{sourceFileLabel(project)}</small>
</span>
</button>
</td>
{active ? (
<td colSpan={3}>
<ProjectProgress project={project} localProgress={uploadProgress} />
</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}`} onClick={onDelete}>
<Icon name="trash" size={16} />
</IconButton>
<IconButton label={`Редактировать ${project.name}`} onClick={onEdit}>
<Icon name="edit" size={16} />
</IconButton>
</div>
</td>
</tr>
);
}
function ProjectProgress({
project,
localProgress,
}: {
project: SimulationProject;
localProgress?: SimulationUploadProgress;
}) {
const progress = progressPresentation(project, localProgress);
return (
<div
className="simulation-catalog__progress"
data-indeterminate={progress.indeterminate ? "true" : undefined}
role="progressbar"
aria-label={progress.label}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress.indeterminate ? undefined : progress.percent}
>
{progress.meta ? <small className="simulation-catalog__progress-meta">{progress.meta}</small> : null}
<span className="simulation-catalog__progress-track">
<i style={{ width: `${progress.percent}%` }} />
</span>
<small className="simulation-catalog__progress-stage">{progress.label}</small>
</div>
);
}
function progressPresentation(
project: SimulationProject,
local?: SimulationUploadProgress,
): { percent: number; label: string; meta: string | null; indeterminate: boolean } {
if (project.status === "uploading") {
const uploaded = local?.uploadedBytes ?? project.source.uploadedByteLength;
const total = local?.totalBytes ?? project.source.totalByteLength;
const percent = total ? Math.min(100, uploaded / total * 100) : 0;
return {
percent,
label: `${formatBytes(uploaded)} / ${formatBytes(total)}`,
meta: progressMeta(percent, local?.estimatedSecondsRemaining),
indeterminate: false,
};
}
if (project.status === "queued") {
return { percent: 0, label: "Ожидает обработки", meta: null, indeterminate: true };
}
const estimate = estimateSimulationProcessing(project);
if (estimate) {
return {
percent: estimate.percent,
label: processingLabel(project),
meta: progressMeta(estimate.percent, estimate.estimatedSecondsRemaining),
indeterminate: false,
};
}
const completed = project.provider.progress?.completed_steps;
const total = project.provider.progress?.total_steps;
if (typeof completed === "number" && typeof total === "number" && total > 0) {
const percent = Math.min(100, completed / total * 100);
return {
percent,
label: processingLabel(project),
meta: progressMeta(percent, null),
indeterminate: false,
};
}
return {
percent: project.status === "importing" ? 96 : 0,
label: processingLabel(project),
meta: project.status === "importing" ? progressMeta(96, null) : null,
indeterminate: project.status !== "importing",
};
}
function progressMeta(percent: number, seconds: number | null | undefined): string {
const roundedPercent = Math.min(100, Math.max(0, Math.floor(percent)));
return `Прогресс ${roundedPercent}% | ${remainingTimeLabel(seconds)}`;
}
function remainingTimeLabel(seconds: number | null | undefined): string {
if (seconds === null || seconds === undefined) return "Время уточняется";
if (seconds <= 60) return "Осталось меньше минуты";
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `Осталось примерно ${minutes} мин`;
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return `Осталось примерно ${hours} ч${remainder ? ` ${remainder} мин` : ""}`;
}
function processingLabel(project: SimulationProject): string {
if (project.status === "importing") return "Подготавливаем сцену";
const stage = project.provider.state;
if (stage === "verifying_source") return "Проверяем исходник";
if (stage === "inspecting") return "Анализируем локацию";
if (stage === "building_preview") return "Подготавливаем локацию";
if (stage === "building_streamed_sog") return "Собираем уровни детализации";
if (stage === "building_collision") return "Собираем коллизии";
return "Обрабатываем локацию";
}
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 === "queued") return { label: "В очереди", tone: "neutral" };
if (status === "importing") return { label: "Подготовка", tone: "accent" };
return { label: "Обработка", tone: "accent" };
}
function sourceFileLabel(project: SimulationProject): string {
const first = project.source.files[0]?.logicalPath;
if (project.source.kind === "archive" && first) return first;
return `${sceneTypeLabel(project.sceneType)} · ${project.source.files.length} файл(ов)`;
}
function sceneTypeLabel(value: SimulationProject["sceneType"]): string {
if (value === "interior") return "Интерьер";
if (value === "object") return "Объект";
return "Улица";
}
function countStatus(projects: SimulationProject[], status: SimulationProjectStatus): number {
return projects.filter((project) => project.status === status).length;
}
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 })} ГБ`;
}
@@ -18,6 +18,10 @@ import {
type SimulationUploadCandidate,
type SimulationUploadProgress,
} from "../../core/simulation/projects";
import {
candidatesFromDrop,
validateSimulationCandidates,
} from "../../core/simulation/sourceFiles";
export function SimulationProjectWindow({
open,
@@ -55,7 +59,7 @@ export function SimulationProjectWindow({
const acceptCandidates = (next: SimulationUploadCandidate[]) => {
try {
validateCandidates(next);
validateSimulationCandidates(next);
setCandidates(next);
setError(null);
if (!name.trim() && next.length) {
@@ -213,7 +217,10 @@ export function SimulationProjectWindow({
<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>
<strong>
{formatBytes(progress.uploadedBytes)} / {formatBytes(progress.totalBytes)}
{" · "}{formatUploadEta(progress.estimatedSecondsRemaining)}
</strong>
<small>{progress.currentPath}</small>
</div>
</div>
@@ -224,83 +231,15 @@ export function SimulationProjectWindow({
);
}
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 formatUploadEta(seconds: number | null): string {
if (seconds === 0) return "загружено";
if (seconds === null) return "считаем время";
if (seconds < 60) return `осталось ≈ ${Math.max(1, seconds)} сек`;
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `осталось ≈ ${minutes} мин`;
const hours = Math.floor(minutes / 60);
const remainder = minutes % 60;
return `осталось ≈ ${hours} ч${remainder ? ` ${remainder} мин` : ""}`;
}
function summarizeCandidates(candidates: SimulationUploadCandidate[]): { title: string; detail: string } {
@@ -0,0 +1,999 @@
import {
Application,
Color,
Entity,
Mat4,
Mesh,
PRIMITIVE_TRIANGLES,
Quat,
StandardMaterial,
Vec3,
WasmModule,
type ModelComponent,
} from "playcanvas";
import { MeshoptSimplifier } from "meshoptimizer/simplifier";
import type { SimulationUgvPreset } from "../../core/simulation/projects";
const AMMO_MODULE_ROOT = "/vendor/playcanvas/ammo/2026-08-25";
const UGV_SPAWN_POSITION = new Vec3(0, 1.15, 0);
const UGV_SPAWN_ROTATION = new Vec3(0, 0, 0);
const ZERO = new Vec3(0, 0, 0);
const DISABLE_DEACTIVATION = 4;
const PHYSICS_PROXY_RATIO = 0.04;
const PHYSICS_PROXY_ERROR = 0.0003;
const PHYSICS_PROXY_MAX_TRIANGLES = 240_000;
const SERVICE_BRAKE_DECELERATION_MPS2 = 1.8;
const COAST_DECELERATION_MPS2 = 0.18;
const BRAKE_ATTITUDE_DAMPING = 6;
const DEFAULT_ORBIT_PITCH = 0.48;
const CAMERA_RETURN_DELAY_SECONDS = 1.2;
const CAMERA_RETURN_DURATION_SECONDS = 2;
const UGV_SPAWN_OFFSETS: ReadonlyArray<readonly [number, number]> = [
[0, 0],
[1.5, 0],
[-1.5, 0],
[0, 1.5],
[0, -1.5],
[1.5, 1.5],
[-1.5, 1.5],
[1.5, -1.5],
[-1.5, -1.5],
[3, 0],
[-3, 0],
[0, 3],
[0, -3],
];
export const DEFAULT_SIMULATION_UGV_PRESET: SimulationUgvPreset = {
presetName: "UGV 100 кг",
massKg: 100,
dimensionsMeters: {
length: 1,
width: 0.8,
height: 0.4,
groundClearance: 0.15,
},
maxSpeedMetersPerSecond: 1.2,
maxTurnRateDegrees: 45,
invertSteering: false,
};
type NativeObject = object;
interface NativeVector3 extends NativeObject {
x(): number;
y(): number;
z(): number;
setValue(x: number, y: number, z: number): void;
}
interface NativeQuaternion extends NativeObject {
x(): number;
y(): number;
z(): number;
w(): number;
}
interface NativeTransform extends NativeObject {
getOrigin(): NativeVector3;
getRotation(): NativeQuaternion;
}
interface NativeWheelInfo extends NativeObject {
set_m_suspensionStiffness(value: number): void;
set_m_wheelsDampingRelaxation(value: number): void;
set_m_wheelsDampingCompression(value: number): void;
set_m_frictionSlip(value: number): void;
set_m_rollInfluence(value: number): void;
}
interface NativeRaycastVehicle extends NativeObject {
setCoordinateSystem(right: number, up: number, forward: number): void;
addWheel(
connectionPoint: NativeVector3,
direction: NativeVector3,
axle: NativeVector3,
suspensionRestLength: number,
radius: number,
tuning: NativeObject,
isFront: boolean,
): NativeWheelInfo;
applyEngineForce(force: number, wheel: number): void;
setBrake(force: number, wheel: number): void;
setSteeringValue(value: number, wheel: number): void;
getNumWheels(): number;
updateWheelTransform(wheel: number, interpolated: boolean): void;
getWheelTransformWS(wheel: number): NativeTransform;
getForwardVector(): NativeVector3;
getCurrentSpeedKmHour(): number;
resetSuspension(): void;
}
export interface AmmoApi {
btVehicleTuning: new () => NativeObject;
btDefaultVehicleRaycaster: new (world: NativeObject) => NativeObject;
btRaycastVehicle: new (
tuning: NativeObject,
body: NativeObject,
raycaster: NativeObject,
) => NativeRaycastVehicle;
btVector3: new (x: number, y: number, z: number) => NativeVector3;
destroy(object: NativeObject): void;
}
interface NativeDynamicsWorld extends NativeObject {
addAction(action: NativeObject): void;
removeAction(action: NativeObject): void;
}
interface PhysicsSystemAccess {
systems: {
rigidbody: {
dynamicsWorld: NativeDynamicsWorld | null;
raycastFirst(start: Vec3, end: Vec3): { point: Vec3; normal: Vec3 } | null;
};
};
}
interface NativeRigidBodyAccess {
rigidbody?: {
body: NativeObject | null;
linearVelocity: Vec3;
angularVelocity: Vec3;
teleport(position: Vec3, rotation?: Vec3 | Quat): void;
};
}
interface WheelDefinition {
connection: Vec3;
left: boolean;
front: boolean;
anchor: Entity;
}
let ammoInstancePromise: Promise<AmmoApi> | null = null;
export function prepareSimulationPhysics(): Promise<AmmoApi> {
if (ammoInstancePromise) return ammoInstancePromise;
ammoInstancePromise = new Promise<AmmoApi>((resolve, reject) => {
if (!WasmModule.getConfig("Ammo")) {
WasmModule.setConfig("Ammo", {
glueUrl: `${AMMO_MODULE_ROOT}/ammo.wasm.js`,
wasmUrl: `${AMMO_MODULE_ROOT}/ammo.wasm.wasm`,
errorHandler: (error) => {
ammoInstancePromise = null;
reject(new Error(`Физический модуль UGV не загрузился: ${String(error)}`));
},
});
}
WasmModule.getInstance("Ammo", (instance) => {
if (!instance) {
ammoInstancePromise = null;
reject(new Error("Физический модуль UGV вернул пустой экземпляр."));
return;
}
resolve(instance as AmmoApi);
});
});
return ammoInstancePromise;
}
export class SimulationUgvController {
private readonly pressed = new Set<string>();
private readonly collisionBodies: Entity[] = [];
private readonly collisionMeshes: Mesh[] = [];
private readonly wheelDefinitions: WheelDefinition[] = [];
private readonly cameraTarget = new Vec3();
private readonly cameraDesired = new Vec3();
private readonly cameraLookAt = new Vec3();
private readonly forward = new Vec3(0, 0, 1);
private readonly smoothedCamera = new Vec3();
private readonly limitedLinearVelocity = new Vec3();
private readonly limitedAngularVelocity = new Vec3();
private readonly spawnPosition = UGV_SPAWN_POSITION.clone();
private orbitPointerId: number | null = null;
private orbitPointerX = 0;
private orbitPointerY = 0;
private orbitYaw = 0;
private orbitPitch = DEFAULT_ORBIT_PITCH;
private orbitDistance = 3.8;
private orbitIdleSeconds = 0;
private cameraOrbitInitialized = false;
private active = false;
private vehicleEntity: Entity | null = null;
private vehicle: NativeRaycastVehicle | null = null;
private vehicleTuning: NativeObject | null = null;
private vehicleRaycaster: NativeObject | null = null;
private dynamicsWorld: NativeDynamicsWorld | null = null;
private chassisMaterial: StandardMaterial | null = null;
private wheelMaterial: StandardMaterial | null = null;
private cameraInitialized = false;
private settings: SimulationUgvPreset;
constructor(
private readonly app: Application,
private readonly canvas: HTMLCanvasElement,
private readonly camera: Entity,
private readonly ammo: AmmoApi,
initialSettings: SimulationUgvPreset = DEFAULT_SIMULATION_UGV_PRESET,
) {
this.settings = cloneUgvPreset(initialSettings);
}
get enabled(): boolean {
return this.active;
}
configure(settings: SimulationUgvPreset): void {
const next = cloneUgvPreset(settings);
const rebuildRequired = this.active && vehicleGeometryChanged(this.settings, next);
this.settings = next;
if (!rebuildRequired) return;
this.destroyVehicle();
this.spawnPosition.copy(this.findSafeSpawnPosition());
this.createVehicle();
this.reset(false, false);
}
async enable(collisionWorld: Entity): Promise<void> {
if (this.active) return;
await nextAnimationFrame();
try {
await this.createStaticCollisionBodies(collisionWorld);
await nextAnimationFrame();
this.spawnPosition.copy(this.findSafeSpawnPosition());
this.createVehicle();
this.attachInput();
this.active = true;
this.reset();
} catch (error) {
this.disable();
throw error;
}
}
disable(): void {
this.active = false;
this.detachInput();
this.pressed.clear();
this.destroyVehicle();
for (const entity of this.collisionBodies.splice(0)) {
runCleanup("destroy static physics proxy", () => entity.destroy());
}
for (const mesh of this.collisionMeshes.splice(0)) {
runCleanup("destroy static physics mesh", () => mesh.destroy());
}
this.cameraInitialized = false;
this.cameraOrbitInitialized = false;
this.orbitIdleSeconds = 0;
}
dispose(): void {
this.disable();
}
reset(focusCanvas = true, resetCameraOrbit = true): void {
const vehicleEntity = this.vehicleEntity as (Entity & NativeRigidBodyAccess) | null;
const rigidbody = vehicleEntity?.rigidbody;
if (!vehicleEntity || !rigidbody) return;
rigidbody.teleport(this.spawnPosition, UGV_SPAWN_ROTATION);
rigidbody.linearVelocity = ZERO;
rigidbody.angularVelocity = ZERO;
this.vehicle?.resetSuspension();
this.cameraInitialized = false;
if (resetCameraOrbit) {
this.cameraOrbitInitialized = false;
this.orbitIdleSeconds = 0;
}
this.updateCamera(1);
if (focusCanvas) this.canvas.focus({ preventScroll: true });
}
syncCollisionBodies(): void {
for (const entity of this.collisionBodies) {
const rigidbody = (entity as Entity & NativeRigidBodyAccess).rigidbody;
rigidbody?.teleport(entity.getPosition(), entity.getRotation());
}
}
update(deltaSeconds: number): void {
if (!this.active || !this.vehicle || !this.vehicleEntity) return;
const forwardInput = Number(this.isPressed("KeyW", "ArrowUp")) - Number(this.isPressed("KeyS", "ArrowDown"));
const requestedTurn = Number(this.isPressed("KeyA", "ArrowLeft")) - Number(this.isPressed("KeyD", "ArrowRight"));
const turnInput = this.settings.invertSteering ? -requestedTurn : requestedTurn;
const braking = this.pressed.has("Space");
const rigidbody = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody;
const speedMetersPerSecond = this.vehicle.getCurrentSpeedKmHour() / 3.6;
const maxSpeed = this.settings.maxSpeedMetersPerSecond;
const maxTurnRate = this.settings.maxTurnRateDegrees * Math.PI / 180;
const pureTurn = !braking && forwardInput === 0 && turnInput !== 0;
const desiredSpeed = forwardInput * maxSpeed;
const speedError = desiredSpeed - speedMetersPerSecond;
const speedResponseRange = Math.max(0.35, maxSpeed * 0.2);
const throttle = forwardInput === 0
? 0
: clamp(Math.abs(speedError) / speedResponseRange, 0, 1);
const maximumAcceleration = clamp(1.4 + maxSpeed * 0.35, 1.8, 4.2);
const driveForcePerWheel = this.settings.massKg * maximumAcceleration * throttle / 4;
const pivotForcePerWheel = this.settings.massKg * 0.65;
const forwardCommand = braking ? 0 : forwardInput;
const turnCommand = braking ? 0 : turnInput;
const leftCommand = clamp(forwardCommand - turnCommand, -1, 1);
const rightCommand = clamp(forwardCommand + turnCommand, -1, 1);
const engineForce = pureTurn ? pivotForcePerWheel : driveForcePerWheel;
for (let index = 0; index < this.wheelDefinitions.length; index += 1) {
const definition = this.wheelDefinitions[index];
const command = definition.left ? leftCommand : rightCommand;
this.vehicle.setSteeringValue(0, index);
this.vehicle.applyEngineForce(command * engineForce, index);
this.vehicle.setBrake(0, index);
this.vehicle.updateWheelTransform(index, true);
const transform = this.vehicle.getWheelTransformWS(index);
const position = transform.getOrigin();
const rotation = transform.getRotation();
definition.anchor.setPosition(position.x(), position.y(), position.z());
definition.anchor.setRotation(rotation.x(), rotation.y(), rotation.z(), rotation.w());
}
if (rigidbody) {
const linearVelocity = rigidbody.linearVelocity;
let nextLinearX = linearVelocity.x;
let nextLinearZ = linearVelocity.z;
let horizontalSpeed = Math.hypot(nextLinearX, nextLinearZ);
const coasting = !braking && forwardInput === 0 && turnInput === 0;
if ((braking || coasting) && horizontalSpeed > 0.001) {
const deceleration = braking
? SERVICE_BRAKE_DECELERATION_MPS2
: COAST_DECELERATION_MPS2;
const nextSpeed = Math.max(0, horizontalSpeed - deceleration * Math.max(0, deltaSeconds));
const scale = nextSpeed / horizontalSpeed;
nextLinearX *= scale;
nextLinearZ *= scale;
horizontalSpeed = nextSpeed;
}
if (pureTurn && horizontalSpeed > 0.001) {
const pivotDamping = Math.exp(-Math.max(0, deltaSeconds) * 8);
nextLinearX *= pivotDamping;
nextLinearZ *= pivotDamping;
horizontalSpeed *= pivotDamping;
}
if (horizontalSpeed > maxSpeed * 1.05) {
const scale = maxSpeed / horizontalSpeed;
nextLinearX *= scale;
nextLinearZ *= scale;
}
if (nextLinearX !== linearVelocity.x || nextLinearZ !== linearVelocity.z) {
this.limitedLinearVelocity.set(nextLinearX, linearVelocity.y, nextLinearZ);
rigidbody.linearVelocity = this.limitedLinearVelocity;
}
const angularVelocity = rigidbody.angularVelocity;
const attitudeDamping = braking
? Math.exp(-Math.max(0, deltaSeconds) * BRAKE_ATTITUDE_DAMPING)
: 1;
let nextAngularY = angularVelocity.y;
if (pureTurn) {
const desiredYawRate = -turnInput * maxTurnRate;
const turnAcceleration = Math.max(2, maxTurnRate * 4);
nextAngularY = approach(
angularVelocity.y,
desiredYawRate,
turnAcceleration * Math.max(0, deltaSeconds),
);
} else if (Math.abs(angularVelocity.y) > maxTurnRate) {
nextAngularY = Math.sign(angularVelocity.y) * maxTurnRate;
}
if (attitudeDamping !== 1 || nextAngularY !== angularVelocity.y) {
this.limitedAngularVelocity.set(
angularVelocity.x * attitudeDamping,
nextAngularY,
angularVelocity.z * attitudeDamping,
);
rigidbody.angularVelocity = this.limitedAngularVelocity;
}
}
const body = (this.vehicleEntity as Entity & NativeRigidBodyAccess).rigidbody?.body as {
setActivationState?: (state: number) => void;
} | null;
body?.setActivationState?.(DISABLE_DEACTIVATION);
if (this.vehicleEntity.getPosition().y < -8) this.reset();
this.updateCamera(deltaSeconds);
}
private async createStaticCollisionBodies(collisionWorld: Entity): Promise<void> {
const models = collisionWorld.findComponents("model") as ModelComponent[];
if (models.length === 0) throw new Error("В слое коллизий нет геометрии для физики UGV.");
const meshInstances = models.flatMap((model) => Array.from(model.meshInstances ?? []));
const totalTriangles = meshInstances.reduce(
(total, meshInstance) => total + Math.floor((meshInstance.mesh.primitive[0]?.count ?? 0) / 3),
0,
);
if (totalTriangles === 0) throw new Error("В слое коллизий нет треугольников для физики UGV.");
const targetRatio = totalTriangles <= PHYSICS_PROXY_MAX_TRIANGLES
? 1
: Math.min(PHYSICS_PROXY_RATIO, PHYSICS_PROXY_MAX_TRIANGLES / totalTriangles);
await MeshoptSimplifier.ready;
await nextAnimationFrame();
const rootInverse = collisionWorld.getWorldTransform().clone().invert();
for (let index = 0; index < meshInstances.length; index += 1) {
const meshInstance = meshInstances[index];
const physicsMesh = createPhysicsProxyMesh(
this.app,
meshInstance.mesh,
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
targetRatio,
);
if (!physicsMesh) continue;
const entity = new Entity(`UGV physics proxy ${index + 1}`);
collisionWorld.addChild(entity);
entity.addComponent("collision", {
type: "mesh",
render: { meshes: [physicsMesh] },
checkVertexDuplicates: false,
});
entity.addComponent("rigidbody", {
type: "static",
friction: 0.95,
restitution: 0,
});
this.collisionBodies.push(entity);
this.collisionMeshes.push(physicsMesh);
if ((index + 1) % 4 === 0) await nextAnimationFrame();
}
if (this.collisionBodies.length === 0) {
throw new Error("Не удалось создать статический физический слой UGV.");
}
}
private createVehicle(): void {
const access = this.app as unknown as PhysicsSystemAccess;
const dynamicsWorld = access.systems.rigidbody.dynamicsWorld;
if (!dynamicsWorld) throw new Error("Физический мир PlayCanvas не готов.");
const vehicle = new Entity("SimulationUGV");
vehicle.addComponent("collision", { type: "compound" });
vehicle.addComponent("rigidbody", {
type: "dynamic",
mass: this.settings.massKg,
friction: 0.85,
linearDamping: 0.08,
angularDamping: 0.45,
});
this.chassisMaterial = createMaterial(readThemeAccent(), new Color(0.03, 0.04, 0.05));
this.wheelMaterial = createMaterial(new Color(0.055, 0.06, 0.07), new Color(0.008, 0.009, 0.01));
const dimensions = vehicleDimensions(this.settings);
const chassis = createBox(
"UGV chassis",
new Vec3(dimensions.bodyWidth, dimensions.mainHeight, dimensions.length),
this.chassisMaterial,
);
chassis.setLocalPosition(0, dimensions.mainCenterY, 0);
chassis.addComponent("collision", {
type: "box",
halfExtents: new Vec3(
dimensions.bodyWidth / 2,
dimensions.mainHeight / 2,
dimensions.length / 2,
),
});
vehicle.addChild(chassis);
const equipment = createBox(
"UGV equipment",
new Vec3(dimensions.bodyWidth * 0.68, dimensions.equipmentHeight, dimensions.length * 0.56),
this.chassisMaterial,
);
equipment.setLocalPosition(0, dimensions.equipmentCenterY, -dimensions.length * 0.04);
equipment.addComponent("collision", {
type: "box",
halfExtents: new Vec3(
dimensions.bodyWidth * 0.34,
dimensions.equipmentHeight / 2,
dimensions.length * 0.28,
),
});
vehicle.addChild(equipment);
const wheelConnections = [
{ connection: new Vec3(dimensions.wheelX, dimensions.wheelConnectionY, dimensions.wheelZ), left: false, front: true },
{ connection: new Vec3(-dimensions.wheelX, dimensions.wheelConnectionY, dimensions.wheelZ), left: true, front: true },
{ connection: new Vec3(dimensions.wheelX, dimensions.wheelConnectionY, -dimensions.wheelZ), left: false, front: false },
{ connection: new Vec3(-dimensions.wheelX, dimensions.wheelConnectionY, -dimensions.wheelZ), left: true, front: false },
];
for (const definition of wheelConnections) {
const anchor = new Entity(definition.left ? "UGV wheel left" : "UGV wheel right");
anchor.setLocalPosition(definition.connection);
const wheelMesh = new Entity("UGV wheel mesh");
wheelMesh.addComponent("render", { type: "cylinder", castShadows: false, receiveShadows: false });
wheelMesh.setLocalEulerAngles(0, 0, 90);
wheelMesh.setLocalScale(
dimensions.wheelRadius * 2,
dimensions.wheelThickness,
dimensions.wheelRadius * 2,
);
applyMaterial(wheelMesh, this.wheelMaterial);
anchor.addChild(wheelMesh);
vehicle.addChild(anchor);
this.wheelDefinitions.push({ ...definition, anchor });
}
vehicle.setLocalPosition(this.spawnPosition);
this.app.root.addChild(vehicle);
const body = (vehicle as Entity & NativeRigidBodyAccess).rigidbody?.body;
if (!body) {
vehicle.destroy();
throw new Error("Динамическое тело UGV не создалось.");
}
const tuning = new this.ammo.btVehicleTuning();
const raycaster = new this.ammo.btDefaultVehicleRaycaster(dynamicsWorld);
const nativeVehicle = new this.ammo.btRaycastVehicle(tuning, body, raycaster);
nativeVehicle.setCoordinateSystem(0, 1, 2);
const axle = new this.ammo.btVector3(-1, 0, 0);
const direction = new this.ammo.btVector3(0, -1, 0);
const connection = new this.ammo.btVector3(0, 0, 0);
for (const definition of this.wheelDefinitions) {
connection.setValue(definition.connection.x, definition.connection.y, definition.connection.z);
const wheel = nativeVehicle.addWheel(
connection,
direction,
axle,
dimensions.suspensionRestLength,
dimensions.wheelRadius,
tuning,
definition.front,
);
wheel.set_m_suspensionStiffness(24);
wheel.set_m_wheelsDampingRelaxation(3.2);
wheel.set_m_wheelsDampingCompression(4.8);
wheel.set_m_frictionSlip(5.5);
wheel.set_m_rollInfluence(0.08);
}
this.ammo.destroy(axle);
this.ammo.destroy(direction);
this.ammo.destroy(connection);
dynamicsWorld.addAction(nativeVehicle);
this.vehicleEntity = vehicle;
this.vehicleTuning = tuning;
this.vehicleRaycaster = raycaster;
this.vehicle = nativeVehicle;
this.dynamicsWorld = dynamicsWorld;
}
private updateCamera(deltaSeconds: number): void {
if (!this.vehicleEntity || !this.vehicle) return;
this.cameraTarget.copy(this.vehicleEntity.getPosition());
this.cameraLookAt.copy(this.cameraTarget);
this.cameraLookAt.y += Math.max(0.15, this.settings.dimensionsMeters.height * 0.35);
const nativeForward = this.vehicle.getForwardVector();
this.forward.set(nativeForward.x(), 0, nativeForward.z());
if (this.forward.lengthSq() < 0.001) this.forward.set(0, 0, 1);
this.forward.normalize();
const rearOrbitYaw = Math.atan2(-this.forward.x, -this.forward.z);
if (!this.cameraOrbitInitialized) {
this.orbitYaw = rearOrbitYaw;
this.orbitPitch = DEFAULT_ORBIT_PITCH;
this.orbitDistance = Math.max(3.2, this.settings.dimensionsMeters.length * 3.8);
this.cameraOrbitInitialized = true;
this.orbitIdleSeconds = 0;
} else if (this.orbitPointerId === null) {
this.orbitIdleSeconds += Math.max(0, deltaSeconds);
if (this.orbitIdleSeconds >= CAMERA_RETURN_DELAY_SECONDS) {
const returnDelta = Math.max(0, deltaSeconds) / CAMERA_RETURN_DURATION_SECONDS;
this.orbitYaw = approachAngle(this.orbitYaw, rearOrbitYaw, Math.PI * returnDelta);
this.orbitPitch = approach(
this.orbitPitch,
DEFAULT_ORBIT_PITCH,
Math.PI * 0.5 * returnDelta,
);
}
}
const horizontalDistance = Math.cos(this.orbitPitch) * this.orbitDistance;
this.cameraDesired.set(
this.cameraLookAt.x + Math.sin(this.orbitYaw) * horizontalDistance,
this.cameraLookAt.y + Math.sin(this.orbitPitch) * this.orbitDistance,
this.cameraLookAt.z + Math.cos(this.orbitYaw) * horizontalDistance,
);
if (!this.cameraInitialized) {
this.smoothedCamera.copy(this.cameraDesired);
this.cameraInitialized = true;
} else {
const blend = 1 - Math.exp(
-Math.max(0, deltaSeconds) * (this.orbitPointerId === null ? 8 : 18),
);
this.smoothedCamera.lerp(this.smoothedCamera, this.cameraDesired, blend);
}
this.camera.setPosition(this.smoothedCamera);
this.camera.lookAt(this.cameraLookAt);
}
private findSafeSpawnPosition(): Vec3 {
const dimensions = vehicleDimensions(this.settings);
const footprint: ReadonlyArray<readonly [number, number]> = [
[0, 0],
[dimensions.width * 0.42, dimensions.length * 0.42],
[-dimensions.width * 0.42, dimensions.length * 0.42],
[dimensions.width * 0.42, -dimensions.length * 0.42],
[-dimensions.width * 0.42, -dimensions.length * 0.42],
];
const clearanceDirections: ReadonlyArray<readonly [number, number]> = [
[dimensions.width * 0.62, 0],
[-dimensions.width * 0.62, 0],
[0, dimensions.length * 0.62],
[0, -dimensions.length * 0.62],
];
const from = new Vec3();
const to = new Vec3();
const physics = (this.app as unknown as PhysicsSystemAccess).systems.rigidbody;
for (const [offsetX, offsetZ] of UGV_SPAWN_OFFSETS) {
const heights: number[] = [];
let suitable = true;
for (const [sampleX, sampleZ] of footprint) {
const x = UGV_SPAWN_POSITION.x + offsetX + sampleX;
const z = UGV_SPAWN_POSITION.z + offsetZ + sampleZ;
from.set(x, 8, z);
to.set(x, -8, z);
const hit = physics.raycastFirst(from, to);
if (!hit || hit.normal.y < 0.72 || Math.abs(hit.point.y) > 2.5) {
suitable = false;
break;
}
heights.push(hit.point.y);
}
if (!suitable || heights.length !== footprint.length) continue;
const minimum = Math.min(...heights);
const maximum = Math.max(...heights);
if (maximum - minimum > 0.32) continue;
const x = UGV_SPAWN_POSITION.x + offsetX;
const z = UGV_SPAWN_POSITION.z + offsetZ;
const ground = heights.reduce((sum, height) => sum + height, 0) / heights.length;
const clearanceHeight = ground + dimensions.originHeight;
const origin = new Vec3(x, clearanceHeight, z);
const blocked = clearanceDirections.some(([directionX, directionZ]) => {
to.set(x + directionX, clearanceHeight, z + directionZ);
return physics.raycastFirst(origin, to) !== null;
});
if (!blocked) return new Vec3(x, ground + dimensions.originHeight, z);
}
return UGV_SPAWN_POSITION.clone();
}
private destroyVehicle(): void {
if (this.vehicle && this.dynamicsWorld) {
runCleanup("remove vehicle action", () => this.dynamicsWorld?.removeAction(this.vehicle as NativeObject));
}
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
if (this.vehicleTuning) runCleanup("destroy vehicle tuning", () => this.ammo.destroy(this.vehicleTuning as NativeObject));
this.vehicle = null;
this.vehicleRaycaster = null;
this.vehicleTuning = null;
this.dynamicsWorld = null;
if (this.vehicleEntity) runCleanup("destroy vehicle entity", () => this.vehicleEntity?.destroy());
this.vehicleEntity = null;
this.wheelDefinitions.length = 0;
if (this.chassisMaterial) runCleanup("destroy chassis material", () => this.chassisMaterial?.destroy());
if (this.wheelMaterial) runCleanup("destroy wheel material", () => this.wheelMaterial?.destroy());
this.chassisMaterial = null;
this.wheelMaterial = null;
this.cameraInitialized = false;
}
private isPressed(primary: string, alternative: string): boolean {
return this.pressed.has(primary) || this.pressed.has(alternative);
}
private attachInput(): void {
window.addEventListener("keydown", this.onKeyDown, true);
window.addEventListener("keyup", this.onKeyUp, true);
window.addEventListener("blur", this.onWindowBlur);
this.canvas.addEventListener("pointerdown", this.onOrbitPointerDown);
this.canvas.addEventListener("pointermove", this.onOrbitPointerMove);
this.canvas.addEventListener("pointerup", this.onOrbitPointerUp);
this.canvas.addEventListener("pointercancel", this.onOrbitPointerUp);
this.canvas.addEventListener("wheel", this.onOrbitWheel, { passive: false });
}
private detachInput(): void {
window.removeEventListener("keydown", this.onKeyDown, true);
window.removeEventListener("keyup", this.onKeyUp, true);
window.removeEventListener("blur", this.onWindowBlur);
this.canvas.removeEventListener("pointerdown", this.onOrbitPointerDown);
this.canvas.removeEventListener("pointermove", this.onOrbitPointerMove);
this.canvas.removeEventListener("pointerup", this.onOrbitPointerUp);
this.canvas.removeEventListener("pointercancel", this.onOrbitPointerUp);
this.canvas.removeEventListener("wheel", this.onOrbitWheel);
this.releaseOrbitPointer();
}
private onOrbitPointerDown = (event: PointerEvent): void => {
if (!this.active || event.button !== 0 || this.orbitPointerId !== null) return;
event.preventDefault();
this.orbitPointerId = event.pointerId;
this.orbitPointerX = event.clientX;
this.orbitPointerY = event.clientY;
this.orbitIdleSeconds = 0;
this.canvas.classList.add("is-orbiting");
try {
this.canvas.setPointerCapture(event.pointerId);
} catch {
// A browser may already have released the pointer during a window transition.
}
};
private onOrbitPointerMove = (event: PointerEvent): void => {
if (!this.active || event.pointerId !== this.orbitPointerId) return;
event.preventDefault();
const deltaX = event.clientX - this.orbitPointerX;
const deltaY = event.clientY - this.orbitPointerY;
this.orbitPointerX = event.clientX;
this.orbitPointerY = event.clientY;
this.orbitIdleSeconds = 0;
this.orbitYaw = (this.orbitYaw - deltaX * 0.006) % (Math.PI * 2);
this.orbitPitch = clamp(this.orbitPitch + deltaY * 0.0045, -0.12, 1.32);
};
private onOrbitPointerUp = (event: PointerEvent): void => {
if (event.pointerId !== this.orbitPointerId) return;
this.releaseOrbitPointer();
};
private onOrbitWheel = (event: WheelEvent): void => {
if (!this.active) return;
event.preventDefault();
this.orbitIdleSeconds = 0;
this.orbitDistance = clamp(
this.orbitDistance * Math.exp(event.deltaY * 0.0012),
1.6,
14,
);
};
private releaseOrbitPointer(): void {
const pointerId = this.orbitPointerId;
this.orbitPointerId = null;
this.canvas.classList.remove("is-orbiting");
if (pointerId === null || !this.canvas.hasPointerCapture(pointerId)) return;
try {
this.canvas.releasePointerCapture(pointerId);
} catch {
// Pointer capture is already gone; the local orbit state is still released.
}
}
private onKeyDown = (event: KeyboardEvent): void => {
if (!this.active || isTextEntry(event.target)) return;
if (!UGV_KEYS.has(event.code)) return;
event.preventDefault();
event.stopPropagation();
if (event.code === "KeyR") {
this.reset();
return;
}
this.pressed.add(event.code);
};
private onKeyUp = (event: KeyboardEvent): void => {
if (!this.active || !UGV_KEYS.has(event.code)) return;
event.preventDefault();
event.stopPropagation();
this.pressed.delete(event.code);
};
private onWindowBlur = (): void => {
this.pressed.clear();
this.releaseOrbitPointer();
};
}
const UGV_KEYS = new Set([
"KeyW",
"KeyA",
"KeyS",
"KeyD",
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Space",
"KeyR",
]);
function createPhysicsProxyMesh(
app: Application,
source: Mesh,
localTransform: Mat4,
targetRatio: number,
): Mesh | null {
const primitive = source.primitive[0];
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
if (!primitive || sourceVertexCount === 0 || primitive.count < 3) return null;
const positions = new Float32Array(sourceVertexCount * 3);
const populatedVertices = source.getPositions(positions);
if (populatedVertices === 0) return null;
const transformed = new Float32Array(populatedVertices * 3);
const point = new Vec3();
for (let vertex = 0; vertex < populatedVertices; vertex += 1) {
const offset = vertex * 3;
point.set(positions[offset], positions[offset + 1], positions[offset + 2]);
localTransform.transformPoint(point, point);
transformed[offset] = point.x;
transformed[offset + 1] = point.y;
transformed[offset + 2] = point.z;
}
const requestedIndexCount = Math.max(3, primitive.count);
let indices = new Uint32Array(requestedIndexCount);
const populatedIndices = source.getIndices(indices);
if (populatedIndices === 0) {
const fallbackCount = Math.min(populatedVertices, requestedIndexCount);
indices = new Uint32Array(fallbackCount);
for (let index = 0; index < fallbackCount; index += 1) indices[index] = index;
} else if (populatedIndices !== indices.length) {
indices = indices.slice(0, populatedIndices);
}
const triangleIndexCount = Math.floor(indices.length / 3) * 3;
if (triangleIndexCount < 3) return null;
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
if (targetRatio < 1) {
const targetIndexCount = Math.max(3, Math.floor(indices.length * targetRatio / 3) * 3);
const [simplifiedIndices] = MeshoptSimplifier.simplify(
indices,
transformed,
3,
targetIndexCount,
PHYSICS_PROXY_ERROR,
);
indices = new Uint32Array(simplifiedIndices);
}
const compactedIndices = new Uint32Array(indices);
const [remap, compactedVertexCount] = MeshoptSimplifier.compactMesh(compactedIndices);
const compactedPositions = new Float32Array(compactedVertexCount * 3);
const missing = 0xffff_ffff;
for (let sourceVertex = 0; sourceVertex < remap.length; sourceVertex += 1) {
const compactedVertex = remap[sourceVertex];
if (compactedVertex === missing) continue;
const sourceOffset = sourceVertex * 3;
const compactedOffset = compactedVertex * 3;
compactedPositions[compactedOffset] = transformed[sourceOffset];
compactedPositions[compactedOffset + 1] = transformed[sourceOffset + 1];
compactedPositions[compactedOffset + 2] = transformed[sourceOffset + 2];
}
const physicsMesh = new Mesh(app.graphicsDevice);
physicsMesh.setPositions(compactedPositions);
physicsMesh.setIndices(compactedIndices);
physicsMesh.update(PRIMITIVE_TRIANGLES, true);
return physicsMesh;
}
function cloneUgvPreset(preset: SimulationUgvPreset): SimulationUgvPreset {
return {
...preset,
dimensionsMeters: { ...preset.dimensionsMeters },
};
}
function vehicleGeometryChanged(
current: SimulationUgvPreset,
next: SimulationUgvPreset,
): boolean {
return current.massKg !== next.massKg
|| current.dimensionsMeters.length !== next.dimensionsMeters.length
|| current.dimensionsMeters.width !== next.dimensionsMeters.width
|| current.dimensionsMeters.height !== next.dimensionsMeters.height
|| current.dimensionsMeters.groundClearance !== next.dimensionsMeters.groundClearance;
}
function vehicleDimensions(settings: SimulationUgvPreset) {
const { length, width, height, groundClearance } = settings.dimensionsMeters;
const bodyHeight = height - groundClearance;
const wheelRadius = clamp(
groundClearance * 0.8,
0.07,
Math.min(height * 0.4, length * 0.22),
);
const wheelThickness = clamp(width * 0.13, 0.08, 0.16);
const bodyWidth = Math.max(0.12, width - wheelThickness * 2);
const mainHeight = bodyHeight * 0.68;
const equipmentHeight = bodyHeight - mainHeight;
const suspensionRestLength = clamp(groundClearance * 0.4, 0.04, 0.16);
const originHeight = groundClearance + bodyHeight / 2;
return {
length,
width,
bodyWidth,
mainHeight,
equipmentHeight,
mainCenterY: -bodyHeight / 2 + mainHeight / 2,
equipmentCenterY: bodyHeight / 2 - equipmentHeight / 2,
wheelRadius,
wheelThickness,
wheelX: width / 2 - wheelThickness / 2,
wheelZ: Math.max(0, length / 2 - wheelRadius),
wheelConnectionY: suspensionRestLength + wheelRadius - originHeight,
suspensionRestLength,
originHeight,
};
}
function createBox(name: string, scale: Vec3, material: StandardMaterial): Entity {
const entity = new Entity(name);
entity.addComponent("render", { type: "box", castShadows: false, receiveShadows: false });
entity.setLocalScale(scale);
applyMaterial(entity, material);
return entity;
}
function applyMaterial(entity: Entity, material: StandardMaterial): void {
for (const meshInstance of entity.render?.meshInstances ?? []) meshInstance.material = material;
}
function createMaterial(diffuse: Color, emissive: Color): StandardMaterial {
const material = new StandardMaterial();
material.diffuse = diffuse;
material.emissive = emissive;
material.metalness = 0.15;
material.gloss = 0.32;
material.update();
return material;
}
function readThemeAccent(): Color {
const raw = getComputedStyle(document.documentElement).getPropertyValue("--nodedc-accent-rgb");
const channels = raw.trim().split(/[\s,]+/).map(Number);
if (channels.length < 3 || channels.some((value) => !Number.isFinite(value))) {
return new Color(0.22, 0.72, 0.38);
}
return new Color(channels[0] / 255, channels[1] / 255, channels[2] / 255);
}
function isTextEntry(target: EventTarget | null): boolean {
return target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement
|| target instanceof HTMLSelectElement || target instanceof HTMLButtonElement
|| (target instanceof HTMLElement && target.isContentEditable);
}
function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); }
function approach(current: number, target: number, maximumDelta: number): number {
if (current < target) return Math.min(target, current + maximumDelta);
if (current > target) return Math.max(target, current - maximumDelta);
return target;
}
function approachAngle(current: number, target: number, maximumDelta: number): number {
const delta = ((target - current + Math.PI) % (Math.PI * 2) + Math.PI * 2) % (Math.PI * 2) - Math.PI;
return current + clamp(delta, -maximumDelta, maximumDelta);
}
function runCleanup(label: string, cleanup: () => void): void {
try {
cleanup();
} catch (error) {
console.warn(`[Simulation UGV] Cleanup failed: ${label}`, error);
}
}
function nextAnimationFrame(): Promise<void> { return new Promise((resolve) => requestAnimationFrame(() => resolve())); }
@@ -0,0 +1,191 @@
import {
Button,
Icon,
RangeControl,
Switch,
TextField,
} from "@nodedc/ui-react";
import {
SIMULATION_UGV_EXACT_VALUE_LIMITS,
type SimulationUgvPreset,
} from "../../core/simulation/projects";
export type SimulationUgvPresetSaveState = "idle" | "saving" | "saved" | "failed";
export function SimulationUgvSettingsPanel({
value,
disabled,
saveState,
onChange,
onSave,
}: {
value: SimulationUgvPreset;
disabled: boolean;
saveState: SimulationUgvPresetSaveState;
onChange: (preset: SimulationUgvPreset) => void;
onSave: () => void;
}) {
const dimensions = value.dimensionsMeters;
const updateDimensions = (
patch: Partial<SimulationUgvPreset["dimensionsMeters"]>,
) => {
const next = { ...dimensions, ...patch };
next.groundClearance = Math.max(
SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.min,
Math.min(next.groundClearance, roundToHundredth(next.height - 0.05)),
);
onChange({ ...value, dimensionsMeters: next });
};
return (
<section className="simulation-viewport__settings-group simulation-ugv-settings">
<div className="simulation-viewport__settings-group-head">
<strong>Физика UGV</strong>
<span>Дифференциальный привод</span>
</div>
<p>
A/D без W/S разворачивают UGV вокруг собственного центра. Скорость и
динамика применяются сразу. Мышь вращает камеру вокруг машины, колесо
меняет дистанцию; после паузы камера плавно возвращается назад. Ползунок
работает в штатном диапазоне, точное значение можно ввести вручную.
</p>
<TextField
label="Название пресета"
value={value.presetName}
maxLength={80}
disabled={disabled}
spellCheck={false}
onChange={(event) => onChange({ ...value, presetName: event.currentTarget.value })}
/>
<RangeControl
label="Масса конструкции"
value={value.massKg}
min={10}
max={500}
exactValueBounds={SIMULATION_UGV_EXACT_VALUE_LIMITS.massKg}
step={5}
disabled={disabled}
formatValue={(mass) => `${Math.round(mass)} кг`}
onChange={(massKg) => onChange({ ...value, massKg })}
/>
<div className="simulation-ugv-settings__subhead">
<strong>Габариты</strong>
<span>В метрах</span>
</div>
<RangeControl
label="Длина"
value={dimensions.length}
min={0.4}
max={3}
exactValueBounds={SIMULATION_UGV_EXACT_VALUE_LIMITS.length}
step={0.05}
disabled={disabled}
formatValue={formatMeters}
onChange={(length) => updateDimensions({ length })}
/>
<RangeControl
label="Ширина"
value={dimensions.width}
min={0.3}
max={2}
exactValueBounds={SIMULATION_UGV_EXACT_VALUE_LIMITS.width}
step={0.05}
disabled={disabled}
formatValue={formatMeters}
onChange={(width) => updateDimensions({ width })}
/>
<RangeControl
label="Общая высота"
value={dimensions.height}
min={0.2}
max={2}
exactValueBounds={SIMULATION_UGV_EXACT_VALUE_LIMITS.height}
step={0.05}
disabled={disabled}
formatValue={formatMeters}
onChange={(height) => updateDimensions({ height })}
/>
<RangeControl
label="Клиренс"
value={dimensions.groundClearance}
min={0.03}
max={Math.max(0.03, Math.min(0.6, dimensions.height - 0.05))}
exactValueBounds={{
min: SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.min,
max: Math.min(
SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.max,
dimensions.height - 0.05,
),
}}
step={0.01}
disabled={disabled}
formatValue={formatMeters}
onChange={(groundClearance) => updateDimensions({ groundClearance })}
/>
<div className="simulation-ugv-settings__subhead">
<strong>Динамика</strong>
<span>Ограничители движения</span>
</div>
<RangeControl
label="Скорость движения"
value={value.maxSpeedMetersPerSecond}
min={0.1}
max={8}
exactValueBounds={SIMULATION_UGV_EXACT_VALUE_LIMITS.maxSpeedMetersPerSecond}
step={0.1}
disabled={disabled}
formatValue={(speed) => `${formatDecimal(speed, 1)} м/с`}
onChange={(maxSpeedMetersPerSecond) => onChange({
...value,
maxSpeedMetersPerSecond,
})}
/>
<RangeControl
label="Скорость разворота"
value={value.maxTurnRateDegrees}
min={5}
max={180}
exactValueBounds={SIMULATION_UGV_EXACT_VALUE_LIMITS.maxTurnRateDegrees}
step={5}
disabled={disabled}
formatValue={(rate) => `${Math.round(rate)}°/с`}
onChange={(maxTurnRateDegrees) => onChange({ ...value, maxTurnRateDegrees })}
/>
<Switch
checked={value.invertSteering}
disabled={disabled}
label="Инверсия управления A/D"
onChange={(invertSteering) => onChange({ ...value, invertSteering })}
/>
<Button
className="simulation-ugv-settings__save"
size="compact"
variant="secondary"
icon={<Icon name="save" size={16} />}
disabled={disabled || saveState === "saving" || value.presetName.trim() === ""}
onClick={onSave}
>
{saveState === "saving"
? "Сохраняем…"
: saveState === "saved"
? "Пресет сохранён"
: saveState === "failed"
? "Повторить сохранение"
: "Сохранить пресет"}
</Button>
</section>
);
}
function formatMeters(value: number): string {
return `${formatDecimal(value, 2)} м`;
}
function formatDecimal(value: number, digits: number): string {
return value.toFixed(digits).replace(".", ",");
}
function roundToHundredth(value: number): number {
return Math.round(value * 100) / 100;
}
@@ -2,18 +2,21 @@ import { useEffect, useId, useRef, useState } from "react";
import {
ActivityIndicator,
Button,
GlassMaterialSurface,
Icon,
IconButton,
SegmentedControl,
Select,
StatusBadge,
Switch,
TextField,
} from "@nodedc/ui-react";
import {
saveSimulationViewerSettings,
type SimulationEulerRotation,
type SimulationProject,
type SimulationTransformAxis,
type SimulationUgvPreset,
type SimulationViewerSettings,
} from "../../core/simulation/projects";
import {
@@ -22,16 +25,17 @@ import {
} from "../../core/runtime/latestAsyncCommitter";
import {
PlayCanvasRuntime,
playCanvasAxisTransform,
playCanvasEulerTransform,
type SimulationControlMode,
type SimulationQuality,
type SimulationViewMode,
} from "./PlayCanvasRuntime";
import {
SimulationUgvSettingsPanel,
type SimulationUgvPresetSaveState,
} from "./SimulationUgvSettingsPanel";
const AXIS_OPTIONS: Array<{ value: SimulationTransformAxis; label: string }> = [
{ value: "x", label: "X" },
{ value: "y", label: "Y" },
{ value: "z", label: "Z" },
];
const ROTATION_AXES = ["x", "y", "z"] as const;
export function SimulationViewport({
project,
@@ -44,23 +48,34 @@ export function SimulationViewport({
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
const viewerSettingsRef = useRef(project.viewerSettings);
const settingsCommitterRef = useRef<LatestAsyncCommitter<SimulationViewerSettings> | null>(null);
const settingsCommitSucceededRef = useRef(true);
const ugvSaveRequestRef = useRef(0);
const controlRequestRef = useRef(0);
const settingsId = useId();
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
const [error, setError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
const [controlMode, setControlMode] = useState<SimulationControlMode>("free");
const [controlState, setControlState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const [quality, setQuality] = useState<SimulationQuality>(project.viewerSettings.quality);
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
const [settingsOpen, setSettingsOpen] = useState(false);
const [visualInverted, setVisualInverted] = useState(project.viewerSettings.visual.inverted);
const [visualAxis, setVisualAxis] = useState(project.viewerSettings.visual.axis);
const [collisionInverted, setCollisionInverted] = useState(project.viewerSettings.collision.inverted);
const [collisionAxis, setCollisionAxis] = useState(project.viewerSettings.collision.axis);
const [visualRotation, setVisualRotation] = useState({
...project.viewerSettings.visual.rotationDegrees,
});
const [collisionRotation, setCollisionRotation] = useState({
...project.viewerSettings.collision.rotationDegrees,
});
const [horizontalInverted, setHorizontalInverted] = useState(
project.viewerSettings.camera.invertHorizontal,
);
const [verticalInverted, setVerticalInverted] = useState(
project.viewerSettings.camera.invertVertical,
);
const [ugvDraft, setUgvDraft] = useState<SimulationUgvPreset>(() =>
cloneUgvPreset(project.viewerSettings.ugv),
);
const [ugvSaveState, setUgvSaveState] = useState<SimulationUgvPresetSaveState>("idle");
const [settingsError, setSettingsError] = useState<string | null>(null);
const worldManifestRevision = JSON.stringify(project.worldManifest);
@@ -68,14 +83,18 @@ export function SimulationViewport({
const settings = project.viewerSettings;
viewerSettingsRef.current = settings;
setQuality(settings.quality);
setVisualInverted(settings.visual.inverted);
setVisualAxis(settings.visual.axis);
setCollisionInverted(settings.collision.inverted);
setCollisionAxis(settings.collision.axis);
setVisualRotation({ ...settings.visual.rotationDegrees });
setCollisionRotation({ ...settings.collision.rotationDegrees });
setHorizontalInverted(settings.camera.invertHorizontal);
setVerticalInverted(settings.camera.invertVertical);
setUgvDraft(cloneUgvPreset(settings.ugv));
setUgvSaveState("idle");
ugvSaveRequestRef.current += 1;
setSettingsError(null);
setSettingsOpen(false);
controlRequestRef.current += 1;
setControlMode("free");
setControlState("idle");
}, [project.projectId]);
useEffect(() => {
@@ -84,9 +103,11 @@ export function SimulationViewport({
async commit(settings) {
try {
const saved = await saveSimulationViewerSettings(project.projectId, settings);
settingsCommitSucceededRef.current = true;
if (active) onProjectChange?.(saved);
return true;
} catch (caught) {
settingsCommitSucceededRef.current = false;
if (active) {
setSettingsError(
caught instanceof Error ? caught.message : "Не удалось сохранить настройки сцены.",
@@ -122,15 +143,16 @@ export function SimulationViewport({
runtime.setQuality(settings.quality);
runtime.setCameraInversion("horizontal", settings.camera.invertHorizontal);
runtime.setCameraInversion("vertical", settings.camera.invertVertical);
runtime.setUgvPreset(settings.ugv);
setState("loading");
await runtime.loadWorld(manifest);
runtime.setLayerWorldTransform(
"visual",
playCanvasAxisTransform(settings.visual.axis, settings.visual.inverted),
playCanvasEulerTransform(settings.visual.rotationDegrees),
);
runtime.setLayerWorldTransform(
"collision",
playCanvasAxisTransform(settings.collision.axis, settings.collision.inverted),
playCanvasEulerTransform(settings.collision.rotationDegrees),
);
if (!cancelled) setState("ready");
}).catch((caught: unknown) => {
@@ -140,6 +162,7 @@ export function SimulationViewport({
});
return () => {
cancelled = true;
controlRequestRef.current += 1;
runtime.dispose();
runtimeRef.current = null;
};
@@ -151,25 +174,89 @@ export function SimulationViewport({
const collisionAvailable = project.worldManifest?.collision.available ?? false;
const commitViewerSettings = (settings: SimulationViewerSettings) => {
viewerSettingsRef.current = settings;
settingsCommitSucceededRef.current = false;
settingsCommitterRef.current?.enqueue(settings);
};
const changeUgvDraft = (next: SimulationUgvPreset) => {
setUgvDraft(cloneUgvPreset(next));
setUgvSaveState("idle");
ugvSaveRequestRef.current += 1;
runtimeRef.current?.setUgvPreset(next);
};
const saveUgvPreset = () => {
const committer = settingsCommitterRef.current;
if (!committer) {
setUgvSaveState("failed");
return;
}
const request = ++ugvSaveRequestRef.current;
const normalized = cloneUgvPreset({
...ugvDraft,
presetName: ugvDraft.presetName.trim(),
});
setUgvDraft(normalized);
setUgvSaveState("saving");
setSettingsError(null);
settingsCommitSucceededRef.current = false;
const settings = { ...viewerSettingsRef.current, ugv: normalized };
viewerSettingsRef.current = settings;
committer.enqueue(settings);
void committer.waitForIdle().then(() => {
if (request !== ugvSaveRequestRef.current) return;
setUgvSaveState(settingsCommitSucceededRef.current ? "saved" : "failed");
});
};
const changeControlMode = (next: SimulationControlMode) => {
const runtime = runtimeRef.current;
if (!runtime) return;
const request = ++controlRequestRef.current;
setControlMode(next);
setControlState(next === "ugv" ? "loading" : "idle");
if (next === "ugv" && collisionState !== "ready") setCollisionState("loading");
void runtime.setControlMode(next).then(() => {
if (request !== controlRequestRef.current) return;
if (next === "ugv") {
setControlState("ready");
setCollisionState("ready");
}
}).catch((caught: unknown) => {
if (request !== controlRequestRef.current) return;
setControlMode("free");
setControlState("failed");
setCollisionState("failed");
setError(caught instanceof Error ? caught.message : "Не удалось запустить UGV.");
});
};
return (
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
<section
className="simulation-viewport"
data-control-mode={controlMode}
aria-label={`Сцена ${project.name}`}
>
<header className="simulation-viewport__toolbar">
<div>
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
{collisionState === "loading"
? "Загрузка collision"
{controlState === "loading"
? "Подготовка UGV"
: controlState === "ready"
? "UGV готов"
: controlState === "failed"
? "Ошибка UGV"
: collisionState === "loading"
? "Загрузка коллизий"
: collisionState === "failed"
? "Ошибка collision"
? "Ошибка коллизий"
: state === "ready"
? "Runtime готов"
: state === "failed"
? "Ошибка runtime"
: "Загрузка сцены"}
: "Загрузка локации"}
</StatusBadge>
<span>PlayCanvas Engine 2.21.4</span>
<span>Engine 2.21.4</span>
</div>
<div className="simulation-viewport__controls">
<Select
@@ -190,6 +277,27 @@ export function SimulationViewport({
minMenuWidth={230}
menuWidth={230}
/>
<Select
label="Режим управления"
value={controlMode}
disabled={state !== "ready" || controlState === "loading"}
onChange={changeControlMode}
options={[
{
value: "free",
label: "Свободно",
description: "Свободная камера и навигация",
},
{
value: "ugv",
label: "UGV",
description: "WASD/стрелки · пробел — тормоз · R — домой",
disabled: !collisionAvailable,
},
]}
minMenuWidth={250}
menuWidth={250}
/>
<SegmentedControl
label="Слой сцены"
value={viewMode}
@@ -204,7 +312,7 @@ export function SimulationViewport({
if (next !== "visual") setCollisionState("ready");
}).catch((caught: unknown) => {
setCollisionState("failed");
setError(caught instanceof Error ? caught.message : "Не удалось открыть collision GLB.");
setError(caught instanceof Error ? caught.message : "Не удалось открыть слой коллизий.");
});
}}
items={[
@@ -227,7 +335,7 @@ export function SimulationViewport({
<Icon name="settings" size={18} />
</IconButton>
{settingsOpen ? (
<div
<GlassMaterialSurface
id={settingsId}
className="simulation-viewport__settings"
role="dialog"
@@ -236,100 +344,57 @@ export function SimulationViewport({
<div className="simulation-viewport__settings-head">
<div>
<strong>Настройки сцены</strong>
<span>Gaussian-мир и камера</span>
<span>Локация, камера и UGV</span>
</div>
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
<Icon name="close" size={16} />
</IconButton>
</div>
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-scroll">
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-group-head">
<strong>Система координат</strong>
<span>Мир PlayCanvas · Y вверх</span>
<span>Мир сцены · Y вверх</span>
</div>
<p>
Коррекция слоёв не меняет координаты камеры, навигации и будущей физики.
</p>
<div className="simulation-viewport__settings-switches">
<div className="simulation-viewport__settings-layer-row">
<Switch
checked={visualInverted}
disabled={state !== "ready"}
label="Инверсия визуального слоя"
onChange={(checked) => {
setVisualInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"visual",
playCanvasAxisTransform(visualAxis, checked),
);
commitViewerSettings({
...viewerSettingsRef.current,
visual: { ...viewerSettingsRef.current.visual, inverted: checked },
});
}}
/>
<Select
className="simulation-viewport__axis-select"
label="Ось инверсии визуального слоя"
value={visualAxis}
options={AXIS_OPTIONS}
disabled={state !== "ready"}
minMenuWidth={72}
menuWidth={72}
onChange={(axis) => {
setVisualAxis(axis);
runtimeRef.current?.setLayerWorldTransform(
"visual",
playCanvasAxisTransform(axis, visualInverted),
);
commitViewerSettings({
...viewerSettingsRef.current,
visual: { ...viewerSettingsRef.current.visual, axis },
});
}}
/>
</div>
<div className="simulation-viewport__settings-layer-row">
<Switch
checked={collisionInverted}
disabled={state !== "ready"}
label="Инверсия collision-слоя"
onChange={(checked) => {
setCollisionInverted(checked);
runtimeRef.current?.setLayerWorldTransform(
"collision",
playCanvasAxisTransform(collisionAxis, checked),
);
commitViewerSettings({
...viewerSettingsRef.current,
collision: { ...viewerSettingsRef.current.collision, inverted: checked },
});
}}
/>
<Select
className="simulation-viewport__axis-select"
label="Ось инверсии collision-слоя"
value={collisionAxis}
options={AXIS_OPTIONS}
disabled={state !== "ready"}
minMenuWidth={72}
menuWidth={72}
onChange={(axis) => {
setCollisionAxis(axis);
runtimeRef.current?.setLayerWorldTransform(
"collision",
playCanvasAxisTransform(axis, collisionInverted),
);
commitViewerSettings({
...viewerSettingsRef.current,
collision: { ...viewerSettingsRef.current.collision, axis },
});
}}
/>
</div>
<div className="simulation-viewport__settings-rotations">
<LayerRotationFields
label="Гауссовый слой"
value={visualRotation}
disabled={state !== "ready"}
onChange={(next) => {
setVisualRotation(next);
runtimeRef.current?.setLayerWorldTransform(
"visual",
playCanvasEulerTransform(next),
);
commitViewerSettings({
...viewerSettingsRef.current,
visual: { rotationDegrees: next },
});
}}
/>
<LayerRotationFields
label="Слой коллизий"
value={collisionRotation}
disabled={state !== "ready"}
onChange={(next) => {
setCollisionRotation(next);
runtimeRef.current?.setLayerWorldTransform(
"collision",
playCanvasEulerTransform(next),
);
commitViewerSettings({
...viewerSettingsRef.current,
collision: { rotationDegrees: next },
});
}}
/>
</div>
</section>
<section className="simulation-viewport__settings-group">
</section>
<section className="simulation-viewport__settings-group">
<div className="simulation-viewport__settings-group-head">
<strong>Управление камерой</strong>
<span>Мышь и тачпад</span>
@@ -362,39 +427,165 @@ export function SimulationViewport({
}}
/>
</div>
</section>
{settingsError ? (
<p className="simulation-viewport__settings-error" role="alert">
Настройки не сохранены: {settingsError}
</p>
) : null}
</div>
</section>
<SimulationUgvSettingsPanel
value={ugvDraft}
disabled={state !== "ready"}
saveState={ugvSaveState}
onChange={changeUgvDraft}
onSave={saveUgvPreset}
/>
{settingsError ? (
<p className="simulation-viewport__settings-error" role="alert">
Настройки не сохранены: {settingsError}
</p>
) : null}
</div>
</GlassMaterialSurface>
) : null}
</div>
</div>
<span className="simulation-viewport__toolbar-balance" aria-hidden="true" />
</header>
<div className="simulation-viewport__stage">
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
<canvas ref={canvasRef} aria-label={`Сцена ${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>
{state === "failed" ? null : <ActivityIndicator label="Загрузка локации" />}
<strong>{state === "failed" ? "Локация не открылась" : "Загружаем локацию"}</strong>
<p>{error ?? "LOD-уровни загружаются по мере готовности runtime."}</p>
</div>
) : null}
</div>
{!collisionAvailable ? (
<footer className="simulation-viewport__notice">
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
<StatusBadge tone="warning">Коллизии недоступны</StatusBadge>
<span>Для этой сборки слой коллизий не создавался; визуальный слой остаётся доступен.</span>
</footer>
) : collisionState === "failed" ? (
<footer className="simulation-viewport__notice" role="alert">
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
<StatusBadge tone="warning">Коллизии не открылись</StatusBadge>
<span>{error ?? "Слой коллизий не загрузился."}</span>
</footer>
) : null}
</section>
);
}
function cloneUgvPreset(preset: SimulationUgvPreset): SimulationUgvPreset {
return {
...preset,
dimensionsMeters: { ...preset.dimensionsMeters },
};
}
function LayerRotationFields({
label,
value,
disabled,
onChange,
}: {
label: string;
value: SimulationEulerRotation;
disabled: boolean;
onChange: (rotation: SimulationEulerRotation) => void;
}) {
return (
<div className="simulation-viewport__rotation-row">
<strong>{label}</strong>
<div className="simulation-viewport__rotation-fields">
{ROTATION_AXES.map((axis) => (
<RotationField
key={axis}
label={axis.toUpperCase()}
ariaLabel={`${label}: вращение ${axis.toUpperCase()}`}
value={value[axis]}
disabled={disabled}
onChange={(angle) => onChange({ ...value, [axis]: angle })}
/>
))}
</div>
</div>
);
}
function RotationField({
label,
ariaLabel,
value,
disabled,
onChange,
}: {
label: string;
ariaLabel: string;
value: number;
disabled: boolean;
onChange: (angle: number) => void;
}) {
const [draft, setDraft] = useState(() => String(value));
const editingRef = useRef(false);
const cancelNextBlurRef = useRef(false);
useEffect(() => {
if (!editingRef.current) setDraft(String(value));
}, [value]);
const parsedDraft = () => Number(draft.trim().replace(",", "."));
const finishEditing = (commit: boolean) => {
const parsed = parsedDraft();
if (commit && draft.trim() !== "" && Number.isFinite(parsed)) {
const normalized = Math.max(-360, Math.min(360, parsed));
onChange(normalized);
setDraft(String(normalized));
} else {
setDraft(String(value));
}
editingRef.current = false;
};
return (
<TextField
label={label}
aria-label={ariaLabel}
fieldClassName="simulation-viewport__rotation-field"
className="simulation-viewport__rotation-input"
type="text"
inputMode="decimal"
value={draft}
disabled={disabled}
spellCheck={false}
onFocus={() => {
editingRef.current = true;
}}
onChange={(event) => {
// Treat the first input event as the start of editing as well. Programmatic
// focus (and some browser/trackpad paths) may not deliver React's focus
// event before the controlled value changes.
editingRef.current = true;
const nextDraft = event.currentTarget.value;
setDraft(nextDraft);
const parsed = Number(nextDraft.trim().replace(",", "."));
if (nextDraft.trim() !== "" && Number.isFinite(parsed) && parsed >= -360 && parsed <= 360) {
onChange(parsed);
}
}}
onKeyDown={(event) => {
event.stopPropagation();
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") {
event.preventDefault();
cancelNextBlurRef.current = true;
finishEditing(false);
event.currentTarget.blur();
}
}}
onBlur={() => {
if (cancelNextBlurRef.current) {
cancelNextBlurRef.current = false;
return;
}
finishEditing(true);
}}
/>
);
}