fix(simulation): align LCC layers in PlayCanvas world
This commit is contained in:
@@ -1,10 +1,18 @@
|
||||
import {
|
||||
Application,
|
||||
Asset,
|
||||
BLEND_NORMAL,
|
||||
Color,
|
||||
CULLFACE_NONE,
|
||||
Entity,
|
||||
FILLMODE_NONE,
|
||||
Mat4,
|
||||
RESOLUTION_AUTO,
|
||||
StandardMaterial,
|
||||
Vec2,
|
||||
Vec3,
|
||||
type ContainerResource,
|
||||
type RenderComponent,
|
||||
type ScriptType,
|
||||
} from "playcanvas";
|
||||
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";
|
||||
|
||||
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 {
|
||||
mount(canvas: HTMLCanvasElement): Promise<void>;
|
||||
loadWorld(manifest: SimulationWorldManifest): Promise<void>;
|
||||
setViewMode(mode: SimulationViewMode): void;
|
||||
setViewMode(mode: SimulationViewMode): Promise<void>;
|
||||
setQuality(quality: SimulationQuality): void;
|
||||
setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void;
|
||||
home(): void;
|
||||
focusBounds(): 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 {
|
||||
private app: Application | null = null;
|
||||
@@ -32,15 +80,21 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
private cameraController: CameraController | null = null;
|
||||
private visualEntity: Entity | 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 viewMode: SimulationViewMode = "visual";
|
||||
private quality: SimulationQuality = "auto";
|
||||
private quality: SimulationQuality = "maximum";
|
||||
|
||||
async mount(canvas: HTMLCanvasElement): Promise<void> {
|
||||
if (this.app) throw new Error("PlayCanvas runtime уже смонтирован.");
|
||||
this.canvas = canvas;
|
||||
canvas.tabIndex = 0;
|
||||
canvas.addEventListener("contextmenu", preventContextMenu);
|
||||
canvas.addEventListener("pointerdown", focusCanvas, true);
|
||||
const app = new Application(canvas, {
|
||||
graphicsDeviceOptions: {
|
||||
antialias: true,
|
||||
@@ -50,6 +104,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
},
|
||||
});
|
||||
this.app = app;
|
||||
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
|
||||
app.setCanvasResolution(RESOLUTION_AUTO);
|
||||
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
|
||||
|
||||
const camera = new Entity("SimulationCamera");
|
||||
@@ -59,8 +115,8 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
farClip: 20_000,
|
||||
fov: 58,
|
||||
});
|
||||
camera.setPosition(5, 3, 5);
|
||||
camera.lookAt(0, 0, 0);
|
||||
camera.setPosition(HOME_POSITION);
|
||||
camera.lookAt(HOME_FOCUS);
|
||||
camera.addComponent("script");
|
||||
const controller = camera.script?.create(CameraControls, {
|
||||
properties: {
|
||||
@@ -70,6 +126,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
zoomRange: new Vec2(0.05, 20_000),
|
||||
},
|
||||
}) as CameraController | null;
|
||||
if (controller) invertHorizontalCameraDrag(controller);
|
||||
app.root.addChild(camera);
|
||||
this.camera = camera;
|
||||
this.cameraController = controller;
|
||||
@@ -103,6 +160,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
const ready = (loaded: Asset) => {
|
||||
const visual = new Entity("GaussianWorld");
|
||||
visual.enabled = false;
|
||||
applyWorldTransform(visual, manifest.transforms.worldFromVisual);
|
||||
app.root.addChild(visual);
|
||||
visual.addComponent("gsplat", {
|
||||
asset: loaded,
|
||||
@@ -112,7 +170,6 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.visualEntity = visual;
|
||||
this.applyQuality();
|
||||
this.applyViewMode();
|
||||
this.focusBounds();
|
||||
resolve();
|
||||
};
|
||||
const failed = (error: unknown) => {
|
||||
@@ -122,11 +179,24 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
asset.once("error", failed);
|
||||
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.applyViewMode();
|
||||
if (mode !== "visual" && !this.collisionEntity) {
|
||||
await this.ensureCollision();
|
||||
this.applyViewMode();
|
||||
}
|
||||
}
|
||||
|
||||
setQuality(quality: SimulationQuality): void {
|
||||
@@ -134,6 +204,28 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
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 {
|
||||
const bounds = this.visualEntity?.gsplat?.customAabb;
|
||||
const focus = bounds?.center.clone() ?? new Vec3(0, 0, 0);
|
||||
@@ -153,7 +245,10 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.resizeObserver = null;
|
||||
this.unloadWorld();
|
||||
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.canvas = null;
|
||||
this.camera = null;
|
||||
@@ -175,22 +270,100 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
|
||||
private applyViewMode(): void {
|
||||
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 {
|
||||
const gsplat = this.visualEntity?.gsplat;
|
||||
if (!gsplat) return;
|
||||
const profiles: Record<SimulationQuality, [number, number]> = {
|
||||
auto: [5, 2],
|
||||
low: [2.5, 1.7],
|
||||
medium: [5, 2],
|
||||
high: [9, 2.3],
|
||||
};
|
||||
const [baseDistance, multiplier] = profiles[this.quality];
|
||||
gsplat.lodBaseDistance = baseDistance;
|
||||
gsplat.lodMultiplier = multiplier;
|
||||
const profile = QUALITY_PROFILES[this.quality];
|
||||
if (gsplat) {
|
||||
gsplat.lodBaseDistance = profile.lodBaseDistance;
|
||||
gsplat.lodMultiplier = profile.lodMultiplier;
|
||||
gsplat.lodRangeMin = profile.lodRangeMin;
|
||||
gsplat.lodRangeMax = profile.lodRangeMax;
|
||||
}
|
||||
if (this.app) {
|
||||
const devicePixelRatio = window.devicePixelRatio || 1;
|
||||
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 {
|
||||
@@ -203,9 +376,46 @@ export class PlayCanvasRuntime implements SimulationRuntime {
|
||||
this.visualAsset.unload();
|
||||
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 {
|
||||
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 { ActivityIndicator, Button, SegmentedControl, StatusBadge } from "@nodedc/ui-react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
Icon,
|
||||
IconButton,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
StatusBadge,
|
||||
Switch,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { SimulationProject } from "../../core/simulation/projects";
|
||||
import {
|
||||
PLAYCANVAS_IDENTITY_TRANSFORM,
|
||||
PLAYCANVAS_X_180_TRANSFORM,
|
||||
PlayCanvasRuntime,
|
||||
type SimulationQuality,
|
||||
type SimulationViewMode,
|
||||
@@ -11,10 +22,25 @@ import {
|
||||
export function SimulationViewport({ project }: { project: SimulationProject }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const runtimeRef = useRef<PlayCanvasRuntime | null>(null);
|
||||
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 [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(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@@ -48,41 +74,124 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
<section className="simulation-viewport" aria-label={`Сцена ${project.name}`}>
|
||||
<header className="simulation-viewport__toolbar">
|
||||
<div>
|
||||
<StatusBadge tone={state === "ready" ? "success" : state === "failed" ? "warning" : "accent"}>
|
||||
{state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"}
|
||||
<StatusBadge tone={state === "failed" || collisionState === "failed" ? "warning" : state === "ready" && collisionState !== "loading" ? "success" : "accent"}>
|
||||
{collisionState === "loading"
|
||||
? "Загрузка collision"
|
||||
: collisionState === "failed"
|
||||
? "Ошибка collision"
|
||||
: state === "ready"
|
||||
? "Runtime готов"
|
||||
: state === "failed"
|
||||
? "Ошибка runtime"
|
||||
: "Загрузка сцены"}
|
||||
</StatusBadge>
|
||||
<span>PlayCanvas Engine 2.21.4</span>
|
||||
</div>
|
||||
<SegmentedControl
|
||||
label="Слой сцены"
|
||||
value={viewMode}
|
||||
onChange={(next) => {
|
||||
setViewMode(next);
|
||||
runtimeRef.current?.setViewMode(next);
|
||||
}}
|
||||
items={[
|
||||
{ value: "visual", label: "Визуал" },
|
||||
{ value: "collision", label: "Коллизии", disabled: !collisionAvailable },
|
||||
{ value: "combined", label: "Вместе", disabled: !collisionAvailable },
|
||||
]}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Качество Streamed SOG"
|
||||
value={quality}
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
}}
|
||||
items={[
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "low", label: "Low" },
|
||||
{ value: "medium", label: "Med" },
|
||||
{ value: "high", label: "High" },
|
||||
]}
|
||||
/>
|
||||
<Button size="compact" variant="secondary" onClick={() => runtimeRef.current?.focusBounds()}>
|
||||
Вписать сцену
|
||||
</Button>
|
||||
<div className="simulation-viewport__controls">
|
||||
<Select
|
||||
label="Качество Gaussian-сцены"
|
||||
value={quality}
|
||||
onChange={(next) => {
|
||||
setQuality(next);
|
||||
runtimeRef.current?.setQuality(next);
|
||||
}}
|
||||
options={[
|
||||
{ value: "maximum", label: "Максимум", description: "Только полный LOD 0, Retina до 2×" },
|
||||
{ value: "ultra", label: "Ультра", description: "LOD 0 и Retina до 2×" },
|
||||
{ value: "high", label: "Высокое", description: "LOD 1 и Retina до 1,5×" },
|
||||
{ value: "medium", label: "Среднее", description: "LOD 2 и обычное разрешение" },
|
||||
{ value: "low", label: "Низкое", description: "LOD 3 для слабых устройств" },
|
||||
]}
|
||||
minMenuWidth={230}
|
||||
menuWidth={230}
|
||||
/>
|
||||
<SegmentedControl
|
||||
label="Слой сцены"
|
||||
value={viewMode}
|
||||
onChange={(next) => {
|
||||
setViewMode(next);
|
||||
const runtime = runtimeRef.current;
|
||||
if (!runtime) return;
|
||||
if (next !== "visual" && collisionAvailable && collisionState !== "ready") {
|
||||
setCollisionState("loading");
|
||||
}
|
||||
void runtime.setViewMode(next).then(() => {
|
||||
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>
|
||||
<div className="simulation-viewport__stage">
|
||||
<canvas ref={canvasRef} aria-label={`PlayCanvas сцена ${project.name}`} />
|
||||
@@ -99,7 +208,17 @@ export function SimulationViewport({ project }: { project: SimulationProject })
|
||||
<StatusBadge tone="warning">Collision недоступен</StatusBadge>
|
||||
<span>Для этой сборки collision GLB не был запрошен; визуальный слой настоящий и не подменяется.</span>
|
||||
</footer>
|
||||
) : collisionState === "failed" ? (
|
||||
<footer className="simulation-viewport__notice" role="alert">
|
||||
<StatusBadge tone="warning">Collision не открылся</StatusBadge>
|
||||
<span>{error ?? "PlayCanvas не смог загрузить collision GLB."}</span>
|
||||
</footer>
|
||||
) : null}
|
||||
</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 {
|
||||
container-type: inline-size;
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-rows: auto minmax(0, 1fr) auto;
|
||||
@@ -341,7 +342,9 @@
|
||||
}
|
||||
|
||||
.simulation-viewport__toolbar {
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(12rem, 1fr) auto minmax(12rem, 1fr);
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
border-bottom: 1px solid var(--station-hairline);
|
||||
background: var(--nodedc-glass-panel-bg-soft);
|
||||
@@ -356,6 +359,74 @@
|
||||
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__notice > span:last-child {
|
||||
color: var(--nodedc-text-muted);
|
||||
@@ -364,14 +435,24 @@
|
||||
|
||||
.simulation-viewport__stage {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
min-height: 30rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
outline: none;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.simulation-viewport__stage canvas:focus-visible {
|
||||
@@ -407,6 +488,21 @@
|
||||
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 {
|
||||
display: flex;
|
||||
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.doesNotMatch(packageDocument, /@playcanvas\/react/);
|
||||
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, /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, /dispose\(\)/);
|
||||
assert.doesNotMatch(`${runtime}\n${viewport}`, /iframe|<GSplat/);
|
||||
assert.match(viewport, /Collision недоступен/);
|
||||
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-слоя/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user