fix(simulation): align LCC layers in PlayCanvas world

This commit is contained in:
DCCONSTRUCTIONS
2026-08-26 14:20:40 +03:00
parent 906a6ce37b
commit 424374263e
6 changed files with 545 additions and 60 deletions
@@ -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]);
}
+97 -1
View File
@@ -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-слоя/);
});