diff --git a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts index 7ac9890..85021c1 100644 --- a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts +++ b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts @@ -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; loadWorld(manifest: SimulationWorldManifest): Promise; - setViewMode(mode: SimulationViewMode): void; + setViewMode(mode: SimulationViewMode): Promise; setQuality(quality: SimulationQuality): void; + setLayerWorldTransform(layer: SimulationLayer, transform: number[]): void; + home(): void; focusBounds(): void; dispose(): void; } -type CameraController = ScriptType & Pick; +type DesktopCameraInput = { + read(): { mouse: number[] } & Record; +}; + +type CameraController = ScriptType & Pick & { + _desktopInput?: DesktopCameraInput; +}; + +const HOME_POSITION = new Vec3(0, 1, 0); +const HOME_FOCUS = new Vec3(1, 1, 0); + +const QUALITY_PROFILES: Record = { + 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 | null = null; private resizeObserver: ResizeObserver | null = null; private viewMode: SimulationViewMode = "visual"; - private quality: SimulationQuality = "auto"; + private quality: SimulationQuality = "maximum"; async mount(canvas: HTMLCanvasElement): Promise { 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 { 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 = { - 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 { + 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((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 { + 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()); +} diff --git a/apps/control-station/src/components/simulation/SimulationViewport.tsx b/apps/control-station/src/components/simulation/SimulationViewport.tsx index 9d145c0..f74360c 100644 --- a/apps/control-station/src/components/simulation/SimulationViewport.tsx +++ b/apps/control-station/src/components/simulation/SimulationViewport.tsx @@ -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(null); const runtimeRef = useRef(null); + const settingsId = useId(); const [state, setState] = useState<"mounting" | "loading" | "ready" | "failed">("mounting"); const [error, setError] = useState(null); const [viewMode, setViewMode] = useState("visual"); - const [quality, setQuality] = useState("auto"); + const [quality, setQuality] = useState("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 })
- - {state === "ready" ? "Runtime готов" : state === "failed" ? "Ошибка runtime" : "Загрузка сцены"} + + {collisionState === "loading" + ? "Загрузка collision" + : collisionState === "failed" + ? "Ошибка collision" + : state === "ready" + ? "Runtime готов" + : state === "failed" + ? "Ошибка runtime" + : "Загрузка сцены"} PlayCanvas Engine 2.21.4
- { - setViewMode(next); - runtimeRef.current?.setViewMode(next); - }} - items={[ - { value: "visual", label: "Визуал" }, - { value: "collision", label: "Коллизии", disabled: !collisionAvailable }, - { value: "combined", label: "Вместе", disabled: !collisionAvailable }, - ]} - /> - { - setQuality(next); - runtimeRef.current?.setQuality(next); - }} - items={[ - { value: "auto", label: "Auto" }, - { value: "low", label: "Low" }, - { value: "medium", label: "Med" }, - { value: "high", label: "High" }, - ]} - /> - +
+