fix(simulation): align LCC layers in PlayCanvas world
This commit is contained in:
@@ -1,10 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
Application,
|
Application,
|
||||||
Asset,
|
Asset,
|
||||||
|
BLEND_NORMAL,
|
||||||
Color,
|
Color,
|
||||||
|
CULLFACE_NONE,
|
||||||
Entity,
|
Entity,
|
||||||
|
FILLMODE_NONE,
|
||||||
|
Mat4,
|
||||||
|
RESOLUTION_AUTO,
|
||||||
|
StandardMaterial,
|
||||||
Vec2,
|
Vec2,
|
||||||
Vec3,
|
Vec3,
|
||||||
|
type ContainerResource,
|
||||||
|
type RenderComponent,
|
||||||
type ScriptType,
|
type ScriptType,
|
||||||
} from "playcanvas";
|
} from "playcanvas";
|
||||||
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
||||||
@@ -12,18 +20,58 @@ import { CameraControls } from "playcanvas/scripts/esm/camera-controls.mjs";
|
|||||||
import type { SimulationWorldManifest } from "../../core/simulation/projects";
|
import type { SimulationWorldManifest } from "../../core/simulation/projects";
|
||||||
|
|
||||||
export type SimulationViewMode = "visual" | "collision" | "combined";
|
export type SimulationViewMode = "visual" | "collision" | "combined";
|
||||||
export type SimulationQuality = "auto" | "low" | "medium" | "high";
|
export type SimulationQuality = "low" | "medium" | "high" | "ultra" | "maximum";
|
||||||
|
export type SimulationLayer = "visual" | "collision";
|
||||||
|
|
||||||
|
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 interface SimulationRuntime {
|
export interface SimulationRuntime {
|
||||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||||
setViewMode(mode: SimulationViewMode): void;
|
setViewMode(mode: SimulationViewMode): Promise<void>;
|
||||||
setQuality(quality: SimulationQuality): void;
|
setQuality(quality: SimulationQuality): void;
|
||||||
|
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
|
||||||
|
home(): void;
|
||||||
focusBounds(): void;
|
focusBounds(): void;
|
||||||
dispose(): void;
|
dispose(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus">;
|
type DesktopCameraInput = {
|
||||||
|
read(): { mouse: number[] } & Record<string, number[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CameraController = ScriptType & Pick<CameraControls, "reset" | "focus"> & {
|
||||||
|
_desktopInput?: DesktopCameraInput;
|
||||||
|
};
|
||||||
|
|
||||||
|
const HOME_POSITION = new Vec3(0, 1, 0);
|
||||||
|
const HOME_FOCUS = new Vec3(1, 1, 0);
|
||||||
|
|
||||||
|
const QUALITY_PROFILES: Record<SimulationQuality, {
|
||||||
|
lodBaseDistance: number;
|
||||||
|
lodMultiplier: number;
|
||||||
|
lodRangeMin: number;
|
||||||
|
lodRangeMax: number;
|
||||||
|
pixelRatio: number;
|
||||||
|
}> = {
|
||||||
|
low: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 3, lodRangeMax: 5, pixelRatio: 0.75 },
|
||||||
|
medium: { lodBaseDistance: 5, lodMultiplier: 2, lodRangeMin: 2, lodRangeMax: 5, pixelRatio: 1 },
|
||||||
|
high: { lodBaseDistance: 5, lodMultiplier: 3, lodRangeMin: 1, lodRangeMax: 5, pixelRatio: 1.5 },
|
||||||
|
ultra: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2 },
|
||||||
|
maximum: { lodBaseDistance: 7, lodMultiplier: 3, lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2 },
|
||||||
|
};
|
||||||
|
|
||||||
export class PlayCanvasRuntime implements SimulationRuntime {
|
export class PlayCanvasRuntime implements SimulationRuntime {
|
||||||
private app: Application | null = null;
|
private app: Application | null = null;
|
||||||
@@ -32,15 +80,21 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
private cameraController: CameraController | null = null;
|
private cameraController: CameraController | null = null;
|
||||||
private visualEntity: Entity | null = null;
|
private visualEntity: Entity | null = null;
|
||||||
private visualAsset: Asset | null = null;
|
private visualAsset: Asset | null = null;
|
||||||
|
private collisionEntity: Entity | null = null;
|
||||||
|
private collisionAsset: Asset | null = null;
|
||||||
|
private collisionMaterial: StandardMaterial | null = null;
|
||||||
|
private collisionSource: { meshUrl: string; projectId: string; worldTransform: number[] } | null = null;
|
||||||
|
private collisionLoadPromise: Promise<void> | null = null;
|
||||||
private resizeObserver: ResizeObserver | null = null;
|
private resizeObserver: ResizeObserver | null = null;
|
||||||
private viewMode: SimulationViewMode = "visual";
|
private viewMode: SimulationViewMode = "visual";
|
||||||
private quality: SimulationQuality = "auto";
|
private quality: SimulationQuality = "maximum";
|
||||||
|
|
||||||
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
||||||
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
||||||
this.canvas = canvas;
|
this.canvas = canvas;
|
||||||
canvas.tabIndex = 0;
|
canvas.tabIndex = 0;
|
||||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||||
|
canvas.addEventListener("pointerdown", focusCanvas, true);
|
||||||
const app = new Application(canvas, {
|
const app = new Application(canvas, {
|
||||||
graphicsDeviceOptions: {
|
graphicsDeviceOptions: {
|
||||||
antialias: true,
|
antialias: true,
|
||||||
@@ -50,6 +104,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||||
|
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||||
|
|
||||||
const camera = new Entity("SimulationCamera");
|
const camera = new Entity("SimulationCamera");
|
||||||
@@ -59,8 +115,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
farClip: 20_000,
|
farClip: 20_000,
|
||||||
fov: 58,
|
fov: 58,
|
||||||
});
|
});
|
||||||
camera.setPosition(5, 3, 5);
|
camera.setPosition(HOME_POSITION);
|
||||||
camera.lookAt(0, 0, 0);
|
camera.lookAt(HOME_FOCUS);
|
||||||
camera.addComponent("script");
|
camera.addComponent("script");
|
||||||
const controller = camera.script?.create(CameraControls, {
|
const controller = camera.script?.create(CameraControls, {
|
||||||
properties: {
|
properties: {
|
||||||
@@ -70,6 +126,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
zoomRange: new Vec2(0.05, 20_000),
|
zoomRange: new Vec2(0.05, 20_000),
|
||||||
},
|
},
|
||||||
}) as CameraController | null;
|
}) as CameraController | null;
|
||||||
|
if (controller) invertHorizontalCameraDrag(controller);
|
||||||
app.root.addChild(camera);
|
app.root.addChild(camera);
|
||||||
this.camera = camera;
|
this.camera = camera;
|
||||||
this.cameraController = controller;
|
this.cameraController = controller;
|
||||||
@@ -103,6 +160,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
const ready = (loaded: Asset) => {
|
const ready = (loaded: Asset) => {
|
||||||
const visual = new Entity("GaussianWorld");
|
const visual = new Entity("GaussianWorld");
|
||||||
visual.enabled = false;
|
visual.enabled = false;
|
||||||
|
applyWorldTransform(visual, manifest.transforms.worldFromVisual);
|
||||||
app.root.addChild(visual);
|
app.root.addChild(visual);
|
||||||
visual.addComponent("gsplat", {
|
visual.addComponent("gsplat", {
|
||||||
asset: loaded,
|
asset: loaded,
|
||||||
@@ -112,7 +170,6 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.visualEntity = visual;
|
this.visualEntity = visual;
|
||||||
this.applyQuality();
|
this.applyQuality();
|
||||||
this.applyViewMode();
|
this.applyViewMode();
|
||||||
this.focusBounds();
|
|
||||||
resolve();
|
resolve();
|
||||||
};
|
};
|
||||||
const failed = (error: unknown) => {
|
const failed = (error: unknown) => {
|
||||||
@@ -122,11 +179,24 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
asset.once("error", failed);
|
asset.once("error", failed);
|
||||||
app.assets.load(asset);
|
app.assets.load(asset);
|
||||||
});
|
});
|
||||||
|
if (manifest.collision.available && manifest.collision.meshUrl) {
|
||||||
|
this.collisionSource = {
|
||||||
|
meshUrl: manifest.collision.meshUrl,
|
||||||
|
projectId: manifest.projectId,
|
||||||
|
worldTransform: manifest.transforms.worldFromCollision,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.applyViewMode();
|
||||||
|
this.home();
|
||||||
}
|
}
|
||||||
|
|
||||||
setViewMode(mode: SimulationViewMode): void {
|
async setViewMode(mode: SimulationViewMode): Promise<void> {
|
||||||
this.viewMode = mode;
|
this.viewMode = mode;
|
||||||
this.applyViewMode();
|
this.applyViewMode();
|
||||||
|
if (mode !== "visual" && !this.collisionEntity) {
|
||||||
|
await this.ensureCollision();
|
||||||
|
this.applyViewMode();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setQuality(quality: SimulationQuality): void {
|
setQuality(quality: SimulationQuality): void {
|
||||||
@@ -134,6 +204,28 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.applyQuality();
|
this.applyQuality();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void {
|
||||||
|
if (transform.length !== 16) {
|
||||||
|
throw new Error("World transform должен содержать матрицу 4×4.");
|
||||||
|
}
|
||||||
|
if (layer === "visual") {
|
||||||
|
if (this.visualEntity) applyWorldTransform(this.visualEntity, transform);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.collisionSource) this.collisionSource.worldTransform = [...transform];
|
||||||
|
if (this.collisionEntity) applyWorldTransform(this.collisionEntity, transform);
|
||||||
|
}
|
||||||
|
|
||||||
|
home(): void {
|
||||||
|
if (this.cameraController) {
|
||||||
|
this.cameraController.reset(HOME_FOCUS, HOME_POSITION);
|
||||||
|
} else if (this.camera) {
|
||||||
|
this.camera.setPosition(HOME_POSITION);
|
||||||
|
this.camera.lookAt(HOME_FOCUS);
|
||||||
|
}
|
||||||
|
this.canvas?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
focusBounds(): void {
|
focusBounds(): void {
|
||||||
const bounds = this.visualEntity?.gsplat?.customAabb;
|
const bounds = this.visualEntity?.gsplat?.customAabb;
|
||||||
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
||||||
@@ -153,7 +245,10 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.resizeObserver = null;
|
this.resizeObserver = null;
|
||||||
this.unloadWorld();
|
this.unloadWorld();
|
||||||
if (this.app) this.app.destroy();
|
if (this.app) this.app.destroy();
|
||||||
if (this.canvas) this.canvas.removeEventListener("contextmenu", preventContextMenu);
|
if (this.canvas) {
|
||||||
|
this.canvas.removeEventListener("contextmenu", preventContextMenu);
|
||||||
|
this.canvas.removeEventListener("pointerdown", focusCanvas, true);
|
||||||
|
}
|
||||||
this.app = null;
|
this.app = null;
|
||||||
this.canvas = null;
|
this.canvas = null;
|
||||||
this.camera = null;
|
this.camera = null;
|
||||||
@@ -175,22 +270,100 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
|
|
||||||
private applyViewMode(): void {
|
private applyViewMode(): void {
|
||||||
if (this.visualEntity) {
|
if (this.visualEntity) {
|
||||||
this.visualEntity.enabled = this.viewMode !== "collision";
|
this.visualEntity.enabled = this.viewMode !== "collision" || !this.collisionEntity;
|
||||||
|
}
|
||||||
|
if (this.collisionEntity) {
|
||||||
|
this.collisionEntity.enabled = this.viewMode !== "visual";
|
||||||
|
}
|
||||||
|
if (this.collisionMaterial) {
|
||||||
|
const combined = this.viewMode === "combined";
|
||||||
|
this.collisionMaterial.opacity = combined ? 0.34 : 0.82;
|
||||||
|
this.collisionMaterial.depthWrite = !combined;
|
||||||
|
this.collisionMaterial.update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyQuality(): void {
|
private applyQuality(): void {
|
||||||
const gsplat = this.visualEntity?.gsplat;
|
const gsplat = this.visualEntity?.gsplat;
|
||||||
if (!gsplat) return;
|
const profile = QUALITY_PROFILES[this.quality];
|
||||||
const profiles: Record<SimulationQuality, [number, number]> = {
|
if (gsplat) {
|
||||||
auto: [5, 2],
|
gsplat.lodBaseDistance = profile.lodBaseDistance;
|
||||||
low: [2.5, 1.7],
|
gsplat.lodMultiplier = profile.lodMultiplier;
|
||||||
medium: [5, 2],
|
gsplat.lodRangeMin = profile.lodRangeMin;
|
||||||
high: [9, 2.3],
|
gsplat.lodRangeMax = profile.lodRangeMax;
|
||||||
};
|
}
|
||||||
const [baseDistance, multiplier] = profiles[this.quality];
|
if (this.app) {
|
||||||
gsplat.lodBaseDistance = baseDistance;
|
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||||
gsplat.lodMultiplier = multiplier;
|
this.app.graphicsDevice.maxPixelRatio = Math.min(devicePixelRatio, profile.pixelRatio);
|
||||||
|
this.resize();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadCollision(meshUrl: string, projectId: string, worldTransform: number[]): Promise<void> {
|
||||||
|
const app = this.requiredApp();
|
||||||
|
const asset = new Asset(
|
||||||
|
`SimulationCollision:${projectId}`,
|
||||||
|
"container",
|
||||||
|
{ url: meshUrl, filename: "scene.collision.glb" },
|
||||||
|
);
|
||||||
|
app.assets.add(asset);
|
||||||
|
this.collisionAsset = asset;
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
asset.ready((loaded) => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.collisionEntity = entity;
|
||||||
|
this.collisionMaterial = material;
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
asset.once("error", (error: unknown) => {
|
||||||
|
reject(new Error(error instanceof Error ? error.message : "PlayCanvas не загрузил collision GLB."));
|
||||||
|
});
|
||||||
|
app.assets.load(asset);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCollision(): Promise<void> {
|
||||||
|
if (this.collisionEntity) return;
|
||||||
|
if (!this.collisionSource) {
|
||||||
|
throw new Error("Collision GLB отсутствует в world manifest.");
|
||||||
|
}
|
||||||
|
if (!this.collisionLoadPromise) {
|
||||||
|
this.collisionLoadPromise = this.loadCollision(
|
||||||
|
this.collisionSource.meshUrl,
|
||||||
|
this.collisionSource.projectId,
|
||||||
|
this.collisionSource.worldTransform,
|
||||||
|
).finally(() => {
|
||||||
|
this.collisionLoadPromise = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.collisionLoadPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
private unloadWorld(): void {
|
private unloadWorld(): void {
|
||||||
@@ -203,9 +376,46 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
|||||||
this.visualAsset.unload();
|
this.visualAsset.unload();
|
||||||
this.visualAsset = null;
|
this.visualAsset = null;
|
||||||
}
|
}
|
||||||
|
if (this.collisionEntity) {
|
||||||
|
this.collisionEntity.destroy();
|
||||||
|
this.collisionEntity = null;
|
||||||
|
}
|
||||||
|
if (this.collisionAsset && this.app) {
|
||||||
|
this.app.assets.remove(this.collisionAsset);
|
||||||
|
this.collisionAsset.unload();
|
||||||
|
this.collisionAsset = null;
|
||||||
|
}
|
||||||
|
this.collisionMaterial?.destroy();
|
||||||
|
this.collisionMaterial = null;
|
||||||
|
this.collisionSource = null;
|
||||||
|
this.collisionLoadPromise = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function preventContextMenu(event: Event): void {
|
function preventContextMenu(event: Event): void {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function focusCanvas(event: Event): void {
|
||||||
|
(event.currentTarget as HTMLCanvasElement | null)?.focus({ preventScroll: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function invertHorizontalCameraDrag(controller: CameraController): void {
|
||||||
|
// CameraControls 2.21.4 deliberately exposes one pinned input source here.
|
||||||
|
// Keep its pointer-capture and fly/orbit behavior, changing only horizontal drag semantics.
|
||||||
|
const input = controller._desktopInput;
|
||||||
|
if (!input) return;
|
||||||
|
const read = input.read.bind(input);
|
||||||
|
input.read = () => {
|
||||||
|
const frame = read();
|
||||||
|
if (frame.mouse.length > 0) frame.mouse[0] *= -1;
|
||||||
|
return frame;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWorldTransform(entity: Entity, values: number[]): void {
|
||||||
|
const transform = new Mat4().set(values);
|
||||||
|
entity.setLocalPosition(transform.getTranslation());
|
||||||
|
entity.setLocalEulerAngles(transform.getEulerAngles());
|
||||||
|
entity.setLocalScale(transform.getScale());
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useId, useRef, useState } from "react";
|
||||||
import { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
|
import {
|
||||||
|
ActivityIndicator,
|
||||||
|
Button,
|
||||||
|
Icon,
|
||||||
|
IconButton,
|
||||||
|
SegmentedControl,
|
||||||
|
Select,
|
||||||
|
StatusBadge,
|
||||||
|
Switch,
|
||||||
|
} from "@nodedc/ui-react";
|
||||||
|
|
||||||
import type { SimulationProject } from "../../core/simulation/projects";
|
import type { SimulationProject } from "../../core/simulation/projects";
|
||||||
import {
|
import {
|
||||||
|
PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||||
|
PLAYCANVAS_X_180_TRANSFORM,
|
||||||
PlayCanvasRuntime,
|
PlayCanvasRuntime,
|
||||||
type SimulationQuality,
|
type SimulationQuality,
|
||||||
type SimulationViewMode,
|
type SimulationViewMode,
|
||||||
@@ -11,10 +22,25 @@ import {
|
|||||||
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||||
|
const settingsId = useId();
|
||||||
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
|
const [viewMode, setViewMode] = useState<SimulationViewMode>("visual");
|
||||||
const [quality, setQuality] = useState<SimulationQuality>("auto");
|
const [quality, setQuality] = useState<SimulationQuality>("maximum");
|
||||||
|
const [collisionState, setCollisionState] = useState<"idle" | "loading" | "ready" | "failed">("idle");
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||||
|
const [visualInverted, setVisualInverted] = useState(
|
||||||
|
() => isX180Transform(project.worldManifest?.transforms.worldFromVisual),
|
||||||
|
);
|
||||||
|
const [collisionInverted, setCollisionInverted] = useState(
|
||||||
|
() => isX180Transform(project.worldManifest?.transforms.worldFromCollision),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setVisualInverted(isX180Transform(project.worldManifest?.transforms.worldFromVisual));
|
||||||
|
setCollisionInverted(isX180Transform(project.worldManifest?.transforms.worldFromCollision));
|
||||||
|
setSettingsOpen(false);
|
||||||
|
}, [project.projectId, project.worldManifest]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -48,41 +74,124 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
|||||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||||
<header className="simulation-viewport__toolbar">
|
<header className="simulation-viewport__toolbar">
|
||||||
<div>
|
<div>
|
||||||
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
|
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
|
||||||
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
|
{collisionState === "loading"
|
||||||
|
? "Загрузка collision"
|
||||||
|
: collisionState === "failed"
|
||||||
|
? "Ошибка collision"
|
||||||
|
: state === "ready"
|
||||||
|
? "Runtime готов"
|
||||||
|
: state === "failed"
|
||||||
|
? "Ошибка runtime"
|
||||||
|
: "Загрузка сцены"}
|
||||||
</StatusBadge>
|
</StatusBadge>
|
||||||
<span>PlayCanvas Engine 2.21.4</span>
|
<span>PlayCanvas Engine 2.21.4</span>
|
||||||
</div>
|
</div>
|
||||||
<SegmentedControl
|
<div className="simulation-viewport__controls">
|
||||||
label="Слой сцены"
|
<Select
|
||||||
value={viewMode}
|
label="Качество Gaussian-сцены"
|
||||||
onChange={(next) => {
|
value={quality}
|
||||||
setViewMode(next);
|
onChange={(next) => {
|
||||||
runtimeRef.current?.setViewMode(next);
|
setQuality(next);
|
||||||
}}
|
runtimeRef.current?.setQuality(next);
|
||||||
items={[
|
}}
|
||||||
{ value: "visual", label: "Визуал" },
|
options={[
|
||||||
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
{ value: "maximum", label: "Максимум", description: "Только полный LOD 0, Retina до 2×" },
|
||||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
{ value: "ultra", label: "Ультра", description: "LOD 0 и Retina до 2×" },
|
||||||
]}
|
{ value: "high", label: "Высокое", description: "LOD 1 и Retina до 1,5×" },
|
||||||
/>
|
{ value: "medium", label: "Среднее", description: "LOD 2 и обычное разрешение" },
|
||||||
<SegmentedControl
|
{ value: "low", label: "Низкое", description: "LOD 3 для слабых устройств" },
|
||||||
label="Качество Streamed SOG"
|
]}
|
||||||
value={quality}
|
minMenuWidth={230}
|
||||||
onChange={(next) => {
|
menuWidth={230}
|
||||||
setQuality(next);
|
/>
|
||||||
runtimeRef.current?.setQuality(next);
|
<SegmentedControl
|
||||||
}}
|
label="Слой сцены"
|
||||||
items={[
|
value={viewMode}
|
||||||
{ value: "auto", label: "Auto" },
|
onChange={(next) => {
|
||||||
{ value: "low", label: "Low" },
|
setViewMode(next);
|
||||||
{ value: "medium", label: "Med" },
|
const runtime = runtimeRef.current;
|
||||||
{ value: "high", label: "High" },
|
if (!runtime) return;
|
||||||
]}
|
if (next !== "visual" && collisionAvailable && collisionState !== "ready") {
|
||||||
/>
|
setCollisionState("loading");
|
||||||
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.focusBounds()}>
|
}
|
||||||
Вписать сцену
|
void runtime.setViewMode(next).then(() => {
|
||||||
</Button>
|
if (next !== "visual") setCollisionState("ready");
|
||||||
|
}).catch((caught: unknown) => {
|
||||||
|
setCollisionState("failed");
|
||||||
|
setError(caught instanceof Error ? caught.message : "Не удалось открыть collision GLB.");
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
items={[
|
||||||
|
{ value: "visual", label: "Визуал" },
|
||||||
|
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
||||||
|
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.home()}>
|
||||||
|
Домой
|
||||||
|
</Button>
|
||||||
|
<div className="simulation-viewport__settings-anchor">
|
||||||
|
<IconButton
|
||||||
|
label="Настройки системы координат"
|
||||||
|
aria-controls={settingsId}
|
||||||
|
aria-expanded={settingsOpen}
|
||||||
|
aria-pressed={settingsOpen}
|
||||||
|
onClick={() => setSettingsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<Icon name="settings" size={18} />
|
||||||
|
</IconButton>
|
||||||
|
{settingsOpen ? (
|
||||||
|
<div
|
||||||
|
id={settingsId}
|
||||||
|
className="simulation-viewport__settings"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="Настройки системы координат"
|
||||||
|
>
|
||||||
|
<div className="simulation-viewport__settings-head">
|
||||||
|
<div>
|
||||||
|
<strong>Система координат</strong>
|
||||||
|
<span>Мир PlayCanvas · Y вверх</span>
|
||||||
|
</div>
|
||||||
|
<IconButton label="Закрыть настройки" onClick={() => setSettingsOpen(false)}>
|
||||||
|
<Icon name="close" size={16} />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Коррекция применяется к слоям независимо и не меняет координаты камеры,
|
||||||
|
навигации и будущей физики.
|
||||||
|
</p>
|
||||||
|
<div className="simulation-viewport__settings-switches">
|
||||||
|
<Switch
|
||||||
|
checked={visualInverted}
|
||||||
|
disabled={state !== "ready"}
|
||||||
|
label="Инверсия визуального слоя"
|
||||||
|
onChange={(checked) => {
|
||||||
|
setVisualInverted(checked);
|
||||||
|
runtimeRef.current?.setLayerWorldTransform(
|
||||||
|
"visual",
|
||||||
|
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Switch
|
||||||
|
checked={collisionInverted}
|
||||||
|
disabled={state !== "ready"}
|
||||||
|
label="Инверсия collision-слоя"
|
||||||
|
onChange={(checked) => {
|
||||||
|
setCollisionInverted(checked);
|
||||||
|
runtimeRef.current?.setLayerWorldTransform(
|
||||||
|
"collision",
|
||||||
|
checked ? PLAYCANVAS_X_180_TRANSFORM : PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="simulation-viewport__toolbar-balance" aria-hidden="true" />
|
||||||
</header>
|
</header>
|
||||||
<div className="simulation-viewport__stage">
|
<div className="simulation-viewport__stage">
|
||||||
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
||||||
@@ -99,7 +208,17 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
|||||||
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
||||||
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
||||||
</footer>
|
</footer>
|
||||||
|
) : collisionState === "failed" ? (
|
||||||
|
<footer className="simulation-viewport__notice" role="alert">
|
||||||
|
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
|
||||||
|
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
|
||||||
|
</footer>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isX180Transform(transform: number[] | undefined): boolean {
|
||||||
|
if (!transform || transform.length !== 16) return false;
|
||||||
|
return transform.every((value, index) => value === PLAYCANVAS_X_180_TRANSFORM[index]);
|
||||||
|
}
|
||||||
|
|||||||
@@ -332,6 +332,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport {
|
.simulation-viewport {
|
||||||
|
container-type: inline-size;
|
||||||
display: grid;
|
display: grid;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||||
@@ -341,7 +342,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport__toolbar {
|
.simulation-viewport__toolbar {
|
||||||
flex-wrap: wrap;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(12rem, 1fr) auto minmax(12rem, 1fr);
|
||||||
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
border-bottom: 1px solid var(--station-hairline);
|
border-bottom: 1px solid var(--station-hairline);
|
||||||
background: var(--nodedc-glass-panel-bg-soft);
|
background: var(--nodedc-glass-panel-bg-soft);
|
||||||
@@ -356,6 +359,74 @@
|
|||||||
gap: 0.45rem;
|
gap: 0.45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__controls {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings-anchor {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 12;
|
||||||
|
top: calc(100% + 0.65rem);
|
||||||
|
right: 0;
|
||||||
|
display: grid;
|
||||||
|
width: min(19rem, 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings-head > div,
|
||||||
|
.simulation-viewport__settings-switches {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings strong {
|
||||||
|
color: var(--nodedc-text-primary);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings span,
|
||||||
|
.simulation-viewport__settings p {
|
||||||
|
color: var(--nodedc-text-muted);
|
||||||
|
font-size: 0.56rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__settings-switches {
|
||||||
|
gap: 0.55rem;
|
||||||
|
border-radius: 0.72rem;
|
||||||
|
background: rgb(255 255 255 / 0.035);
|
||||||
|
padding: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__toolbar-balance {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.simulation-viewport__toolbar > div:first-child > span:last-child,
|
.simulation-viewport__toolbar > div:first-child > span:last-child,
|
||||||
.simulation-viewport__notice > span:last-child {
|
.simulation-viewport__notice > span:last-child {
|
||||||
color: var(--nodedc-text-muted);
|
color: var(--nodedc-text-muted);
|
||||||
@@ -364,14 +435,24 @@
|
|||||||
|
|
||||||
.simulation-viewport__stage {
|
.simulation-viewport__stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
min-height: 30rem;
|
min-height: 30rem;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport__stage canvas {
|
.simulation-viewport__stage canvas {
|
||||||
display: block;
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__stage canvas:active {
|
||||||
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
.simulation-viewport__stage canvas:focus-visible {
|
.simulation-viewport__stage canvas:focus-visible {
|
||||||
@@ -407,6 +488,21 @@
|
|||||||
padding: 0.48rem 0.65rem;
|
padding: 0.48rem 0.65rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@container (max-width: 68rem) {
|
||||||
|
.simulation-viewport__toolbar {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__controls {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.simulation-viewport__toolbar-balance {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.simulation-workspace__processing {
|
.simulation-workspace__processing {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
|
|||||||
@@ -63,11 +63,29 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
|
|||||||
assert.match(packageDocument, /"playcanvas": "2\.21\.4"/);
|
assert.match(packageDocument, /"playcanvas": "2\.21\.4"/);
|
||||||
assert.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
assert.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
||||||
assert.match(runtime, /new Application\(canvas/);
|
assert.match(runtime, /new Application\(canvas/);
|
||||||
|
assert.match(runtime, /setCanvasFillMode\(FILLMODE_NONE/);
|
||||||
|
assert.match(runtime, /setCanvasResolution\(RESOLUTION_AUTO\)/);
|
||||||
assert.match(runtime, /new Asset\([^,]+, "gsplat"/);
|
assert.match(runtime, /new Asset\([^,]+, "gsplat"/);
|
||||||
assert.match(runtime, /camera\.script\?\.create\(CameraControls/);
|
assert.match(runtime, /camera\.script\?\.create\(CameraControls/);
|
||||||
|
assert.match(runtime, /const HOME_POSITION = new Vec3\(0, 1, 0\)/);
|
||||||
|
assert.match(runtime, /const HOME_FOCUS = new Vec3\(1, 1, 0\)/);
|
||||||
|
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 0, pixelRatio: 2/);
|
||||||
|
assert.match(runtime, /frame\.mouse\[0\] \*= -1/);
|
||||||
|
assert.match(runtime, /new Asset\([\s\S]*"container"/);
|
||||||
|
assert.match(runtime, /instantiateRenderEntity/);
|
||||||
|
assert.match(runtime, /applyWorldTransform/);
|
||||||
|
assert.match(runtime, /setLayerWorldTransform/);
|
||||||
|
assert.match(runtime, /PLAYCANVAS_X_180_TRANSFORM/);
|
||||||
|
assert.match(runtime, /ensureCollision/);
|
||||||
assert.match(runtime, /app\.root\.addChild/);
|
assert.match(runtime, /app\.root\.addChild/);
|
||||||
assert.match(runtime, /dispose\(\)/);
|
assert.match(runtime, /dispose\(\)/);
|
||||||
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
||||||
assert.match(viewport, /Collision недоступен/);
|
assert.match(viewport, /Collision недоступен/);
|
||||||
assert.match(viewport, /Для этой сборки collision GLB не был запрошен/);
|
assert.match(viewport, /Для этой сборки collision GLB не был запрошен/);
|
||||||
|
assert.match(viewport, /<Select[\s\S]*Качество Gaussian-сцены/);
|
||||||
|
assert.match(viewport, /Максимум/);
|
||||||
|
assert.match(viewport, /runtimeRef\.current\?\.home\(\)/);
|
||||||
|
assert.match(viewport, /Настройки системы координат/);
|
||||||
|
assert.match(viewport, /Инверсия визуального слоя/);
|
||||||
|
assert.match(viewport, /Инверсия collision-слоя/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -297,9 +297,17 @@ class SimulationProjectStore:
|
|||||||
result: dict[str, Any],
|
result: dict[str, Any],
|
||||||
artifacts: list[dict[str, Any]],
|
artifacts: list[dict[str, Any]],
|
||||||
world_manifest: dict[str, Any],
|
world_manifest: dict[str, Any],
|
||||||
|
provider_job_id: str | None = None,
|
||||||
|
provider_progress: object = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
document = self._read(project_id)
|
document = self._read(project_id)
|
||||||
|
if provider_job_id is not None:
|
||||||
|
if PROVIDER_JOB_ID_PATTERN.fullmatch(provider_job_id) is None:
|
||||||
|
raise SimulationProjectError("simulation provider job id is invalid")
|
||||||
|
document["provider"]["job_id"] = provider_job_id
|
||||||
|
if provider_progress is not None:
|
||||||
|
document["provider"]["progress"] = provider_progress
|
||||||
document["status"] = "ready"
|
document["status"] = "ready"
|
||||||
document["provider"]["state"] = "ready"
|
document["provider"]["state"] = "ready"
|
||||||
document["provider"]["runtime"] = result.get("runtime")
|
document["provider"]["runtime"] = result.get("runtime")
|
||||||
@@ -464,10 +472,10 @@ class SimulationProjectService:
|
|||||||
"outputs": {
|
"outputs": {
|
||||||
"preview_sog": True,
|
"preview_sog": True,
|
||||||
"streamed_sog": True,
|
"streamed_sog": True,
|
||||||
"collision": False,
|
"collision": True,
|
||||||
},
|
},
|
||||||
"preview_lod": "coarsest",
|
"preview_lod": "coarsest",
|
||||||
"collision_profile": None,
|
"collision_profile": _collision_profile(str(project["scene_type"])),
|
||||||
}
|
}
|
||||||
submitted = provider.submit_build(request)
|
submitted = provider.submit_build(request)
|
||||||
job_id = submitted.get("job_id")
|
job_id = submitted.get("job_id")
|
||||||
@@ -607,12 +615,24 @@ def _world_manifest(project_id: str, artifacts: list[dict[str, Any]]) -> dict[st
|
|||||||
"available": url_for("collision-mesh") is not None,
|
"available": url_for("collision-mesh") is not None,
|
||||||
},
|
},
|
||||||
"transforms": {
|
"transforms": {
|
||||||
"world_from_visual": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
"world_from_visual": [1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1],
|
||||||
"world_from_collision": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
"world_from_collision": [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _collision_profile(scene_type: str) -> dict[str, Any]:
|
||||||
|
"""Build the portable walkable-volume contract around the scanner origin."""
|
||||||
|
return {
|
||||||
|
"scene_type": scene_type,
|
||||||
|
"seed_position": [0, 1, 0],
|
||||||
|
"capsule_height": 1.6,
|
||||||
|
"capsule_radius": 0.2,
|
||||||
|
"voxel_size": 0.05,
|
||||||
|
"mesh_shape": "smooth",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _project_name(value: str) -> str:
|
def _project_name(value: str) -> str:
|
||||||
normalized = " ".join(value.split())
|
normalized = " ".join(value.split())
|
||||||
if not 1 <= len(normalized) <= 120:
|
if not 1 <= len(normalized) <= 120:
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ class _ReadyProvider:
|
|||||||
self.deleted: list[str] = []
|
self.deleted: list[str] = []
|
||||||
self.upload_calls = 0
|
self.upload_calls = 0
|
||||||
self.submit_calls = 0
|
self.submit_calls = 0
|
||||||
|
self.submitted_document: dict[str, object] | None = None
|
||||||
|
|
||||||
def capabilities(self) -> dict[str, object]:
|
def capabilities(self) -> dict[str, object]:
|
||||||
return {"outputs": ["preview.sog", "streamed-sog"]}
|
return {"outputs": ["preview.sog", "streamed-sog"]}
|
||||||
@@ -118,8 +119,9 @@ class _ReadyProvider:
|
|||||||
members=members,
|
members=members,
|
||||||
)
|
)
|
||||||
|
|
||||||
def submit_build(self, _document: dict[str, object]) -> dict[str, object]:
|
def submit_build(self, document: dict[str, object]) -> dict[str, object]:
|
||||||
self.submit_calls += 1
|
self.submit_calls += 1
|
||||||
|
self.submitted_document = document
|
||||||
return {
|
return {
|
||||||
"schema_version": "gaussian-pipeline.job/v1",
|
"schema_version": "gaussian-pipeline.job/v1",
|
||||||
"job_id": "gsp-20260826000000-deadbeef",
|
"job_id": "gsp-20260826000000-deadbeef",
|
||||||
@@ -195,8 +197,28 @@ def test_service_materializes_world_manifest_and_deletes_both_copies(tmp_path: P
|
|||||||
assert ready["status"] == "ready"
|
assert ready["status"] == "ready"
|
||||||
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
|
assert ready["world_manifest"]["visual"]["preview_sog_url"].endswith("/preview.sog")
|
||||||
assert ready["world_manifest"]["collision"]["available"] is False
|
assert ready["world_manifest"]["collision"]["available"] is False
|
||||||
|
assert ready["world_manifest"]["transforms"]["world_from_visual"] == [
|
||||||
|
1, 0, 0, 0, 0, -1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1,
|
||||||
|
]
|
||||||
|
assert ready["world_manifest"]["transforms"]["world_from_collision"] == [
|
||||||
|
1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1,
|
||||||
|
]
|
||||||
assert provider.upload_calls == 1
|
assert provider.upload_calls == 1
|
||||||
assert provider.submit_calls == 1
|
assert provider.submit_calls == 1
|
||||||
|
assert provider.submitted_document is not None
|
||||||
|
assert provider.submitted_document["outputs"] == {
|
||||||
|
"preview_sog": True,
|
||||||
|
"streamed_sog": True,
|
||||||
|
"collision": True,
|
||||||
|
}
|
||||||
|
assert provider.submitted_document["collision_profile"] == {
|
||||||
|
"scene_type": "interior",
|
||||||
|
"seed_position": [0, 1, 0],
|
||||||
|
"capsule_height": 1.6,
|
||||||
|
"capsule_radius": 0.2,
|
||||||
|
"voxel_size": 0.05,
|
||||||
|
"mesh_shape": "smooth",
|
||||||
|
}
|
||||||
service.delete(project["project_id"])
|
service.delete(project["project_id"])
|
||||||
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
assert provider.deleted == ["gsp-20260826000000-deadbeef"]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user