feat(simulation): add Gaussian UGV runtime pipeline
This commit is contained in:
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
export type SimulationSceneType = "interior" | "outdoor" | "object";
|
||||
export type SimulationTransformAxis = "x" | "y" | "z";
|
||||
export type SimulationViewerQuality = "low" | "medium" | "high" | "ultra" | "maximum";
|
||||
export interface SimulationEulerRotation {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
export type SimulationProjectStatus =
|
||||
| "uploading"
|
||||
| "queued"
|
||||
@@ -35,13 +39,38 @@ export interface SimulationWorldManifest {
|
||||
}
|
||||
|
||||
export interface SimulationViewerSettings {
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v1";
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v3";
|
||||
quality: SimulationViewerQuality;
|
||||
visual: { inverted: boolean; axis: SimulationTransformAxis };
|
||||
collision: { inverted: boolean; axis: SimulationTransformAxis };
|
||||
visual: { rotationDegrees: SimulationEulerRotation };
|
||||
collision: { rotationDegrees: SimulationEulerRotation };
|
||||
camera: { invertHorizontal: boolean; invertVertical: boolean };
|
||||
ugv: SimulationUgvPreset;
|
||||
}
|
||||
|
||||
export interface SimulationUgvPreset {
|
||||
presetName: string;
|
||||
massKg: number;
|
||||
dimensionsMeters: {
|
||||
length: number;
|
||||
width: number;
|
||||
height: number;
|
||||
groundClearance: number;
|
||||
};
|
||||
maxSpeedMetersPerSecond: number;
|
||||
maxTurnRateDegrees: number;
|
||||
invertSteering: boolean;
|
||||
}
|
||||
|
||||
export const SIMULATION_UGV_EXACT_VALUE_LIMITS = {
|
||||
massKg: { min: 0.01, max: 1_000_000_000 },
|
||||
length: { min: 0.01, max: 1_000_000_000 },
|
||||
width: { min: 0.01, max: 1_000_000_000 },
|
||||
height: { min: 0.07, max: 1_000_000_000 },
|
||||
groundClearance: { min: 0.01, max: 1_000_000_000 },
|
||||
maxSpeedMetersPerSecond: { min: 0, max: 1_000_000_000 },
|
||||
maxTurnRateDegrees: { min: 0, max: 1_000_000_000 },
|
||||
} as const;
|
||||
|
||||
export interface SimulationProject {
|
||||
schemaVersion: "missioncore.simulation-project/v1";
|
||||
projectId: string;
|
||||
@@ -58,7 +87,9 @@ export interface SimulationProject {
|
||||
provider: {
|
||||
providerId: "gaussian-pipeline";
|
||||
jobId: string | null;
|
||||
jobCreatedAtUtc: string | null;
|
||||
state: string | null;
|
||||
stateStartedAtUtc: string | null;
|
||||
progress: { completed_steps?: number; total_steps?: number; stage?: string } | null;
|
||||
runtime: { source_revision?: string; image_digest?: string } | null;
|
||||
};
|
||||
@@ -85,11 +116,60 @@ export interface SimulationUploadProgress {
|
||||
uploadedBytes: number;
|
||||
totalBytes: number;
|
||||
currentPath: string;
|
||||
bytesPerSecond: number | null;
|
||||
estimatedSecondsRemaining: number | null;
|
||||
}
|
||||
|
||||
const API_ROOT = "/api/v1/simulation-worlds/projects";
|
||||
const CHUNK_BYTES = 8 * 1024 * 1024;
|
||||
const MAX_UPLOAD_ATTEMPTS = 3;
|
||||
const REFERENCE_SOURCE_BYTES = 4_100_808_854;
|
||||
const REFERENCE_PREPARATION_SECONDS = 187;
|
||||
const REFERENCE_STREAM_SECONDS = 2_317;
|
||||
|
||||
export interface SimulationProcessingEstimate {
|
||||
percent: number;
|
||||
estimatedSecondsRemaining: number | null;
|
||||
}
|
||||
|
||||
export function estimateSimulationProcessing(
|
||||
project: SimulationProject,
|
||||
nowMilliseconds = Date.now(),
|
||||
): SimulationProcessingEstimate | null {
|
||||
if (project.status !== "processing") return null;
|
||||
const jobStarted = Date.parse(project.provider.jobCreatedAtUtc ?? "");
|
||||
if (!Number.isFinite(jobStarted)) return null;
|
||||
const elapsedSeconds = Math.max(0, (nowMilliseconds - jobStarted) / 1_000);
|
||||
const sourceScale = clamp(
|
||||
project.source.totalByteLength / REFERENCE_SOURCE_BYTES,
|
||||
0.1,
|
||||
4,
|
||||
);
|
||||
let expectedSeconds = (
|
||||
REFERENCE_PREPARATION_SECONDS + REFERENCE_STREAM_SECONDS
|
||||
) * sourceScale;
|
||||
if (project.provider.state === "building_streamed_sog") {
|
||||
const streamedStarted = Date.parse(project.provider.stateStartedAtUtc ?? "");
|
||||
if (Number.isFinite(streamedStarted) && streamedStarted >= jobStarted) {
|
||||
const preparationSeconds = Math.max(1, (streamedStarted - jobStarted) / 1_000);
|
||||
const observedLoad = clamp(
|
||||
preparationSeconds / (REFERENCE_PREPARATION_SECONDS * sourceScale),
|
||||
0.5,
|
||||
2,
|
||||
);
|
||||
expectedSeconds = preparationSeconds
|
||||
+ REFERENCE_STREAM_SECONDS * sourceScale * observedLoad;
|
||||
}
|
||||
}
|
||||
const rawPercent = expectedSeconds > 0 ? elapsedSeconds / expectedSeconds * 100 : 0;
|
||||
if (rawPercent >= 100) {
|
||||
return { percent: 99, estimatedSecondsRemaining: null };
|
||||
}
|
||||
return {
|
||||
percent: Math.max(1, Math.floor(rawPercent)),
|
||||
estimatedSecondsRemaining: Math.max(0, Math.ceil(expectedSeconds - elapsedSeconds)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchSimulationProjects(signal?: AbortSignal): Promise<SimulationProject[]> {
|
||||
const response = await fetch(API_ROOT, { signal, cache: "no-store" });
|
||||
@@ -107,6 +187,15 @@ export async function createAndUploadSimulationProject(
|
||||
sceneType: SimulationSceneType,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
onProgress: (progress: SimulationUploadProgress) => void,
|
||||
): Promise<SimulationProject> {
|
||||
const project = await createSimulationProject(name, sceneType, candidates);
|
||||
return uploadSimulationProjectSource(project, candidates, onProgress);
|
||||
}
|
||||
|
||||
export async function createSimulationProject(
|
||||
name: string,
|
||||
sceneType: SimulationSceneType,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
): Promise<SimulationProject> {
|
||||
const sourceKind = sourceKindFor(candidates);
|
||||
const createdResponse = await fetch(API_ROOT, {
|
||||
@@ -123,36 +212,85 @@ export async function createAndUploadSimulationProject(
|
||||
})),
|
||||
}),
|
||||
});
|
||||
let project = parseProject(await jsonResponse(createdResponse));
|
||||
return parseProject(await jsonResponse(createdResponse));
|
||||
}
|
||||
|
||||
export async function uploadSimulationProjectSource(
|
||||
project: SimulationProject,
|
||||
candidates: SimulationUploadCandidate[],
|
||||
onProgress: (progress: SimulationUploadProgress) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SimulationProject> {
|
||||
const byPath = new Map(candidates.map((candidate) => [candidate.logicalPath, candidate.file]));
|
||||
const totalBytes = candidates.reduce((total, candidate) => total + candidate.file.size, 0);
|
||||
let confirmedBytes = 0;
|
||||
let sampleBytes = 0;
|
||||
let sampleAt = performance.now();
|
||||
let smoothedBytesPerSecond: number | null = null;
|
||||
const reportProgress = (currentPath: string) => {
|
||||
const remainingBytes = Math.max(0, totalBytes - confirmedBytes);
|
||||
onProgress({
|
||||
uploadedBytes: confirmedBytes,
|
||||
totalBytes,
|
||||
currentPath,
|
||||
bytesPerSecond: smoothedBytesPerSecond,
|
||||
estimatedSecondsRemaining: smoothedBytesPerSecond && remainingBytes > 0
|
||||
? Math.ceil(remainingBytes / smoothedBytesPerSecond)
|
||||
: remainingBytes === 0 ? 0 : null,
|
||||
});
|
||||
};
|
||||
for (const sourceFile of project.source.files) {
|
||||
signal?.throwIfAborted();
|
||||
const file = byPath.get(sourceFile.logicalPath);
|
||||
if (!file) throw new Error(`Сервер изменил состав источника: ${sourceFile.logicalPath}`);
|
||||
const uploadUrl = `${API_ROOT}/${encodeURIComponent(project.projectId)}/source/${encodeURIComponent(sourceFile.fileId)}`;
|
||||
let offset = await readUploadOffset(uploadUrl, file.size);
|
||||
let offset = await readUploadOffset(uploadUrl, file.size, signal);
|
||||
confirmedBytes += offset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
sampleBytes = confirmedBytes;
|
||||
sampleAt = performance.now();
|
||||
reportProgress(sourceFile.logicalPath);
|
||||
while (offset < file.size) {
|
||||
signal?.throwIfAborted();
|
||||
const nextOffset = Math.min(file.size, offset + CHUNK_BYTES);
|
||||
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset);
|
||||
const confirmedOffset = await uploadChunk(uploadUrl, file, offset, nextOffset, signal);
|
||||
confirmedBytes += confirmedOffset - offset;
|
||||
offset = confirmedOffset;
|
||||
onProgress({ uploadedBytes: confirmedBytes, totalBytes, currentPath: sourceFile.logicalPath });
|
||||
const now = performance.now();
|
||||
const elapsedSeconds = (now - sampleAt) / 1_000;
|
||||
const transferredBytes = confirmedBytes - sampleBytes;
|
||||
if (elapsedSeconds > 0 && transferredBytes > 0) {
|
||||
const currentBytesPerSecond = transferredBytes / elapsedSeconds;
|
||||
smoothedBytesPerSecond = smoothedBytesPerSecond === null
|
||||
? currentBytesPerSecond
|
||||
: smoothedBytesPerSecond * 0.7 + currentBytesPerSecond * 0.3;
|
||||
}
|
||||
sampleBytes = confirmedBytes;
|
||||
sampleAt = now;
|
||||
reportProgress(sourceFile.logicalPath);
|
||||
}
|
||||
}
|
||||
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(project.projectId)}/build`, {
|
||||
return startSimulationProjectBuild(project.projectId, signal);
|
||||
}
|
||||
|
||||
export async function startSimulationProjectBuild(
|
||||
projectId: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<SimulationProject> {
|
||||
const buildResponse = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
signal,
|
||||
});
|
||||
project = parseProject(await jsonResponse(buildResponse));
|
||||
return project;
|
||||
return parseProject(await jsonResponse(buildResponse));
|
||||
}
|
||||
|
||||
async function readUploadOffset(uploadUrl: string, fileSize: number): Promise<number> {
|
||||
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store" });
|
||||
async function readUploadOffset(
|
||||
uploadUrl: string,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
const head = await fetch(uploadUrl, { method: "HEAD", cache: "no-store", signal });
|
||||
if (!head.ok) await jsonResponse(head);
|
||||
const value = head.headers.get("Upload-Offset");
|
||||
if (!value || !/^\d+$/.test(value)) {
|
||||
@@ -170,11 +308,13 @@ async function uploadChunk(
|
||||
file: File,
|
||||
offset: number,
|
||||
nextOffset: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<number> {
|
||||
let lastFailure: unknown = null;
|
||||
for (let attempt = 1; attempt <= MAX_UPLOAD_ATTEMPTS; attempt += 1) {
|
||||
let response: Response | null = null;
|
||||
try {
|
||||
signal?.throwIfAborted();
|
||||
response = await fetch(uploadUrl, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
@@ -182,8 +322,10 @@ async function uploadChunk(
|
||||
"Upload-Offset": String(offset),
|
||||
},
|
||||
body: file.slice(offset, nextOffset),
|
||||
signal,
|
||||
});
|
||||
} catch (caught) {
|
||||
if (signal?.aborted) throw caught;
|
||||
lastFailure = caught;
|
||||
}
|
||||
if (response?.ok) {
|
||||
@@ -203,7 +345,7 @@ async function uploadChunk(
|
||||
lastFailure = new Error(`Mission Core временно вернул HTTP ${response.status}.`);
|
||||
}
|
||||
try {
|
||||
const confirmed = await readUploadOffset(uploadUrl, file.size);
|
||||
const confirmed = await readUploadOffset(uploadUrl, file.size, signal);
|
||||
if (confirmed === nextOffset) return confirmed;
|
||||
if (confirmed !== offset) {
|
||||
throw new Error("Сервер подтвердил неожиданное смещение загрузки.");
|
||||
@@ -246,26 +388,32 @@ export async function saveSimulationViewerSettings(
|
||||
body: JSON.stringify({
|
||||
schema_version: settings.schemaVersion,
|
||||
quality: settings.quality,
|
||||
visual: settings.visual,
|
||||
collision: settings.collision,
|
||||
visual: { rotation_degrees: settings.visual.rotationDegrees },
|
||||
collision: { rotation_degrees: settings.collision.rotationDegrees },
|
||||
camera: {
|
||||
invert_horizontal: settings.camera.invertHorizontal,
|
||||
invert_vertical: settings.camera.invertVertical,
|
||||
},
|
||||
ugv: {
|
||||
preset_name: settings.ugv.presetName,
|
||||
mass_kg: settings.ugv.massKg,
|
||||
dimensions_m: {
|
||||
length: settings.ugv.dimensionsMeters.length,
|
||||
width: settings.ugv.dimensionsMeters.width,
|
||||
height: settings.ugv.dimensionsMeters.height,
|
||||
ground_clearance: settings.ugv.dimensionsMeters.groundClearance,
|
||||
},
|
||||
max_speed_mps: settings.ugv.maxSpeedMetersPerSecond,
|
||||
max_turn_rate_degrees: settings.ugv.maxTurnRateDegrees,
|
||||
invert_steering: settings.ugv.invertSteering,
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
return parseProject(await jsonResponse(response));
|
||||
}
|
||||
|
||||
export async function retrySimulationProject(projectId: string): Promise<SimulationProject> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}/build`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
return parseProject(await jsonResponse(response));
|
||||
}
|
||||
export const retrySimulationProject = startSimulationProjectBuild;
|
||||
|
||||
export async function deleteSimulationProject(projectId: string): Promise<void> {
|
||||
const response = await fetch(`${API_ROOT}/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
@@ -321,7 +469,9 @@ function parseProject(value: unknown): SimulationProject {
|
||||
provider: {
|
||||
providerId: provider.provider_id === "gaussian-pipeline" ? "gaussian-pipeline" : invalid("provider id"),
|
||||
jobId: nullableString(provider.job_id, "provider job id"),
|
||||
jobCreatedAtUtc: nullableString(provider.job_created_at_utc ?? null, "provider job creation"),
|
||||
state: nullableString(provider.state, "provider state"),
|
||||
stateStartedAtUtc: nullableString(provider.state_started_at_utc ?? null, "provider state start"),
|
||||
progress: provider.progress === null ? null : objectValue(provider.progress, "provider progress"),
|
||||
runtime: provider.runtime === null ? null : objectValue(provider.runtime, "provider runtime"),
|
||||
},
|
||||
@@ -345,16 +495,17 @@ function parseProject(value: unknown): SimulationProject {
|
||||
|
||||
function parseViewerSettings(value: unknown): SimulationViewerSettings {
|
||||
const record = objectValue(value, "viewer settings");
|
||||
exactKeys(record, ["schema_version", "quality", "visual", "collision", "camera"], "viewer settings");
|
||||
if (record.schema_version !== "missioncore.simulation-viewer-settings/v1") {
|
||||
exactKeys(record, ["schema_version", "quality", "visual", "collision", "camera", "ugv"], "viewer settings");
|
||||
if (record.schema_version !== "missioncore.simulation-viewer-settings/v3") {
|
||||
throw new Error("Настройки сцены вернули неподдерживаемую версию.");
|
||||
}
|
||||
const visual = parseLayerViewerSettings(record.visual, "visual settings");
|
||||
const collision = parseLayerViewerSettings(record.collision, "collision settings");
|
||||
const camera = objectValue(record.camera, "camera settings");
|
||||
exactKeys(camera, ["invert_horizontal", "invert_vertical"], "camera settings");
|
||||
const ugv = parseUgvPreset(record.ugv);
|
||||
return {
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v1",
|
||||
schemaVersion: "missioncore.simulation-viewer-settings/v3",
|
||||
quality: qualityValue(record.quality),
|
||||
visual,
|
||||
collision,
|
||||
@@ -362,18 +513,88 @@ function parseViewerSettings(value: unknown): SimulationViewerSettings {
|
||||
invertHorizontal: booleanValue(camera.invert_horizontal, "horizontal camera inversion"),
|
||||
invertVertical: booleanValue(camera.invert_vertical, "vertical camera inversion"),
|
||||
},
|
||||
ugv,
|
||||
};
|
||||
}
|
||||
|
||||
function parseUgvPreset(value: unknown): SimulationUgvPreset {
|
||||
const record = objectValue(value, "UGV settings");
|
||||
exactKeys(
|
||||
record,
|
||||
["preset_name", "mass_kg", "dimensions_m", "max_speed_mps", "max_turn_rate_degrees", "invert_steering"],
|
||||
"UGV settings",
|
||||
);
|
||||
const dimensions = objectValue(record.dimensions_m, "UGV dimensions");
|
||||
exactKeys(dimensions, ["length", "width", "height", "ground_clearance"], "UGV dimensions");
|
||||
const presetName = stringValue(record.preset_name, "UGV preset name");
|
||||
if (presetName.length > 80) return invalid("UGV preset name");
|
||||
const height = finiteNumberValue(
|
||||
dimensions.height,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.height.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.height.max,
|
||||
"UGV height",
|
||||
);
|
||||
const groundClearance = finiteNumberValue(
|
||||
dimensions.ground_clearance,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.groundClearance.max,
|
||||
"UGV ground clearance",
|
||||
);
|
||||
if (groundClearance >= height - 0.05) return invalid("UGV ground clearance");
|
||||
return {
|
||||
presetName,
|
||||
massKg: finiteNumberValue(
|
||||
record.mass_kg,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.massKg.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.massKg.max,
|
||||
"UGV mass",
|
||||
),
|
||||
dimensionsMeters: {
|
||||
length: finiteNumberValue(
|
||||
dimensions.length,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.length.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.length.max,
|
||||
"UGV length",
|
||||
),
|
||||
width: finiteNumberValue(
|
||||
dimensions.width,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.width.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.width.max,
|
||||
"UGV width",
|
||||
),
|
||||
height,
|
||||
groundClearance,
|
||||
},
|
||||
maxSpeedMetersPerSecond: finiteNumberValue(
|
||||
record.max_speed_mps,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxSpeedMetersPerSecond.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxSpeedMetersPerSecond.max,
|
||||
"UGV maximum speed",
|
||||
),
|
||||
maxTurnRateDegrees: finiteNumberValue(
|
||||
record.max_turn_rate_degrees,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxTurnRateDegrees.min,
|
||||
SIMULATION_UGV_EXACT_VALUE_LIMITS.maxTurnRateDegrees.max,
|
||||
"UGV maximum turn rate",
|
||||
),
|
||||
invertSteering: booleanValue(record.invert_steering, "UGV steering inversion"),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLayerViewerSettings(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): { inverted: boolean; axis: SimulationTransformAxis } {
|
||||
): { rotationDegrees: SimulationEulerRotation } {
|
||||
const record = objectValue(value, label);
|
||||
exactKeys(record, ["inverted", "axis"], label);
|
||||
exactKeys(record, ["rotation_degrees"], label);
|
||||
const rotation = objectValue(record.rotation_degrees, `${label} rotation`);
|
||||
exactKeys(rotation, ["x", "y", "z"], `${label} rotation`);
|
||||
return {
|
||||
inverted: booleanValue(record.inverted, `${label} inversion`),
|
||||
axis: axisValue(record.axis),
|
||||
rotationDegrees: {
|
||||
x: rotationValue(rotation.x, `${label} X rotation`),
|
||||
y: rotationValue(rotation.y, `${label} Y rotation`),
|
||||
z: rotationValue(rotation.z, `${label} Z rotation`),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -466,9 +687,18 @@ function sceneTypeValue(value: unknown): SimulationSceneType {
|
||||
return invalid("scene type");
|
||||
}
|
||||
|
||||
function axisValue(value: unknown): SimulationTransformAxis {
|
||||
if (value === "x" || value === "y" || value === "z") return value;
|
||||
return invalid("transform axis");
|
||||
function rotationValue(value: unknown, label: string): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= -360 && value <= 360) {
|
||||
return value;
|
||||
}
|
||||
return invalid(label);
|
||||
}
|
||||
|
||||
function finiteNumberValue(value: unknown, minimum: number, maximum: number, label: string): number {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value >= minimum && value <= maximum) {
|
||||
return value;
|
||||
}
|
||||
return invalid(label);
|
||||
}
|
||||
|
||||
function qualityValue(value: unknown): SimulationViewerQuality {
|
||||
@@ -488,3 +718,7 @@ function statusValue(value: unknown): SimulationProjectStatus {
|
||||
function invalid(label: string): never {
|
||||
throw new Error(`Некорректный ${label}.`);
|
||||
}
|
||||
|
||||
function clamp(value: number, minimum: number, maximum: number): number {
|
||||
return Math.min(maximum, Math.max(minimum, value));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import type { SimulationUploadCandidate } from "./projects";
|
||||
|
||||
const ARCHIVE_PATTERN = /\.(zip|rar|7z)$/i;
|
||||
export const MAX_SIMULATION_SOURCE_BYTES = 16 * 1024 ** 3;
|
||||
|
||||
interface LegacyFileEntry {
|
||||
isFile: boolean;
|
||||
isDirectory: boolean;
|
||||
name: string;
|
||||
fullPath: string;
|
||||
file?: (callback: (file: File) => void, error?: (error: DOMException) => void) => void;
|
||||
createReader?: () => LegacyDirectoryReader;
|
||||
}
|
||||
|
||||
interface LegacyDirectoryReader {
|
||||
readEntries: (
|
||||
callback: (entries: LegacyFileEntry[]) => void,
|
||||
error?: (error: DOMException) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export 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?.isDirectory) {
|
||||
await collectEntry(entry, candidates);
|
||||
continue;
|
||||
}
|
||||
const file = item.getAsFile();
|
||||
if (file) {
|
||||
candidates.push({ file, logicalPath: file.name });
|
||||
continue;
|
||||
}
|
||||
if (entry) await collectEntry(entry, candidates);
|
||||
}
|
||||
if (!candidates.length) {
|
||||
for (const file of Array.from(transfer.files)) {
|
||||
candidates.push({ file, logicalPath: file.name });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
export async function archivesFromDrop(transfer: DataTransfer): Promise<File[]> {
|
||||
const candidates = await candidatesFromDrop(transfer);
|
||||
const invalid = candidates.filter((candidate) => !ARCHIVE_PATTERN.test(candidate.logicalPath));
|
||||
if (invalid.length) {
|
||||
throw new Error("В каталог можно перетащить только отдельные архивы ZIP, RAR или 7z.");
|
||||
}
|
||||
if (!candidates.length) {
|
||||
throw new Error("В перетаскивании не найдено архивов ZIP, RAR или 7z.");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.logicalPath.includes("/")) {
|
||||
throw new Error("Перетащите сами архивы, а не содержащую их папку.");
|
||||
}
|
||||
if (candidate.file.size <= 0) {
|
||||
throw new Error(`Пустой архив: ${candidate.file.name}`);
|
||||
}
|
||||
if (candidate.file.size > MAX_SIMULATION_SOURCE_BYTES) {
|
||||
throw new Error(`Архив ${candidate.file.name} превышает лимит 16 ГБ.`);
|
||||
}
|
||||
}
|
||||
return candidates.map((candidate) => candidate.file);
|
||||
}
|
||||
|
||||
export function validateSimulationCandidates(candidates: SimulationUploadCandidate[]): void {
|
||||
if (!candidates.length) throw new Error("Выберите архив или папку с результатом LCC/LCC2.");
|
||||
const archives = candidates.filter((candidate) => ARCHIVE_PATTERN.test(candidate.logicalPath));
|
||||
if (archives.length) {
|
||||
if (candidates.length !== 1) throw new Error("Архив нужно загружать одним файлом.");
|
||||
if (archives[0]!.file.size <= 0) throw new Error(`Пустой файл: ${archives[0]!.logicalPath}`);
|
||||
if (archives[0]!.file.size > MAX_SIMULATION_SOURCE_BYTES) {
|
||||
throw new Error(`Архив ${archives[0]!.logicalPath} превышает лимит 16 ГБ.`);
|
||||
}
|
||||
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>();
|
||||
let totalBytes = 0;
|
||||
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}`);
|
||||
totalBytes += candidate.file.size;
|
||||
if (paths.has(candidate.logicalPath)) throw new Error(`Повторяющийся путь: ${candidate.logicalPath}`);
|
||||
paths.add(candidate.logicalPath);
|
||||
}
|
||||
if (totalBytes > MAX_SIMULATION_SOURCE_BYTES) {
|
||||
throw new Error("Папка с исходниками превышает лимит 16 ГБ.");
|
||||
}
|
||||
}
|
||||
|
||||
export function hasDroppedFiles(transfer: DataTransfer): boolean {
|
||||
return transfer.files.length > 0
|
||||
|| Array.from(transfer.items).some((item) => item.kind === "file")
|
||||
|| Array.from(transfer.types).some((type) => type.toLowerCase() === "files");
|
||||
}
|
||||
|
||||
export function archiveProjectName(fileName: string): string {
|
||||
const name = fileName.replace(ARCHIVE_PATTERN, "").trim();
|
||||
return name || "Новая сцена";
|
||||
}
|
||||
|
||||
function collectEntry(
|
||||
entry: LegacyFileEntry,
|
||||
target: SimulationUploadCandidate[],
|
||||
): Promise<void> {
|
||||
if (entry.isFile && entry.file) {
|
||||
return new Promise<File>((resolve, reject) => entry.file?.(resolve, reject))
|
||||
.then((file) => {
|
||||
target.push({ file, logicalPath: entry.fullPath.replace(/^\//, "") || file.name });
|
||||
});
|
||||
}
|
||||
if (!entry.isDirectory || !entry.createReader) return Promise.resolve();
|
||||
return collectDirectory(entry.createReader(), target);
|
||||
}
|
||||
|
||||
async function collectDirectory(
|
||||
reader: LegacyDirectoryReader,
|
||||
target: SimulationUploadCandidate[],
|
||||
): Promise<void> {
|
||||
while (true) {
|
||||
const batch = await new Promise<LegacyFileEntry[]>((resolve, reject) => {
|
||||
reader.readEntries(resolve, reject);
|
||||
});
|
||||
if (!batch.length) return;
|
||||
for (const child of batch) await collectEntry(child, target);
|
||||
}
|
||||
}
|
||||
@@ -161,7 +161,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
label: "Симуляции",
|
||||
title: "Симуляции",
|
||||
eyebrow: "ТЕСТОВЫЙ КОНТУР / СИМУЛЯЦИИ",
|
||||
description: "Каталог Gaussian-миров, переносимые сборки и интерактивные PlayCanvas-сцены.",
|
||||
description: "Каталог Gaussian-миров, переносимые сборки и интерактивные сцены.",
|
||||
icon: "globe",
|
||||
kind: "simulations",
|
||||
groups: [],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
.simulation-workspace {
|
||||
display: grid;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 0.9rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
@@ -17,6 +19,7 @@
|
||||
.simulation-viewport__notice,
|
||||
.simulation-catalog__summary,
|
||||
.simulation-catalog__state,
|
||||
.simulation-catalog__notice,
|
||||
.simulation-catalog__actions,
|
||||
.simulation-project-window__footer-state,
|
||||
.simulation-source-drop > div,
|
||||
@@ -60,7 +63,10 @@
|
||||
}
|
||||
|
||||
.simulation-catalog {
|
||||
position: relative;
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
align-content: start;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -100,23 +106,28 @@
|
||||
.simulation-catalog__table-wrap {
|
||||
overflow: auto;
|
||||
border-radius: 1rem;
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.simulation-catalog__table {
|
||||
width: 100%;
|
||||
min-width: 62rem;
|
||||
border-collapse: collapse;
|
||||
min-width: 52rem;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0 0.34rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table th,
|
||||
.simulation-catalog__table td {
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
padding: 0.72rem 0.8rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.simulation-catalog__table th {
|
||||
padding-top: 0.2rem;
|
||||
padding-bottom: 0.18rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table th:first-child,
|
||||
.simulation-catalog__table td:first-child {
|
||||
padding-left: 1rem;
|
||||
@@ -124,6 +135,8 @@
|
||||
|
||||
.simulation-catalog__table th:last-child,
|
||||
.simulation-catalog__table td:last-child {
|
||||
width: 6.4rem;
|
||||
min-width: 6.4rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
|
||||
@@ -131,12 +144,21 @@
|
||||
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody tr:hover {
|
||||
.simulation-catalog__table tbody td {
|
||||
background: rgb(255 255 255 / 0.025);
|
||||
transition: background var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody tr:last-child td {
|
||||
border-bottom: 0;
|
||||
.simulation-catalog__table tbody tr:hover td {
|
||||
background: rgb(255 255 255 / 0.045);
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody td:first-child {
|
||||
border-radius: 0.85rem 0 0 0.85rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__table tbody td:last-child {
|
||||
border-radius: 0 0.85rem 0.85rem 0;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td {
|
||||
@@ -156,7 +178,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
min-width: 14rem;
|
||||
min-width: 11rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -165,16 +187,26 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td:first-child > button > span:last-child {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td:first-child > button strong {
|
||||
display: block;
|
||||
max-width: 20rem;
|
||||
overflow: hidden;
|
||||
overflow: clip;
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.7rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-catalog__table td:first-child > button small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-catalog__project-icon,
|
||||
.simulation-catalog__empty > span {
|
||||
display: grid;
|
||||
@@ -188,10 +220,53 @@
|
||||
}
|
||||
|
||||
.simulation-catalog__actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__progress {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 0.26rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__progress-meta,
|
||||
.simulation-catalog__progress-stage {
|
||||
overflow: hidden;
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.54rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.simulation-catalog__progress-track {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 0.3rem;
|
||||
border-radius: 999px;
|
||||
background: rgb(255 255 255 / 0.08);
|
||||
}
|
||||
|
||||
.simulation-catalog__progress-track i {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
min-width: 0;
|
||||
border-radius: inherit;
|
||||
background: rgb(var(--nodedc-accent-rgb));
|
||||
transition: width var(--nodedc-duration-fast) var(--nodedc-ease-standard);
|
||||
}
|
||||
|
||||
.simulation-catalog__progress[data-indeterminate="true"] .simulation-catalog__progress-track i {
|
||||
width: 28% !important;
|
||||
animation: simulation-progress-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.simulation-catalog__progress-stage {
|
||||
font-size: 0.52rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__state,
|
||||
.simulation-catalog__empty {
|
||||
min-height: 20rem;
|
||||
@@ -203,6 +278,67 @@
|
||||
font-size: 0.65rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__notice {
|
||||
justify-content: flex-start;
|
||||
gap: 0.55rem;
|
||||
border-radius: 0.85rem;
|
||||
background: rgb(var(--nodedc-danger-rgb) / 0.08);
|
||||
color: var(--nodedc-danger);
|
||||
padding: 0.7rem 0.85rem;
|
||||
font-size: 0.62rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__drop-overlay {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
gap: 0.55rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--nodedc-canvas);
|
||||
color: var(--nodedc-text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.simulation-catalog__drop-mark {
|
||||
display: grid;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin-bottom: 0.25rem;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--nodedc-glass-control-active);
|
||||
color: var(--nodedc-glass-control-active-text);
|
||||
}
|
||||
|
||||
.simulation-catalog__drop-overlay strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__drop-overlay span {
|
||||
font-size: 0.64rem;
|
||||
}
|
||||
|
||||
.simulation-catalog__drop-overlay small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.56rem;
|
||||
}
|
||||
|
||||
@keyframes simulation-progress-pulse {
|
||||
from { transform: translateX(-105%); }
|
||||
to { transform: translateX(360%); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.simulation-catalog__progress[data-indeterminate="true"] .simulation-catalog__progress-track i {
|
||||
animation: none;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.simulation-catalog__state > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
@@ -342,6 +478,9 @@
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar {
|
||||
position: sticky;
|
||||
z-index: 10;
|
||||
top: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1fr) auto minmax(12rem, 1fr);
|
||||
align-items: center;
|
||||
@@ -379,19 +518,34 @@
|
||||
top: calc(100% + 0.65rem);
|
||||
right: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
width: min(21rem, calc(100cqw - 1.3rem));
|
||||
gap: 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
background: rgb(25 27 31 / 0.98);
|
||||
box-shadow: 0 1rem 3rem rgb(0 0 0 / 0.42);
|
||||
padding: 0.8rem;
|
||||
max-height: min(44rem, calc(100vh - 7rem));
|
||||
overflow: clip;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-head {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
background: var(--nodedc-glass-panel-bg-strong);
|
||||
padding: 0.8rem;
|
||||
box-shadow: 0 0.55rem 1rem rgb(0 0 0 / 0.18);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-scroll {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.8rem 0.8rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-head > div,
|
||||
@@ -440,16 +594,49 @@
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-layer-row {
|
||||
.simulation-viewport__settings-rotations {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 4.5rem;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__axis-select {
|
||||
width: 4.5rem;
|
||||
min-width: 0;
|
||||
.simulation-viewport__rotation-row {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-row > strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-field {
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-field .nodedc-field__label-row {
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-field .nodedc-field__label {
|
||||
font-size: 0.54rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-input {
|
||||
min-height: 1.75rem;
|
||||
padding: 0.2rem 0.55rem;
|
||||
font-size: 0.64rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.simulation-viewport__rotation-input:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.simulation-viewport__settings-error {
|
||||
@@ -459,6 +646,30 @@
|
||||
padding: 0.55rem 0.65rem;
|
||||
}
|
||||
|
||||
.simulation-ugv-settings {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.simulation-ugv-settings__subhead {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.1rem;
|
||||
padding-top: 0.55rem;
|
||||
border-top: 1px solid rgb(255 255 255 / 0.055);
|
||||
}
|
||||
|
||||
.simulation-ugv-settings__subhead strong {
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.58rem;
|
||||
}
|
||||
|
||||
.simulation-ugv-settings__save {
|
||||
width: 100%;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar-balance {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -472,7 +683,7 @@
|
||||
.simulation-viewport__stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 30rem;
|
||||
min-height: 18rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -491,6 +702,16 @@
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.simulation-viewport[data-control-mode="ugv"] .simulation-viewport__stage canvas,
|
||||
.simulation-viewport[data-control-mode="ugv"] .simulation-viewport__stage canvas:active {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.simulation-viewport[data-control-mode="ugv"] .simulation-viewport__stage canvas.is-orbiting,
|
||||
.simulation-viewport[data-control-mode="ugv"] .simulation-viewport__stage canvas.is-orbiting:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas:focus-visible {
|
||||
box-shadow: inset 0 0 0 2px rgb(var(--nodedc-accent-rgb) / 0.7);
|
||||
}
|
||||
|
||||
@@ -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