diff --git a/.gitattributes b/.gitattributes
index ced284c..939b625 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -11,3 +11,4 @@
*.lcc binary
apps/control-station/vendor/rerun-web-viewer-0.34.1/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
apps/control-station/vendor/rerun-web-viewer-0.36.3/re_viewer_bg.nodedc.wasm filter=lfs diff=lfs merge=lfs -text
+apps/control-station/public/rover-scene/*.glb filter=lfs diff=lfs merge=lfs -text
diff --git a/.gitignore b/.gitignore
index 307135a..f443432 100644
--- a/.gitignore
+++ b/.gitignore
@@ -49,3 +49,10 @@ mqtt.summary.json
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
!tests/fixtures/**/*.pcap
!tests/fixtures/**/*.pcapng
+
+# Dependency caches may be links into an existing local checkout.
+.venv
+node_modules
+
+# Private experiment evidence and generated build archives.
+/outputs/
diff --git a/apps/control-station/public/rover-scene/dcd006-v020.glb b/apps/control-station/public/rover-scene/dcd006-v020.glb
new file mode 100644
index 0000000..068a892
--- /dev/null
+++ b/apps/control-station/public/rover-scene/dcd006-v020.glb
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79
+size 54960576
diff --git a/apps/control-station/public/rover-scene/dcd006-v020.json b/apps/control-station/public/rover-scene/dcd006-v020.json
new file mode 100644
index 0000000..f14fdb4
--- /dev/null
+++ b/apps/control-station/public/rover-scene/dcd006-v020.json
@@ -0,0 +1,39 @@
+{
+ "source": "DCD-006_rover_v020.blend",
+ "source_sha256": "c49a0470974bbe65b4e65b4b5c06cb887730715a1ddfa231b86426fed6de8f79",
+ "model": "DCD-006 v020 complete rover",
+ "source_object_count": 1426,
+ "render_mesh_count": 1,
+ "original_bounds": [
+ [
+ -0.527999997138977,
+ -0.45249998569488525,
+ 0.0
+ ],
+ [
+ 0.6350000500679016,
+ 0.45249998569488525,
+ 0.734000027179718
+ ]
+ ],
+ "dimensions_m": [
+ 1.1630001068115234,
+ 0.9049999713897705,
+ 0.734000027179718
+ ],
+ "translation_m": [
+ -0.05350002646446228,
+ -0.0,
+ -0.0
+ ],
+ "glb_bytes": 54960576,
+ "glb_sha256": "15f17f5b5b014e0f65273aa1565b7fb8e46381ca389a3c3d3ca89a32806bfb79",
+ "material_source_sha256": "ac50e29fe9fe7ab4012628c52fce30e262e9e0bba4a4d49abe59a90c84391b2e",
+ "material_rules": {
+ "metal": 1,
+ "rubber": 2,
+ "steel": 6,
+ "paint": 3
+ },
+ "source_saved": false
+}
diff --git a/apps/control-station/public/rover-scene/environment-map.png b/apps/control-station/public/rover-scene/environment-map.png
new file mode 100644
index 0000000..2b51a58
Binary files /dev/null and b/apps/control-station/public/rover-scene/environment-map.png differ
diff --git a/apps/control-station/src/App.tsx b/apps/control-station/src/App.tsx
index f0ab8ca..de9e409 100644
--- a/apps/control-station/src/App.tsx
+++ b/apps/control-station/src/App.tsx
@@ -822,7 +822,7 @@ export default function App() {
/>
) : activeDefinition.kind === "missions" ? (
- ) : activeDefinition.kind === "vehicles" ? null : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
+ ) : activeDefinition.kind === "vehicles" ? : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
Offline evaluation
) : activeDefinition.kind === "lab-archive" ? (
laboratoryAnnotation.control
diff --git a/apps/control-station/src/components/rover/RoverTelemetry.tsx b/apps/control-station/src/components/rover/RoverTelemetry.tsx
new file mode 100644
index 0000000..482630d
--- /dev/null
+++ b/apps/control-station/src/components/rover/RoverTelemetry.tsx
@@ -0,0 +1,25 @@
+import type {RoverState} from '../../core/fleet/useRoverControl';
+import {drivePositions} from '../../../../../plugins/vesc/frontend/src/model';
+const number=(v:number|undefined,digits=1)=>typeof v==='number'&&Number.isFinite(v)?v.toLocaleString('ru-RU',{maximumFractionDigits:digits}):'—';
+export function RoverTelemetry({state}:{state:RoverState}){
+ const devices=state.snapshot.devices??[],profile=state.snapshot.profile;
+ const positions=drivePositions(profile?.layout??null);
+ const assigned=Object.entries(profile?.bindings??{}).map(([slot,binding])=>({
+ id:binding.device_id,uuid:binding.uuid,label:positions[slot]??slot,
+ reading:devices.find(d=>d.id===binding.device_id&&d.uuid===binding.uuid),
+ }));
+ const readings=[...assigned,...devices.filter(d=>!assigned.some(a=>a.id===d.id&&a.uuid===d.uuid)).map(d=>({...d,reading:d}))];
+ return
+ {!state.fresh?
Нет свежих показаний VESC.
:!readings.length?
Получение показаний VESC…
:readings.map(d=>{
+ const fresh=!!d.reading&&d.reading.age_ms<1000,v=fresh?d.reading?.values:undefined;
+ return
{d.label}VESC {d.uuid.slice(0,6).toUpperCase()}{!d.reading?' · Нет связи':!fresh?' · Нет свежих данных':''}
+ - Ток мотора
- {number(v?.motor_current_a)} А
+ - Ток батареи
- {number(v?.input_current_a)} А
+ - Напряжение
- {number(v?.input_voltage_v)} В
+ - Обороты, ERPM
- {number(v?.erpm,0)}
+ - Контроллер
- {number(v?.mos_temperature_c)} °C
+ - Ошибка VESC
- {v?v.fault_code===0?'Нет':String(v.fault_code):'—'}
+ ;
+ })}
+
;
+}
diff --git a/apps/control-station/src/components/rover/RoverView.tsx b/apps/control-station/src/components/rover/RoverView.tsx
new file mode 100644
index 0000000..7759f4d
--- /dev/null
+++ b/apps/control-station/src/components/rover/RoverView.tsx
@@ -0,0 +1,70 @@
+import {lazy,Suspense,useCallback,useEffect,useRef,useState} from 'react';
+import {createPortal} from 'react-dom';
+import {Button,Icon,IconButton,KeyButton,LoadingRegion,Select,StatusBadge,Switch,TextField,ToastStack,Window} from '@nodedc/ui-react';
+import {keysFor,roverSettings,type RoverMode} from '../../core/fleet/roverInput';
+import {bindRoverHoldInput} from '../../core/fleet/roverHoldInput';
+import type {RoverController} from '../../core/fleet/useRoverControl';
+import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
+import './rover.css';
+const Scene=lazy(()=>import('./playcanvas/PlayCanvasViewer'));
+const labels:Record={observing:'Наблюдение',preparing:'Подготовка управления',ready:'Управление включено',driving:'Команда движения',receiver:'Управление с пульта',stopping:'Остановка',stopped:'Управление выключено',fault:'Управление остановлено'};
+
+export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
+ const storage=`missioncore.rover-view.v1:${vehicleID}`;
+ const [settings,setSettings]=useState(()=>{try{return roverSettings(JSON.parse(localStorage.getItem(storage)??'null'));}catch{return roverSettings(null);}});
+ const [open,setOpen]=useState(false),[confirmed,setConfirmed]=useState(false),[held,setHeld]=useState>(new Set());
+ const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
+ const stage=useRef(null),input=useRef|null>(null);
+ const stop=useCallback(()=>{input.current?.dispose();input.current=null;c.setInputGuard(null);setHeld(new Set());c.stop();setConfirmed(false);},[c.stop,c.setInputGuard]);
+ useEffect(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
+ useEffect(()=>{if(!active)stop();},[active,stop]);
+ useEffect(()=>{
+ if(!c.ready||!active||open||!stage.current)return;
+ const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
+ input.current=binding;c.setInputGuard(binding.valid);
+ return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;};
+ },[c.ready,active,open,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
+ const showSettings=()=>{stop();setOpen(true);};
+ const preparing=c.pending||(c.armed&&!c.ready);
+ const availability=c.connecting?'Синхронизация с бортом':!c.state.fresh?'Нет свежих данных с борта':!c.state.snapshot.supported?'Управление на борту недоступно':c.state.controlling&&!c.armed?'Управление уже включено':!c.canArm&&!c.armed?'Завершение предыдущего управления':null;
+ const tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
+ const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
+ return <>
+ {createPortal(,header.actionsTarget)}
+ {createPortal(,header.statusTarget)}
+ {if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
+ {settings.model==='dcd006-v020'?
+
+ {modelState==='error'&&Не удалось загрузить модель ровера.
}
+ :
Выберите модель аппарата.
}
+
{status}
+ {c.ready&&
+
+ {keysFor(settings.mode).map(code=>{if(!c.ready||!e.isTrusted||e.button!==0)return;e.preventDefault();e.currentTarget.setPointerCapture(e.pointerId);stage.current?.focus();input.current?.pointerDown(e.pointerId,code);}}
+ onPointerUp={e=>input.current?.pointerUp(e.pointerId)}
+ onPointerCancel={e=>input.current?.pointerCancel(e.pointerId)}
+ onLostPointerCapture={e=>input.current?.pointerCancel(e.pointerId)}>{code.slice(3)})}
+
+
{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп
+
}
+
+ {c.armed||c.pending?:}
+
+
+ setOpen(false)} title="Управление ровером" size="md">
+
+
+ >;
+}
diff --git a/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json b/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json
new file mode 100644
index 0000000..53ba8be
--- /dev/null
+++ b/apps/control-station/src/components/rover/playcanvas/PROVENANCE.json
@@ -0,0 +1,16 @@
+{
+ "source": "NodeDC ThreeDAssetNode / PlayCanvasViewer",
+ "files": {
+ "PlayCanvasViewer.tsx": "34cd6df2e47a7714288aeb5afb2859b1058eb91dc1819abbf296c6a8937a1533",
+ "playcanvasPostFx.ts": "91cfb7f8ba821fef14681c6c25a61f430ce25aae65b0f181d359f38e37ccd014",
+ "sceneTree.ts": "eead378c2402f69e2c68cd1fc2a5ec9028555dced53858c600c599f613dda516",
+ "environment-map.png": "793f72dce207c1a4d2bdb262610f688bd655a79066298b672b642f228cf7d230"
+ },
+ "adaptations": [
+ "Domain asset URL and error/loading callbacks",
+ "No fallback to a different vehicle",
+ "Removed unused node-editor type helper; rendering settings unchanged",
+ "Observe the host pane on resize; preserve responsive canvas CSS sizing",
+ "Frame the complete rover using the donor 28-degree FOV and current viewport aspect"
+ ]
+}
diff --git a/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx b/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx
new file mode 100644
index 0000000..dc639ae
--- /dev/null
+++ b/apps/control-station/src/components/rover/playcanvas/PlayCanvasViewer.tsx
@@ -0,0 +1,2043 @@
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import * as pc from 'playcanvas'
+import { buildSceneTree } from './sceneTree'
+import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx'
+
+const DEFAULT_MODEL_URL = ''
+const PIXELFORMAT_RGBA8 = (pc as any).PIXELFORMAT_RGBA8 ?? 7
+const PIXELFORMAT_111110F = (pc as any).PIXELFORMAT_111110F ?? 18
+const PIXELFORMAT_RGBA16F = (pc as any).PIXELFORMAT_RGBA16F ?? 12
+const PIXELFORMAT_RGBA32F = (pc as any).PIXELFORMAT_RGBA32F ?? 14
+
+export type PlayCanvasViewerProps = {
+ modelUrl: string | null
+ postFx?: Partial | null
+ viewportSize?: { width: number; height: number }
+ onSceneReady?: (api: { app: pc.Application; camera: pc.Entity; modelRoot: pc.Entity }) => void
+ onSelectionChanged?: (selectedIds: string[]) => void
+ onSceneGraph?: (tree: ReturnType | null, map?: Map) => void
+ exposeSceneGraph?: boolean
+ cameraState?: CameraState | null
+ cameraResetToken?: number | null
+ cameraZoomStrength?: number | null
+ cameraInertiaFactor?: number | null
+ cameraInertiaTime?: number | null
+ cameraAutoRotate?: boolean | null
+ onCameraChange?: (state: CameraState, commit?: boolean) => void
+ onModelState?: (state: "loading" | "ready" | "error") => void
+ muteLogs?: boolean
+}
+
+export type CameraState = {
+ pivot: { x: number; y: number; z: number }
+ distance: number
+ azimuthDeg: number
+ elevationDeg: number
+}
+
+// ------------------------------------------------------------
+// Fallback texture handler
+class SimpleTextureHandler {
+ private gd: pc.GraphicsDevice
+ constructor(gd: pc.GraphicsDevice) {
+ this.gd = gd
+ }
+ load(url: string, callback: (err: any, data?: any) => void) {
+ ;(async () => {
+ const res = await fetch(url)
+ if (!res.ok) throw new Error(`Failed to fetch texture: ${url} (${res.status})`)
+ const blob = await res.blob()
+ const bmp = await createImageBitmap(blob)
+ callback(null, bmp)
+ })().catch((e) => callback(e))
+ }
+ open(_url: string, data: ImageBitmap) {
+ const tex = new pc.Texture(this.gd, {
+ mipmaps: true,
+ } as any)
+ // ImageBitmap is supported by the pinned PlayCanvas runtime.
+ tex.setSource(data as unknown as HTMLImageElement)
+ return tex
+ }
+ patch() {}
+}
+
+function ensureTextureHandler(app: pc.Application) {
+ try {
+ if (!app?.graphicsDevice) return
+
+ const fallback = new SimpleTextureHandler(app.graphicsDevice)
+ const textureCtor = (pc as any).TextureHandler
+ const buildNative = () => {
+ if (!textureCtor) return null
+ try { return new textureCtor(app.graphicsDevice, (app as any).assets) } catch {}
+ try { return new textureCtor(app.graphicsDevice) } catch {}
+ try { return new textureCtor(app) } catch {}
+ return null
+ }
+ const nativeHandler = buildNative()
+ const handler = nativeHandler || fallback
+ const candidates: any[] = []
+ if ((app as any)?.loader) candidates.push((app as any).loader)
+ if ((app as any)?.assets?.loader) candidates.push((app as any).assets.loader)
+ if ((app as any)?.assets?._loader) candidates.push((app as any).assets._loader)
+ if ((app as any)?.assets) candidates.push((app as any).assets)
+
+ for (const loader of candidates) {
+ if (!loader) continue
+
+ const hasGet = typeof loader.getHandler === 'function'
+ const hasAdd = typeof loader.addHandler === 'function'
+
+ if (!hasGet || !hasAdd) {
+ if (loader._handlers && !loader._handlers.texture) {
+ loader._handlers.texture = handler
+ }
+ continue
+ }
+
+ if (!loader.getHandler('texture')) {
+ try { loader.addHandler('texture', handler) } catch {}
+ }
+ if (loader._handlers && !loader._handlers.texture) {
+ loader._handlers.texture = handler
+ }
+ }
+ } catch (e) {
+ console.warn('[PlayCanvasViewer] Failed to register fallback texture handler', e)
+ }
+}
+
+// ------------------------------------------------------------
+// Environment / HDR helpers
+function destroyTexture(texture: pc.Texture | null) {
+ if (!texture) return
+ try { texture.destroy?.() } catch {}
+}
+
+function createGradientEquirectTexture(
+ app: pc.Application,
+ colorA: string,
+ colorB: string,
+ size = 256
+): pc.Texture | null {
+ try {
+ const gd: any = (app as any).graphicsDevice ?? (app as any).device
+ if (!gd || !(pc as any).Texture) return null
+
+ const width = size * 2
+ const height = size
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const ctx = canvas.getContext('2d')
+ if (!ctx) return null
+
+ const grad = ctx.createLinearGradient(0, 0, width, 0)
+ grad.addColorStop(0, colorA)
+ grad.addColorStop(1, colorB)
+ ctx.fillStyle = grad
+ ctx.fillRect(0, 0, width, height)
+
+ const tex = new pc.Texture(gd, {
+ width,
+ height,
+ mipmaps: true,
+ format: (pc as any).PIXELFORMAT_RGBA8 ?? undefined,
+ addressU: pc.ADDRESS_CLAMP_TO_EDGE,
+ addressV: pc.ADDRESS_CLAMP_TO_EDGE,
+ } as any)
+
+ if ((pc as any).TEXTUREPROJECTION_EQUIRECT) {
+ ;(tex as any).projection = (pc as any).TEXTUREPROJECTION_EQUIRECT
+ }
+
+ tex.setSource(canvas)
+ return tex
+ } catch (e) {
+ console.warn('[PlayCanvasViewer] Failed to create gradient equirect texture', e)
+ return null
+ }
+}
+
+function generateSkyboxResources(
+ app: pc.Application,
+ colorA: string,
+ colorB: string
+): {
+ source: pc.Texture | null
+ lightingSource: pc.Texture | null
+ cubemap: pc.Texture | null
+ atlas: pc.Texture | null
+} {
+ const source = createGradientEquirectTexture(app, colorA, colorB)
+ if (!source) return { source: null, lightingSource: null, cubemap: null, atlas: null }
+
+ try {
+ const env: any = (pc as any).EnvLighting
+ if (!env?.generateLightingSource || !env?.generateAtlas) {
+ return { source, lightingSource: null, cubemap: null, atlas: null }
+ }
+
+ const lightingSource = env.generateLightingSource(source, { size: 128 })
+ const atlas = env.generateAtlas(lightingSource)
+ const cubemap = env.generateSkyboxCubemap
+ ? env.generateSkyboxCubemap(source, 128)
+ : lightingSource
+
+ return { source, lightingSource, cubemap, atlas }
+ } catch (e) {
+ console.warn('[PlayCanvasViewer] Failed to generate skybox resources', e)
+ return { source, lightingSource: null, cubemap: null, atlas: null }
+ }
+}
+
+function applySceneEnvironment(
+ app: pc.Application,
+ opts: {
+ envAtlas: pc.Texture | null
+ skybox: pc.Texture | null
+ intensity: number
+ mip: number
+ rotationDeg: number
+ }
+) {
+ const scene: any = app.scene
+ if (!scene) return
+
+ scene.envAtlas = opts.envAtlas || null
+ scene.skybox = opts.skybox || null
+ if (typeof scene.skyboxIntensity === 'number') scene.skyboxIntensity = opts.intensity
+ if (typeof scene.skyboxMip === 'number') scene.skyboxMip = opts.mip
+
+ const rotRad = (opts.rotationDeg * Math.PI) / 180
+ try {
+ if (scene.skyboxRotation && (pc as any).Quat) {
+ const q = new (pc as any).Quat()
+ q.setFromEulerAngles(0, opts.rotationDeg, 0)
+ scene.skyboxRotation = q
+ } else if (typeof scene.setSkyboxRotation === 'function') {
+ scene.setSkyboxRotation(0, rotRad, 0)
+ }
+ } catch {}
+}
+
+function clamp01(v: number) {
+ return Math.max(0, Math.min(1, v))
+}
+
+function toneMapColor(x: number, mode: number) {
+ const v = Math.max(0, x)
+ switch (mode) {
+ case 1: {
+ const A = 0.15
+ const B = 0.50
+ const C = 0.10
+ const D = 0.20
+ const E = 0.02
+ const F = 0.30
+ const num = v * (A * v + C * B) + D * E
+ const den = v * (A * v + B) + D * F
+ return clamp01(num / den - E / F)
+ }
+ case 2: {
+ const t = Math.max(0, v - 0.004)
+ return clamp01((t * (6.2 * t + 0.5)) / (t * (6.2 * t + 1.7) + 0.06))
+ }
+ case 3:
+ case 4: {
+ return clamp01((v * (2.51 * v + 0.03)) / (v * (2.43 * v + 0.59) + 0.14))
+ }
+ case 5: {
+ return clamp01(v / (1 + v))
+ }
+ case 0:
+ default:
+ return clamp01(v)
+ }
+}
+
+function applyMaterialEnvOverride(
+ app: pc.Application,
+ opts: {
+ enabled: boolean
+ envAtlas: pc.Texture | null
+ cubeMap: pc.Texture | null
+ useSkybox?: boolean
+ reflectivityScale?: number | null
+ }
+) {
+ const anyApp = app as any
+ const store: WeakMap =
+ anyApp.__ENV_OVERRIDE_STORE__ ??
+ (anyApp.__ENV_OVERRIDE_STORE__ = new WeakMap())
+
+ const setMat = (mat: any) => {
+ if (!mat || typeof mat !== 'object') return
+
+ if (!store.has(mat)) {
+ store.set(mat, {
+ envAtlas: mat.envAtlas ?? null,
+ cubeMap: mat.cubeMap ?? null,
+ useSkybox: typeof mat.useSkybox === 'boolean' ? mat.useSkybox : undefined,
+ reflectivity: typeof mat.reflectivity === 'number' ? mat.reflectivity : undefined,
+ })
+ }
+
+ if (!opts.enabled) {
+ const prev = store.get(mat)
+ if (prev) {
+ mat.envAtlas = prev.envAtlas ?? null
+ mat.cubeMap = prev.cubeMap ?? null
+ if (typeof prev.useSkybox === 'boolean') mat.useSkybox = prev.useSkybox
+ if (typeof prev.reflectivity === 'number') mat.reflectivity = prev.reflectivity
+ mat.update?.()
+ }
+ return
+ }
+
+ mat.envAtlas = opts.envAtlas ?? null
+ mat.cubeMap = opts.cubeMap ?? null
+ if (typeof opts.useSkybox === 'boolean') mat.useSkybox = opts.useSkybox
+ if (typeof opts.reflectivityScale === 'number') {
+ const base = store.get(mat)?.reflectivity
+ if (typeof base === 'number') {
+ mat.reflectivity = Math.max(0, Math.min(1, base * opts.reflectivityScale))
+ }
+ }
+ mat.update?.()
+ }
+
+ const traverse = (e: pc.Entity) => {
+ const r = (e as any).render
+ if (r?.meshInstances?.length) {
+ for (const mi of r.meshInstances) setMat(mi.material)
+ }
+
+ const m = (e as any).model
+ if (m?.meshInstances?.length) {
+ for (const mi of m.meshInstances) setMat(mi.material)
+ }
+
+ for (const c of e.children ?? []) if (c instanceof pc.Entity) traverse(c)
+ }
+
+ const modelRoot = app.root.findByName('ModelRoot') as pc.Entity | null
+ traverse(modelRoot ?? app.root)
+}
+
+function resolveShadowType(type: string) {
+ const anyPc: any = pc
+ switch (type) {
+ case 'vsm32':
+ return anyPc.SHADOW_VSM_32F ?? anyPc.SHADOW_VSM32 ?? anyPc.SHADOW_VSM_16F ?? pc.SHADOW_VSM_16F
+ case 'pcf1':
+ return anyPc.SHADOW_PCF1 ?? pc.SHADOW_PCF1
+ case 'pcf3':
+ return anyPc.SHADOW_PCF3 ?? pc.SHADOW_PCF3
+ case 'vsm16':
+ default:
+ return anyPc.SHADOW_VSM_16F ?? anyPc.SHADOW_VSM16 ?? pc.SHADOW_VSM_16F
+ }
+}
+
+function applyLightAngles(entity: pc.Entity | null, azimuthDeg: number, elevationDeg: number) {
+ if (!entity) return
+ entity.setLocalEulerAngles(elevationDeg, azimuthDeg, 0)
+}
+
+type ColorInput = string | { r: number; g: number; b: number; a?: number } | pc.Color | null | undefined
+
+function toColor(value: ColorInput, fallback?: pc.Color): pc.Color {
+ if (value instanceof pc.Color) {
+ return new pc.Color(value.r, value.g, value.b, value.a)
+ }
+ if (value && typeof value === 'object') {
+ const r = Number((value as any).r)
+ const g = Number((value as any).g)
+ const b = Number((value as any).b)
+ const a = Number.isFinite((value as any).a) ? Number((value as any).a) : 1
+ if (Number.isFinite(r) && Number.isFinite(g) && Number.isFinite(b)) {
+ return new pc.Color(
+ Math.max(0, Math.min(1, r)),
+ Math.max(0, Math.min(1, g)),
+ Math.max(0, Math.min(1, b)),
+ Math.max(0, Math.min(1, a))
+ )
+ }
+ }
+ if (typeof value === 'string') {
+ const hex = value.trim()
+ const full = /^#?([0-9a-f]{6})$/i.exec(hex)
+ const short = /^#?([0-9a-f]{3})$/i.exec(hex)
+ if (full) {
+ const n = parseInt(full[1], 16)
+ const r = ((n >> 16) & 255) / 255
+ const g = ((n >> 8) & 255) / 255
+ const b = (n & 255) / 255
+ return new pc.Color(r, g, b)
+ }
+ if (short) {
+ const s = short[1]
+ const n = parseInt(`${s[0]}${s[0]}${s[1]}${s[1]}${s[2]}${s[2]}`, 16)
+ const r = ((n >> 16) & 255) / 255
+ const g = ((n >> 8) & 255) / 255
+ const b = (n & 255) / 255
+ return new pc.Color(r, g, b)
+ }
+ }
+ return fallback ? new pc.Color(fallback.r, fallback.g, fallback.b, fallback.a) : new pc.Color(0, 0, 0)
+}
+
+function setWireframeForScene(app: pc.Application, enabled: boolean) {
+ const renders = app.root.findComponents('render') as any[]
+ for (const r of renders) {
+ const meshInstances = r?.meshInstances ?? []
+ for (const mi of meshInstances) {
+ const mat = mi.material as any
+ if (mat && 'wireframe' in mat) {
+ mat.wireframe = enabled
+ mat.update?.()
+ }
+ }
+ }
+}
+
+function collectMeshInstances(entity: pc.Entity | null, out: any[] = []): any[] {
+ if (!entity) return out
+ const render = (entity as any).render
+ const model = (entity as any).model
+ if (render?.meshInstances?.length) out.push(...render.meshInstances)
+ if (model?.meshInstances?.length) out.push(...model.meshInstances)
+ for (const child of entity.children ?? []) collectMeshInstances(child as any, out)
+ return out
+}
+
+function disableInvalidMeshes(root: pc.Entity | null) {
+ if (!root) return
+ const meshInstances = collectMeshInstances(root)
+ meshInstances.forEach((mi: any) => {
+ const mesh = mi?.mesh
+ const vb = mesh?.vertexBuffer
+ const numVertices = vb?.numVertices ?? 0
+ if (!mesh || !numVertices) return
+ const hasIndex = Array.isArray(mesh.indexBuffer) ? mesh.indexBuffer.length > 0 : !!mesh.indexBuffer
+ if (!hasIndex) return
+ try {
+ const indices: number[] = []
+ const count = mesh.getIndices(indices)
+ if (!count || !indices.length) return
+ let maxIndex = -1
+ for (let i = 0; i < count; i += 1) {
+ const v = indices[i] ?? -1
+ if (v > maxIndex) maxIndex = v
+ }
+ if (maxIndex >= numVertices) {
+ mi.visible = false
+ // eslint-disable-next-line no-console
+ console.warn('[PlayCanvasViewer] Disabled mesh with invalid indices', {
+ numVertices,
+ maxIndex,
+ mesh: mesh?.name,
+ })
+ }
+ } catch {}
+ })
+}
+
+function computeAabb(entity: pc.Entity): pc.BoundingBox | null {
+ const renders = entity.findComponents('render') as any[]
+ if (!renders?.length) return null
+
+ let out: pc.BoundingBox | null = null
+ for (const r of renders) {
+ const meshInstances = r?.meshInstances ?? []
+ for (const mi of meshInstances) {
+ const aabb = mi?.aabb as pc.BoundingBox | undefined
+ if (!aabb) continue
+ if (!out) out = aabb.clone()
+ else out.add(aabb)
+ }
+ }
+ return out
+}
+
+type OrbitOptions = {
+ pivot: pc.Vec3
+ distance: number
+ minDistance: number
+ maxDistance: number
+ zoomStrength?: number
+ minElevationDeg?: number
+ maxElevationDeg?: number
+ inertiaFactor?: number
+ inertiaTime?: number
+ initialAzimuthDeg?: number
+ initialElevationDeg?: number
+ autoRotate?: boolean
+ autoRotateSpeed?: number
+ autoRotateDelay?: number
+ autoRotateFade?: number
+ autoRotatePitchSpeed?: number
+ autoRotatePitchAmount?: number
+ onChange?: (state: CameraState, commit: boolean) => void
+}
+
+type OrbitController = {
+ setPivot(v: pc.Vec3): void
+ setDistance(d: number): void
+ setZoomStrength(value?: number | null): void
+ setInertiaFactor(value?: number | null): void
+ setInertiaTime(value?: number | null): void
+ setAutoRotate(value?: boolean | null): void
+ setState(state: CameraState): void
+ getState(): CameraState
+ destroy(): void
+}
+
+function makeOrbitController(app: pc.Application, cameraEntity: pc.Entity, opts: OrbitOptions): OrbitController {
+ const canvas = app.graphicsDevice.canvas as HTMLCanvasElement
+
+ let pivot = opts.pivot.clone()
+ let distance = opts.distance
+ let azimuth = ((opts.initialAzimuthDeg ?? 45) * Math.PI) / 180
+ let elevation = ((opts.initialElevationDeg ?? 25) * Math.PI) / 180
+
+ let targetAzimuth = azimuth
+ let targetElevation = elevation
+ let targetDistance = distance
+
+ const minEl = ((opts.minElevationDeg ?? 5) * Math.PI) / 180
+ const maxEl = ((opts.maxElevationDeg ?? 89) * Math.PI) / 180
+
+ let dragging = false
+ let panning = false
+ let lastX = 0
+ let lastY = 0
+ let idleTime = 0
+ let wheelCommitTimer: number | null = null
+
+ const toDeg = (rad: number) => (rad * 180) / Math.PI
+ const toRad = (deg: number) => (deg * Math.PI) / 180
+ const clampZoom = (value?: number | null) => {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return 0.04
+ return Math.max(0.001, Math.min(0.5, n))
+ }
+ const clampInertia = (value?: number | null) => {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return 0
+ return Math.max(0, Math.min(1, n))
+ }
+ const clampInertiaTime = (value?: number | null) => {
+ const n = Number(value)
+ if (!Number.isFinite(n)) return 0.05
+ return Math.max(0.02, Math.min(10, n))
+ }
+ let zoomStrength = clampZoom(opts.zoomStrength)
+ let inertia = clampInertia(opts.inertiaFactor)
+ let inertiaTime = clampInertiaTime(opts.inertiaTime)
+ let autoRotate = opts.autoRotate !== false
+
+ const snapshot = (useTarget = true): CameraState => ({
+ pivot: { x: pivot.x, y: pivot.y, z: pivot.z },
+ distance: useTarget ? targetDistance : distance,
+ azimuthDeg: toDeg(useTarget ? targetAzimuth : azimuth),
+ elevationDeg: toDeg(useTarget ? targetElevation : elevation),
+ })
+
+ const emitChange = (commit: boolean) => {
+ if (!opts.onChange) return
+ opts.onChange(snapshot(true), commit)
+ }
+
+ const updateCamera = (dt = 0) => {
+ if (autoRotate && !dragging && !panning) {
+ idleTime += dt
+ const delay = Math.max(0, opts.autoRotateDelay ?? 0)
+ if (idleTime > delay) {
+ const t = idleTime - delay
+ const fadeTime = Math.max(0.001, opts.autoRotateFade ?? 0.001)
+ const x = t / fadeTime
+ const fade = x <= 0 ? 0 : x >= 1 ? 1 : 0.5 * Math.sin((x - 0.5) * Math.PI) + 0.5
+ const speed = ((opts.autoRotateSpeed ?? 0) * Math.PI) / 180
+ const pitchSpeed = Number(opts.autoRotatePitchSpeed ?? 0)
+ const pitchAmount = ((opts.autoRotatePitchAmount ?? 0) * Math.PI) / 180
+ targetAzimuth += speed * fade * dt
+ if (pitchSpeed && pitchAmount) {
+ targetElevation += Math.sin(t * pitchSpeed) * fade * dt * pitchAmount
+ }
+ }
+ }
+
+ targetElevation = Math.max(minEl, Math.min(maxEl, targetElevation))
+ targetDistance = Math.max(opts.minDistance, Math.min(opts.maxDistance, targetDistance))
+
+ const step = dt > 0 ? dt : 1 / 60
+ const timeFactor = inertiaTime > 0 ? 1 - Math.exp(-step / inertiaTime) : 1
+ const smooth = inertia <= 0 ? 1 : (1 - inertia) + inertia * timeFactor
+
+ azimuth += (targetAzimuth - azimuth) * smooth
+ elevation += (targetElevation - elevation) * smooth
+ distance += (targetDistance - distance) * smooth
+
+ const x = pivot.x + distance * Math.cos(elevation) * Math.sin(azimuth)
+ const y = pivot.y + distance * Math.sin(elevation)
+ const z = pivot.z + distance * Math.cos(elevation) * Math.cos(azimuth)
+
+ cameraEntity.setPosition(x, y, z)
+ cameraEntity.lookAt(pivot)
+ }
+
+ const onMouseDown = (e: MouseEvent) => {
+ if (e.button === 0) dragging = true
+ if (e.button === 2) panning = true
+ lastX = e.clientX
+ lastY = e.clientY
+ idleTime = 0
+ }
+
+ const onMouseMove = (e: MouseEvent) => {
+ if (!dragging && !panning) return
+ const dx = e.clientX - lastX
+ const dy = e.clientY - lastY
+ lastX = e.clientX
+ lastY = e.clientY
+ idleTime = 0
+
+ if (dragging) {
+ targetAzimuth -= dx * 0.005
+ targetElevation -= dy * 0.005
+ } else if (panning) {
+ const s = distance * 0.0018
+ const right = cameraEntity.right.clone().mulScalar(-dx * s)
+ const up = cameraEntity.up.clone().mulScalar(dy * s)
+ pivot = pivot.add(right).add(up)
+ }
+ updateCamera(0)
+ emitChange(false)
+ }
+
+ const onMouseUp = () => {
+ dragging = false
+ panning = false
+ idleTime = 0
+ emitChange(true)
+ }
+
+ const onWheel = (e: WheelEvent) => {
+ idleTime = 0
+ const dir = Math.sign(e.deltaY)
+ if (dir) targetDistance *= 1 + dir * zoomStrength
+ updateCamera(0)
+ emitChange(false)
+ if (wheelCommitTimer) window.clearTimeout(wheelCommitTimer)
+ wheelCommitTimer = window.setTimeout(() => {
+ wheelCommitTimer = null
+ emitChange(true)
+ }, 180)
+ }
+
+ const onContextMenu = (e: Event) => e.preventDefault()
+
+ canvas.addEventListener('mousedown', onMouseDown)
+ window.addEventListener('mousemove', onMouseMove)
+ window.addEventListener('mouseup', onMouseUp)
+ canvas.addEventListener('wheel', onWheel, { passive: true })
+ canvas.addEventListener('contextmenu', onContextMenu)
+
+ app.on('update', updateCamera as any)
+ updateCamera(0)
+
+ const api: OrbitController = {
+ setPivot(v: pc.Vec3) {
+ pivot = v.clone()
+ updateCamera()
+ },
+ setDistance(d: number) {
+ targetDistance = d
+ updateCamera()
+ },
+ setZoomStrength(value?: number | null) {
+ zoomStrength = clampZoom(value)
+ },
+ setInertiaFactor(value?: number | null) {
+ inertia = clampInertia(value)
+ },
+ setInertiaTime(value?: number | null) {
+ inertiaTime = clampInertiaTime(value)
+ },
+ setAutoRotate(value?: boolean | null) {
+ autoRotate = value !== false
+ if (!autoRotate) idleTime = 0
+ },
+ setState(state: CameraState) {
+ if (!state) return
+ const nextPivot = state.pivot || { x: 0, y: 0, z: 0 }
+ pivot = new pc.Vec3(
+ Number(nextPivot.x) || 0,
+ Number(nextPivot.y) || 0,
+ Number(nextPivot.z) || 0,
+ )
+ const nextAz = toRad(Number(state.azimuthDeg) || 0)
+ const nextEl = toRad(Number(state.elevationDeg) || 0)
+ azimuth = nextAz
+ elevation = nextEl
+ targetAzimuth = nextAz
+ targetElevation = nextEl
+ const nextDistance = Number(state.distance) || distance
+ distance = nextDistance
+ targetDistance = nextDistance
+ updateCamera(0)
+ },
+ getState() {
+ return snapshot(true)
+ },
+ destroy() {
+ canvas.removeEventListener('mousedown', onMouseDown)
+ window.removeEventListener('mousemove', onMouseMove)
+ window.removeEventListener('mouseup', onMouseUp)
+ canvas.removeEventListener('wheel', onWheel as any)
+ canvas.removeEventListener('contextmenu', onContextMenu)
+ app.off('update', updateCamera)
+ if (wheelCommitTimer) window.clearTimeout(wheelCommitTimer)
+ },
+ }
+
+ ;(app as any).__orbit = api
+ return api
+}
+
+type RenderOverrides = { renderFormats: number[]; samples: number }
+
+function resolveRenderOverrides(fx: PostFxSettings, device: pc.GraphicsDevice | null): RenderOverrides {
+ const maxSamples = Number.isFinite((device as any)?.maxSamples) ? Number((device as any).maxSamples) : 1
+ const rawSamples = Number(fx.rendering.samples)
+ let samples = Number.isFinite(rawSamples) ? Math.round(rawSamples) : 1
+ samples = Math.max(1, samples || 1)
+ samples = Math.min(samples, Math.max(1, maxSamples))
+ const originalSamples = samples
+ const forceSingleSample = samples > 1
+ if (forceSingleSample) samples = 1
+
+ const primary = Number(fx.rendering.renderFormat)
+ if (primary === PIXELFORMAT_RGBA8) {
+ return { renderFormats: [PIXELFORMAT_RGBA8], samples }
+ }
+
+ const candidates = [
+ primary,
+ fx.rendering.renderFormatFallback0,
+ fx.rendering.renderFormatFallback1,
+ ]
+
+ const canUseFormat = (fmt: number, sampleCount: number) => {
+ if (!device) return fmt !== PIXELFORMAT_RGBA8
+ if (fmt === PIXELFORMAT_111110F) return !!(device as any).textureRG11B10Renderable
+ if (fmt === PIXELFORMAT_RGBA16F) return !!(device as any).textureHalfFloatRenderable
+ if (fmt === PIXELFORMAT_RGBA32F) {
+ const floatRenderable = !!(device as any).textureFloatRenderable
+ const floatFilterable = (device as any).textureFloatFilterable !== false
+ if (!floatRenderable || !floatFilterable) return false
+ if ((device as any).isWebGPU && sampleCount > 1) return false
+ return true
+ }
+ return fmt !== PIXELFORMAT_RGBA8
+ }
+
+ const buildFormats = (sampleCount: number) => {
+ const list: number[] = []
+ for (const candidate of candidates) {
+ const fmt = Number(candidate)
+ if (!Number.isFinite(fmt)) continue
+ if (!canUseFormat(fmt, sampleCount)) continue
+ if (!list.includes(fmt)) list.push(fmt)
+ }
+ return list
+ }
+
+ let renderFormats = buildFormats(samples)
+ if (!renderFormats.length && samples > 1) {
+ const singleSampleFormats = buildFormats(1)
+ if (singleSampleFormats.length) {
+ samples = 1
+ renderFormats = singleSampleFormats
+ }
+ }
+ if (samples > 1 && renderFormats.length > 0) {
+ samples = 1
+ }
+ if (!renderFormats.length) {
+ samples = forceSingleSample ? 1 : originalSamples
+ renderFormats = [PIXELFORMAT_RGBA8]
+ }
+
+ return { renderFormats, samples }
+}
+
+function applyPostFx(
+ frame: any,
+ fx: PostFxSettings,
+ lutTexture: pc.Texture | null,
+ overrides?: RenderOverrides
+) {
+ if (!frame) return
+
+ if (frame.rendering) {
+ if (Array.isArray(frame.rendering.renderFormats)) {
+ const formats = Array.isArray(overrides?.renderFormats) ? overrides?.renderFormats : (() => {
+ const list: number[] = []
+ if (Number.isFinite(fx.rendering.renderFormat)) list.push(fx.rendering.renderFormat)
+ if (Number.isFinite(fx.rendering.renderFormatFallback0)) list.push(fx.rendering.renderFormatFallback0)
+ if (Number.isFinite(fx.rendering.renderFormatFallback1)) list.push(fx.rendering.renderFormatFallback1)
+ return list
+ })()
+ if (formats.length) {
+ frame.rendering.renderFormats.length = 0
+ frame.rendering.renderFormats.push(...formats)
+ }
+ } else if (Array.isArray(overrides?.renderFormats) && overrides?.renderFormats.length) {
+ const fmt = overrides.renderFormats[0]
+ if (Number.isFinite(fmt)) frame.rendering.renderFormat = fmt
+ }
+ if (typeof frame.rendering.stencil === 'boolean') {
+ frame.rendering.stencil = !!fx.rendering.stencil
+ }
+ frame.rendering.renderTargetScale = fx.rendering.renderTargetScale
+ frame.rendering.samples = Number.isFinite(overrides?.samples) ? Number(overrides?.samples) : fx.rendering.samples
+ frame.rendering.sceneColorMap = true
+ frame.rendering.sceneDepthMap = !!fx.rendering.sceneDepthMap
+ frame.rendering.toneMapping = fx.rendering.toneMapping
+ frame.rendering.sharpness = fx.rendering.sharpness
+ }
+
+ if (frame.ssao) {
+ frame.ssao.type = fx.ssao.type
+ frame.ssao.blurEnabled = !!fx.ssao.blurEnabled
+ if (fx.ssao.type !== 'none') {
+ frame.ssao.intensity = fx.ssao.intensity
+ frame.ssao.radius = fx.ssao.radius
+ frame.ssao.samples = fx.ssao.samples
+ frame.ssao.power = fx.ssao.power
+ frame.ssao.minAngle = fx.ssao.minAngle
+ frame.ssao.scale = fx.ssao.scale
+ }
+ }
+
+ if (frame.bloom) {
+ const enabled = !!fx.bloom.enabled
+ frame.bloom.enabled = enabled
+ frame.bloom.intensity = enabled ? fx.bloom.intensity : 0
+ if (enabled) {
+ const blurLevel =
+ Number.isFinite((fx.bloom as any).lastMipLevel)
+ ? Number((fx.bloom as any).lastMipLevel)
+ : Number((fx.bloom as any).blurLevel)
+ if (Number.isFinite(blurLevel)) frame.bloom.blurLevel = blurLevel
+ }
+ }
+
+ if (frame.grading) {
+ frame.grading.enabled = !!fx.grading.enabled
+ if (fx.grading.enabled) {
+ frame.grading.brightness = fx.grading.brightness
+ frame.grading.contrast = fx.grading.contrast
+ frame.grading.saturation = fx.grading.saturation
+ const c = toColor(fx.grading.tint)
+ frame.grading.tint?.copy?.(c) ?? (frame.grading.tint = c)
+ }
+ }
+
+ if (frame.colorLUT) {
+ frame.colorLUT.texture = lutTexture || null
+ frame.colorLUT.intensity = fx.lut.intensity
+ }
+
+ if (frame.vignette) {
+ const enabled = !!fx.vignette.enabled
+ frame.vignette.enabled = enabled
+ frame.vignette.intensity = enabled ? fx.vignette.intensity : 0
+ if (enabled) {
+ frame.vignette.inner = fx.vignette.inner
+ frame.vignette.outer = fx.vignette.outer
+ frame.vignette.curvature = fx.vignette.curvature
+ const c = toColor(fx.vignette.color)
+ frame.vignette.color?.copy?.(c) ?? (frame.vignette.color = c)
+ }
+ }
+
+ if (frame.taa) {
+ frame.taa.enabled = !!fx.taa.enabled
+ if (fx.taa.enabled) frame.taa.jitter = fx.taa.jitter
+ }
+
+ if (frame.fringing) {
+ const enabled = !!fx.chromaticAberration.enabled
+ frame.fringing.enabled = enabled
+ frame.fringing.intensity = enabled ? fx.chromaticAberration.intensity : 0
+ }
+
+ frame.update?.()
+}
+
+export default function PlayCanvasViewer({
+ modelUrl,
+ postFx,
+ viewportSize,
+ onSceneReady,
+ onSelectionChanged,
+ onSceneGraph,
+ exposeSceneGraph = false,
+ cameraState,
+ cameraResetToken,
+ cameraZoomStrength,
+ cameraInertiaFactor,
+ cameraInertiaTime,
+ cameraAutoRotate,
+ onCameraChange,
+ muteLogs = false,
+ onModelState,
+}: PlayCanvasViewerProps) {
+ const canvasRef = useRef(null)
+ const appRef = useRef(null)
+ const cameraEntityRef = useRef(null)
+ const cameraFrameRef = useRef(null)
+ const modelAssetRef = useRef(null)
+ const envAtlasRef = useRef(null)
+ const envAtlasImageRef = useRef(null)
+ const envAtlasAdjustKeyRef = useRef(null)
+ const skyboxSourceRef = useRef(null)
+ const skyboxLightingSourceRef = useRef(null)
+ const skyboxCubemapRef = useRef(null)
+ const skyboxAtlasRef = useRef(null)
+ const lutTextureRef = useRef(null)
+ const directionalLightRef = useRef(null)
+ const shadowCatcherRef = useRef(null)
+ const shadowCatcherLightRef = useRef(null)
+ const shadowCatcherPlaneRef = useRef(null)
+ const gridEntityRef = useRef(null)
+ const gridMaterialRef = useRef(null)
+ const sceneMapRef = useRef