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 30e6c14..08bcc2d 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 === "datasets" ? ( + ) : activeDefinition.kind === "vehicles" ?
: 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">
+ setSettings(v=>({...v,model}))}/> + setSettings(v=>({...v,currentA:Number(e.target.value)}))}/> + setSettings(v=>({...v,maxErpm:Number(e.target.value)}))}/> + + {availability&&{availability}} +

Движение — только при удержании клавиш или кнопок. При уходе из окна команда снимается. Команда с пульта прекращает удалённое управление.

+ +
+ + ; +} 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>(new Map()) + const loadTokenRef = useRef(0) + const cameraStateRef = useRef(null) + const onCameraChangeRef = useRef(null) + const logWarn = useCallback((...args: any[]) => { if (!muteLogs) console.warn(...args) }, [muteLogs]) + const logError = useCallback((...args: any[]) => { if (!muteLogs) console.error(...args) }, [muteLogs]) + const [appReady, setAppReady] = useState(false) + const [envAtlasVersion, setEnvAtlasVersion] = useState(0) + const [skyboxVersion, setSkyboxVersion] = useState(0) + const [modelVersion, setModelVersion] = useState(0) + + const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx]) + + useEffect(() => { + cameraStateRef.current = cameraState ?? null + }, [cameraState]) + + useEffect(() => { + onCameraChangeRef.current = onCameraChange ?? null + }, [onCameraChange]) + + const resizeCanvas = React.useCallback((width?: number, height?: number) => { + const canvas = canvasRef.current + const inst = appRef.current + if (!canvas || !inst) return + const w = Math.max(1, Math.round(width ?? canvas.parentElement?.clientWidth ?? canvas.clientWidth)) + const h = Math.max(1, Math.round(height ?? canvas.parentElement?.clientHeight ?? canvas.clientHeight)) + if (canvas.width !== w) canvas.width = w + if (canvas.height !== h) canvas.height = h + try { + if (inst.resizeCanvas.length >= 2) { + inst.resizeCanvas(w, h) + } else { + inst.resizeCanvas() + } + } catch {} + // PlayCanvas writes fixed pixel CSS sizes; the hosted pane remains fluid. + canvas.style.width = '100%' + canvas.style.height = '100%' + const model = inst.root.findByName('Model') as pc.Entity | null + const bounds = model ? computeAabb(model) : null + const orbit = (inst as any).__orbit as OrbitController | undefined + if (bounds && orbit) { + const vertical = (cameraEntityRef.current?.camera?.fov ?? 28) * Math.PI / 360 + const horizontal = Math.atan(Math.tan(vertical) * w / h) + orbit.setDistance(Math.max(1.8, bounds.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 1.1)) + } + }, []) + + useEffect(() => { + const canvas = canvasRef.current + if (!canvas) return + + const app = new pc.Application(canvas, { + mouse: new pc.Mouse(canvas), + touch: new pc.TouchDevice(canvas), + keyboard: new pc.Keyboard(window), + }) + + appRef.current = app + + app.setCanvasFillMode((pc as any).FILLMODE_NONE ?? pc.FILLMODE_NONE) + app.setCanvasResolution(pc.RESOLUTION_AUTO) + app.scene.exposure = 1.0 + const renderOverrides = resolveRenderOverrides(fx, (app as any).graphicsDevice ?? (app as any).device ?? null) + + const ensureHandler = (type: string, Ctor: any) => { + const loaderAny: any = (app as any).loader + if (!loaderAny || !Ctor) return + + try { + if (typeof loaderAny.getHandler === 'function' && loaderAny.getHandler(type)) return + } catch {} + + const tryCreate = () => { + try { return new Ctor((app as any).graphicsDevice ?? (app as any).device ?? app) } catch {} + try { return new Ctor(app) } catch {} + try { return new Ctor((app as any).graphicsDevice, (app as any).assets) } catch {} + try { return new Ctor((app as any).graphicsDevice, (app as any).assets, (app as any).loader) } catch {} + return null + } + + const handler = tryCreate() + if (!handler) return + + try { + loaderAny.addHandler(type, handler) + } catch (e) { + logWarn(`[PlayCanvasViewer] Failed to register handler ${type}`, e) + } + } + + ensureHandler('texture', (pc as any).TextureHandler) + ensureHandler('container', (pc as any).ContainerHandler) + ensureTextureHandler(app) + + const modelRoot = new pc.Entity('ModelRoot') + app.root.addChild(modelRoot) + + app.scene.ambientLight = new pc.Color(0, 0, 0) + + const camera = new pc.Entity('Camera') + camera.addComponent('camera', { + clearColor: toColor(fx.rendering.backgroundColor), + fov: 28, + farClip: 500, + nearClip: 0.05, + }) + app.root.addChild(camera) + cameraEntityRef.current = camera + + try { + const CameraFrameCtor: any = (pc as any).CameraFrame + if (CameraFrameCtor) { + const cf = new CameraFrameCtor(app, camera.camera) + cameraFrameRef.current = cf + applyPostFx(cf, fx, lutTextureRef.current, renderOverrides) + try { cf.update?.() } catch {} + } + } catch (e) { + logWarn('[PlayCanvasViewer] CameraFrame init failed:', e) + cameraFrameRef.current = null + } + + const cam = camera.camera! + ;(cam as any).gammaCorrection = pc.GAMMA_SRGB + ;(cam as any).toneMapping = fx.rendering.toneMapping + + if (typeof (cam as any).requestSceneColorMap === 'function') { + ;(cam as any).requestSceneColorMap(true) + } + if (typeof (cam as any).requestSceneDepthMap === 'function') { + ;(cam as any).requestSceneDepthMap(true) + } + if (Array.isArray((cam as any).layers) && !(cam as any).layers.includes(pc.LAYERID_DEPTH)) { + ;(cam as any).layers = [...(cam as any).layers, pc.LAYERID_DEPTH] + } + + const initialCamera = cameraStateRef.current + const orbit = makeOrbitController(app, camera, { + pivot: initialCamera + ? new pc.Vec3( + Number(initialCamera.pivot?.x) || 0, + Number(initialCamera.pivot?.y) || 0, + Number(initialCamera.pivot?.z) || 0, + ) + : new pc.Vec3(0, 0, 0), + distance: Number(initialCamera?.distance) || 6, + minDistance: 0.05, + maxDistance: 500, + minElevationDeg: 5, + maxElevationDeg: 90, + initialAzimuthDeg: Number.isFinite(initialCamera?.azimuthDeg) ? Number(initialCamera?.azimuthDeg) : 45, + initialElevationDeg: Number.isFinite(initialCamera?.elevationDeg) ? Number(initialCamera?.elevationDeg) : 10, + autoRotate: cameraAutoRotate !== false, + autoRotateSpeed: 4, + autoRotateDelay: 4, + autoRotateFade: 5, + autoRotatePitchSpeed: 0, + autoRotatePitchAmount: 1, + zoomStrength: cameraZoomStrength ?? undefined, + inertiaFactor: cameraInertiaFactor ?? undefined, + inertiaTime: cameraInertiaTime ?? undefined, + onChange: (state, commit) => { + onCameraChangeRef.current?.(state, commit) + }, + }) + + const directionalLight = new pc.Entity('DirectionalLight') + directionalLight.addComponent('light', { + type: 'directional', + }) + app.root.addChild(directionalLight) + directionalLightRef.current = directionalLight + + const gridEntity = new pc.Entity('Grid') + gridEntity.addComponent('render', { castShadows: false }) + const gridMaterial = new pc.ShaderMaterial({ + uniqueName: 'grid-shader', + vertexGLSL: ` + attribute vec3 vertex_position; + attribute vec2 aUv0; + + uniform mat4 matrix_model; + uniform mat4 matrix_viewProjection; + + varying vec2 uv0; + + void main(void) { + gl_Position = matrix_viewProjection * matrix_model * vec4(vertex_position, 1.0); + uv0 = aUv0; + } +`, + fragmentGLSL: ` + uniform vec2 uHalfExtents; + uniform vec3 uColorX; + uniform vec3 uColorZ; + uniform vec3 uColorMain; + uniform float uAlphaX; + uniform float uAlphaZ; + uniform float uAlphaMain; + uniform int uResolution; + uniform vec3 uDotColor; + uniform float uDotAlpha; + uniform float uDotSize; + uniform vec3 uCrossColor; + uniform float uCrossAlpha; + uniform float uCrossLength; + uniform float uCrossWidth; + uniform float uFadeStart; + uniform float uFadeEnd; + + varying vec2 uv0; + + // https://bgolus.medium.com/the-best-darn-grid-shader-yet-727f9278b9d8#1e7c + float pristineGrid(in vec2 uv, in vec2 ddx, in vec2 ddy, vec2 lineWidth) { + vec2 uvDeriv = vec2(length(vec2(ddx.x, ddy.x)), length(vec2(ddx.y, ddy.y))); + bvec2 invertLine = bvec2(lineWidth.x > 0.5, lineWidth.y > 0.5); + vec2 targetWidth = vec2( + invertLine.x ? 1.0 - lineWidth.x : lineWidth.x, + invertLine.y ? 1.0 - lineWidth.y : lineWidth.y + ); + vec2 drawWidth = clamp(targetWidth, uvDeriv, vec2(0.5)); + vec2 lineAA = uvDeriv * 1.5; + vec2 gridUV = abs(fract(uv) * 2.0 - 1.0); + gridUV.x = invertLine.x ? gridUV.x : 1.0 - gridUV.x; + gridUV.y = invertLine.y ? gridUV.y : 1.0 - gridUV.y; + vec2 grid2 = smoothstep(drawWidth + lineAA, drawWidth - lineAA, gridUV); + + grid2 *= clamp(targetWidth / drawWidth, 0.0, 1.0); + grid2 = mix(grid2, targetWidth, clamp(uvDeriv * 2.0 - 1.0, 0.0, 1.0)); + grid2.x = invertLine.x ? 1.0 - grid2.x : grid2.x; + grid2.y = invertLine.y ? 1.0 - grid2.y : grid2.y; + + return mix(grid2.x, 1.0, grid2.y); + } + + float dotMask(vec2 levelPos, float radius) { + if (radius <= 0.0) { + return 0.0; + } + vec2 dist = min(fract(levelPos), 1.0 - fract(levelPos)); + float d = length(dist); + float aa = max(fwidth(levelPos.x), fwidth(levelPos.y)); + return 1.0 - smoothstep(radius - aa, radius + aa, d); + } + + float crossMask(vec2 levelPos, float halfLen, float halfWidth) { + if (halfLen <= 0.0 || halfWidth <= 0.0) { + return 0.0; + } + vec2 dist = min(fract(levelPos), 1.0 - fract(levelPos)); + float dx = dist.x; + float dy = dist.y; + float aa = max(fwidth(levelPos.x), fwidth(levelPos.y)); + float hor = (1.0 - smoothstep(halfWidth - aa, halfWidth + aa, dy)) * + (1.0 - smoothstep(halfLen - aa, halfLen + aa, dx)); + float ver = (1.0 - smoothstep(halfWidth - aa, halfWidth + aa, dx)) * + (1.0 - smoothstep(halfLen - aa, halfLen + aa, dy)); + return max(hor, ver); + } + + void main(void) { + vec2 uv = uv0; + + vec2 pos = (uv * 2.0 - 1.0) * uHalfExtents; + vec2 ddx = dFdx(pos); + vec2 ddy = dFdy(pos); + + float epsilon = 1.0 / 255.0; + float res = float(uResolution); + + vec2 levelPos; + float levelSize; + float levelAlpha; + vec3 baseColor = vec3(0.0); + float baseAlpha = 0.0; + float hasBase = 0.0; + + levelPos = pos * 0.1; + levelSize = 2.0 / 1000.0; + levelAlpha = pristineGrid(levelPos, ddx * 0.1, ddy * 0.1, vec2(levelSize)); + if (levelAlpha > epsilon) { + vec3 color; + float alphaMul = uAlphaMain; + if (abs(levelPos.x) < levelSize) { + if (abs(levelPos.y) < levelSize) { + color = vec3(1.0); + } else { + color = uColorZ; + alphaMul = uAlphaZ; + } + } else if (abs(levelPos.y) < levelSize) { + color = uColorX; + alphaMul = uAlphaX; + } else { + color = uColorMain; + } + baseColor = color; + baseAlpha = levelAlpha * alphaMul; + hasBase = 1.0; + } + + if (hasBase < 0.5 && res >= 1.0) { + levelPos = pos; + levelSize = 1.0 / 100.0; + levelAlpha = pristineGrid(levelPos, ddx, ddy, vec2(levelSize)); + if (levelAlpha > epsilon) { + baseColor = uColorMain; + baseAlpha = levelAlpha * uAlphaMain; + hasBase = 1.0; + } + } + + if (hasBase < 0.5 && res >= 2.0) { + levelPos = pos * 10.0; + levelSize = 1.0 / 100.0; + levelAlpha = pristineGrid(levelPos, ddx * 10.0, ddy * 10.0, vec2(levelSize)); + if (levelAlpha > epsilon) { + baseColor = uColorMain; + baseAlpha = levelAlpha * uAlphaMain; + hasBase = 1.0; + } + } + + float dotRadius = max(0.0, uDotSize) * 0.5; + float crossHalfLen = max(0.0, uCrossLength) * 0.5; + float crossHalfWidth = max(0.0, uCrossWidth) * 0.5; + float dotMaskAll = 0.0; + float crossMaskAll = 0.0; + + if (res >= 1.0) { + levelPos = pos; + dotMaskAll = dotMask(levelPos, dotRadius); + crossMaskAll = crossMask(levelPos, crossHalfLen, crossHalfWidth); + } + + vec3 outColor = baseColor; + float outAlpha = baseAlpha; + float dotAlpha = clamp(uDotAlpha, 0.0, 1.0) * dotMaskAll; + float crossAlpha = clamp(uCrossAlpha, 0.0, 1.0) * crossMaskAll; + float fadeStart = max(0.0, uFadeStart); + float fadeEnd = max(0.0, uFadeEnd); + float fade = 1.0; + if (fadeEnd > fadeStart && fadeEnd > 0.0) { + float d = length(pos); + fade = 1.0 - smoothstep(fadeStart, fadeEnd, d); + } + outAlpha *= fade; + dotAlpha *= fade; + crossAlpha *= fade; + if (dotAlpha > 0.0) { + outColor = mix(outColor, uDotColor, dotAlpha); + outAlpha = max(outAlpha, dotAlpha); + } + if (crossAlpha > 0.0) { + outColor = mix(outColor, uCrossColor, crossAlpha); + outAlpha = max(outAlpha, crossAlpha); + } + + if (outAlpha <= epsilon) { + discard; + } + gl_FragColor = vec4(outColor, outAlpha); + } +`, + attributes: { + vertex_position: (pc as any).SEMANTIC_POSITION ?? pc.SEMANTIC_POSITION, + aUv0: (pc as any).SEMANTIC_TEXCOORD0 ?? pc.SEMANTIC_TEXCOORD0, + }, + }) + gridMaterial.blendType = pc.BLEND_NORMAL + gridMaterial.cull = pc.CULLFACE_NONE + gridMaterial.update() + const gridMesh = pc.Mesh.fromGeometry(app.graphicsDevice, new pc.PlaneGeometry()) + const gridMeshInstance = new pc.MeshInstance(gridMesh, gridMaterial) + gridEntity.render!.meshInstances = [gridMeshInstance] + gridEntity.setLocalScale(1000, 1, 1000) + gridEntity.setLocalPosition(0, 0, 0) + gridMaterial.setParameter('uColorX', [1, 0.3, 0.3]) + gridMaterial.setParameter('uColorZ', [0.3, 0.3, 1]) + gridMaterial.setParameter('uColorMain', [0.9, 0.9, 0.9]) + gridMaterial.setParameter('uAlphaX', 1) + gridMaterial.setParameter('uAlphaZ', 1) + gridMaterial.setParameter('uAlphaMain', 1) + gridMaterial.setParameter('uDotColor', [1, 1, 1]) + gridMaterial.setParameter('uDotAlpha', 0) + gridMaterial.setParameter('uDotSize', 0) + gridMaterial.setParameter('uCrossColor', [1, 1, 1]) + gridMaterial.setParameter('uCrossAlpha', 0) + gridMaterial.setParameter('uCrossLength', 0) + gridMaterial.setParameter('uCrossWidth', 0) + gridMaterial.setParameter('uFadeStart', 0) + gridMaterial.setParameter('uFadeEnd', 0) + gridMaterial.setParameter('uResolution', 2) + const gridHalfExtents = new pc.Vec2(500, 500) + gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y]) + gridMaterial.update() + app.root.addChild(gridEntity) + gridEntityRef.current = gridEntity + gridMaterialRef.current = gridMaterial + + const updateGridHalfExtents = () => { + const scale = gridEntity.getLocalScale() + const half = new pc.Vec2(scale.x / 2, scale.z / 2) + if (gridHalfExtents.distance(half) > 0.001) { + gridHalfExtents.copy(half) + gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y]) + gridMaterial.update() + } + } + app.on('prerender', updateGridHalfExtents) + + const shadowCatcher = new pc.Entity('ShadowCatcher') + shadowCatcherRef.current = shadowCatcher + shadowCatcher.setLocalPosition(0, fx.shadowCatcher.yOffset, 0) + + const shadowLight = new pc.Entity('ShadowCatcherLight') + shadowLight.addComponent('light', { + type: 'directional', + castShadows: true, + shadowUpdateMode: pc.SHADOWUPDATE_REALTIME, + }) + shadowCatcherLightRef.current = shadowLight + shadowCatcher.addChild(shadowLight) + + const shadowPlane = new pc.Entity('ShadowCatcherGeometry') + const shadowMat = new pc.StandardMaterial() + shadowMat.blendType = pc.BLEND_MULTIPLICATIVE + shadowMat.shadowCatcher = true + shadowMat.useSkybox = false + shadowMat.depthWrite = false + shadowMat.diffuse.set(0, 0, 0) + shadowMat.specular.set(0, 0, 0) + shadowMat.update() + shadowPlane.addComponent('render', { type: 'plane', castShadows: false, material: shadowMat }) + shadowPlane.setLocalScale(fx.shadowCatcher.size, 1, fx.shadowCatcher.size) + shadowCatcherPlaneRef.current = shadowPlane + shadowPlane.render?.meshInstances?.forEach((mi: any) => { + mi.drawOrder = -1 + mi.material = shadowMat + }) + shadowCatcher.addChild(shadowPlane) + app.root.addChild(shadowCatcher) + + let started = false + try { + app.start() + started = true + } catch (err) { + logError('[PlayCanvasViewer] App start failed:', err) + } + + if (!started) { + try { app.destroy() } catch {} + appRef.current = null + return () => {} + } + + setAppReady(true) + + try { + const atlasUrl = '/rover-scene/environment-map.png' + const asset = new pc.Asset( + 'envAtlas_default', + 'texture', + { url: atlasUrl }, + { + type: 'rgbp', + addressu: 'clamp', + addressv: 'clamp', + mipmaps: false, + } as any + ) + app.assets.add(asset) + app.assets.load(asset) + + asset.ready(() => { + envAtlasRef.current = asset.resource as any + ;(async () => { + try { + const res = await fetch(atlasUrl) + if (!res.ok) throw new Error(`Failed to fetch ${atlasUrl} (${res.status})`) + const blob = await res.blob() + const bmp = await createImageBitmap(blob) + envAtlasImageRef.current = bmp + } catch (err) { + try { + const img = new Image() + img.src = atlasUrl + await new Promise((resolve, reject) => { + img.onload = () => resolve() + img.onerror = () => reject(new Error('Failed to load env atlas image')) + }) + envAtlasImageRef.current = img + } catch (fallbackErr) { + logWarn('[PlayCanvasViewer] Failed to load env atlas image', fallbackErr) + } + } finally { + setEnvAtlasVersion((v) => v + 1) + } + })() + }) + } catch (e) { + logWarn('[PlayCanvasViewer] Failed to init default environment-map.png', e) + } + + onSceneReady?.({ app, camera, modelRoot }) + + const ro = new ResizeObserver(() => resizeCanvas()) + ro.observe(canvas.parentElement ?? canvas) + resizeCanvas() + + return () => { + try { ro.disconnect() } catch {} + orbit.destroy() + cameraEntityRef.current = null + directionalLightRef.current = null + shadowCatcherRef.current = null + shadowCatcherLightRef.current = null + shadowCatcherPlaneRef.current = null + try { cameraFrameRef.current?.destroy?.() } catch {} + try { lutTextureRef.current?.destroy?.() } catch {} + destroyTexture(envAtlasRef.current) + destroyTexture(skyboxSourceRef.current) + destroyTexture(skyboxLightingSourceRef.current) + destroyTexture(skyboxCubemapRef.current) + destroyTexture(skyboxAtlasRef.current) + envAtlasImageRef.current = null + try { app.off('prerender', updateGridHalfExtents) } catch {} + try { gridMaterial.destroy?.() } catch {} + try { shadowMat.destroy?.() } catch {} + + app.destroy() + appRef.current = null + modelAssetRef.current = null + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!viewportSize) return + const w = Math.max(1, Math.round(viewportSize.width)) + const h = Math.max(1, Math.round(viewportSize.height)) + const id = window.requestAnimationFrame(() => resizeCanvas(w, h)) + return () => window.cancelAnimationFrame(id) + }, [viewportSize?.width, viewportSize?.height, resizeCanvas]) + + const resolvedUrl = modelUrl || DEFAULT_MODEL_URL + + useEffect(() => { + const app = appRef.current + if (!app || !resolvedUrl) return + const modelRoot = app.root.findByName('ModelRoot') as pc.Entity | null + if (!modelRoot) return + + let cancelled = false + + const loadGlb = async (url: string) => { + const loadToken = ++loadTokenRef.current + let asset: pc.Asset | null = null + try { + if (!app?.assets) return + + const prevAsset = modelAssetRef.current + const prevChildren = modelRoot.children.slice() + + ensureTextureHandler(app) + + asset = new pc.Asset('model', 'container', { url }) + app.assets.add(asset) + + const loadedAsset = asset + await new Promise((resolve, reject) => { + loadedAsset.ready(() => resolve()) + loadedAsset.on('error', (err: unknown) => reject(err)) + app.assets.load(loadedAsset) + }) + + if (cancelled || loadTokenRef.current !== loadToken) { + try { + if (asset) { + app.assets.remove(asset) + asset.unload?.() + } + } catch {} + return + } + + prevChildren.forEach((c) => c.destroy()) + if (prevAsset) { + try { + app.assets.remove(prevAsset) + prevAsset.unload?.() + } catch {} + } + if (!asset) return + modelAssetRef.current = asset + const resource = asset.resource as any + const entity: pc.Entity = resource.instantiateRenderEntity() + entity.name = 'Model' + modelRoot.addChild(entity) + disableInvalidMeshes(entity) + + const orbit = (app as any).__orbit as OrbitController | undefined + const aabb = computeAabb(entity) + if (orbit && aabb && !cameraStateRef.current) { + orbit.setPivot(aabb.center.clone()) + const cam = cameraEntityRef.current?.camera + const vertical = (cam?.fov ?? 28) * Math.PI / 360 + const horizontal = Math.atan(Math.tan(vertical) * (cam?.aspectRatio ?? 1)) + orbit.setDistance(Math.max(1.8, aabb.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 1.1)) + } + + if (exposeSceneGraph || onSceneGraph) { + const map = new Map() + sceneMapRef.current = map + const graphRoot = modelRoot ?? app.root + const tree = buildSceneTree(graphRoot, map) + onSceneGraph?.(tree, map) + } + setModelVersion((v) => v + 1) + onModelState?.("ready") + } catch (e) { + logError('[PlayCanvasViewer] Failed to load model:', e) + if (!cancelled && asset && app?.assets) { + try { + app.assets.remove(asset) + asset.unload?.() + } catch {} + } + if (!cancelled) onModelState?.("error") + } + } + + onModelState?.("loading") + loadGlb(resolvedUrl) + + return () => { + cancelled = true + } + }, [resolvedUrl, exposeSceneGraph, onSceneGraph]) + + useEffect(() => { + if (!appReady || !cameraState) return + const app = appRef.current + if (!app) return + const orbit = (app as any).__orbit as OrbitController | undefined + if (!orbit) return + orbit.setState(cameraState) + }, [appReady, cameraState]) + + useEffect(() => { + if (!appReady) return + const app = appRef.current + if (!app) return + const orbit = (app as any).__orbit as OrbitController | undefined + if (!orbit) return + orbit.setZoomStrength(cameraZoomStrength ?? undefined) + }, [appReady, cameraZoomStrength]) + + useEffect(() => { + if (!appReady) return + const app = appRef.current + if (!app) return + const orbit = (app as any).__orbit as OrbitController | undefined + if (!orbit) return + orbit.setInertiaFactor(cameraInertiaFactor ?? undefined) + }, [appReady, cameraInertiaFactor]) + + useEffect(() => { + if (!appReady) return + const app = appRef.current + if (!app) return + const orbit = (app as any).__orbit as OrbitController | undefined + if (!orbit) return + orbit.setInertiaTime(cameraInertiaTime ?? undefined) + }, [appReady, cameraInertiaTime]) + + useEffect(() => { + if (!appReady) return + const app = appRef.current + if (!app) return + const orbit = (app as any).__orbit as OrbitController | undefined + if (!orbit) return + orbit.setAutoRotate(cameraAutoRotate ?? undefined) + }, [appReady, cameraAutoRotate]) + useEffect(() => { + if (!appReady || !cameraResetToken) return + const app = appRef.current + if (!app) return + const orbit = (app as any).__orbit as OrbitController | undefined + if (!orbit) return + const modelRoot = app.root.findByName('ModelRoot') as pc.Entity | null + if (!modelRoot) return + const modelEntity = (modelRoot as any).findByName?.('Model') as pc.Entity | null + const aabb = computeAabb(modelEntity || modelRoot) + if (!aabb) return + orbit.setState({ + pivot: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z }, + distance: Math.max(1.8, aabb.halfExtents.length() * 2.5), + azimuthDeg: 45, + elevationDeg: 10, + }) + }, [appReady, cameraResetToken, modelVersion]) + + useEffect(() => { + const app = appRef.current + if (!app) return + const camEnt = app.root.findByName('Camera') as pc.Entity | null + const cam = camEnt?.camera as any + if (cam) cam.clearColor = toColor(fx.rendering.backgroundColor) + }, [fx.rendering.backgroundColor]) + + useEffect(() => { + const grid = gridEntityRef.current + const mat = gridMaterialRef.current + if (!grid || !mat) return + + const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled + grid.enabled = gridVisible + const cX = toColor(fx.grid.colorX) + const cZ = toColor(fx.grid.colorZ) + const cMain = toColor(fx.grid.colorMain) + const dotColor = toColor(fx.grid.dotsColor) + const crossColor = toColor(fx.grid.crossColor) + mat.setParameter('uColorX', [cX.r, cX.g, cX.b]) + mat.setParameter('uColorZ', [cZ.r, cZ.g, cZ.b]) + mat.setParameter('uColorMain', [cMain.r, cMain.g, cMain.b]) + const linesEnabled = !!fx.grid.enabled + mat.setParameter('uAlphaX', linesEnabled ? clamp01(fx.grid.alphaX) : 0) + mat.setParameter('uAlphaZ', linesEnabled ? clamp01(fx.grid.alphaZ) : 0) + mat.setParameter('uAlphaMain', linesEnabled ? clamp01(fx.grid.alphaMain) : 0) + mat.setParameter('uDotColor', [dotColor.r, dotColor.g, dotColor.b]) + mat.setParameter('uDotAlpha', fx.grid.dotsEnabled ? clamp01(fx.grid.dotsAlpha) : 0) + mat.setParameter('uDotSize', Math.max(0, Number(fx.grid.dotsDiameter) || 0)) + mat.setParameter('uCrossColor', [crossColor.r, crossColor.g, crossColor.b]) + mat.setParameter('uCrossAlpha', fx.grid.crossEnabled ? clamp01(fx.grid.crossAlpha) : 0) + mat.setParameter('uCrossLength', Math.max(0, Number(fx.grid.crossLength) || 0)) + mat.setParameter('uCrossWidth', Math.max(0, Number(fx.grid.crossWidth) || 0)) + mat.setParameter('uFadeStart', Math.max(0, Number(fx.grid.fadeStart) || 0)) + mat.setParameter('uFadeEnd', Math.max(0, Number(fx.grid.fadeEnd) || 0)) + mat.update() + }, [ + fx.grid.enabled, + fx.grid.colorX, + fx.grid.colorZ, + fx.grid.colorMain, + fx.grid.alphaX, + fx.grid.alphaZ, + fx.grid.alphaMain, + fx.grid.dotsEnabled, + fx.grid.dotsColor, + fx.grid.dotsAlpha, + fx.grid.dotsDiameter, + fx.grid.crossEnabled, + fx.grid.crossColor, + fx.grid.crossAlpha, + fx.grid.crossLength, + fx.grid.crossWidth, + fx.grid.fadeStart, + fx.grid.fadeEnd, + ]) + + useEffect(() => { + const cam = cameraEntityRef.current?.camera as any + if (cam) cam.toneMapping = fx.rendering.toneMapping + }, [fx.rendering.toneMapping]) + + useEffect(() => { + const app = appRef.current + if (!app) return + setWireframeForScene(app, fx.rendering.wireframe) + }, [fx.rendering.wireframe]) + + useEffect(() => { + const frame: any = cameraFrameRef.current + if (!frame) return + const overrides = resolveRenderOverrides(fx, appRef.current?.graphicsDevice ?? null) + applyPostFx(frame, fx, lutTextureRef.current, overrides) + }, [fx]) + + useEffect(() => { + const app = appRef.current + if (!app) return + if (fx.lighting) { + app.scene.exposure = fx.lighting.exposure + } + }, [fx.lighting?.exposure]) + + useEffect(() => { + const app = appRef.current + if (!app) return + + const scene: any = app.scene + const fogType = fx.rendering.fog + const fogValue = + fogType === 'linear' ? pc.FOG_LINEAR : + fogType === 'exp' ? pc.FOG_EXP : + fogType === 'exp2' ? pc.FOG_EXP2 : + pc.FOG_NONE + + const fog = scene?.fog + if (fog && typeof fog === 'object') { + fog.type = fogValue + const fogColor = toColor(fx.rendering.fogColor) + if (fog.color?.copy) fog.color.copy(fogColor) + else fog.color = fogColor + + if (Number.isFinite(fx.rendering.fogDensity)) { + fog.density = fx.rendering.fogDensity + } + + const range = fx.rendering.fogRange + const start = Number.isFinite(fx.rendering.fogStart) ? fx.rendering.fogStart : (Array.isArray(range) ? range[0] : undefined) + const end = Number.isFinite(fx.rendering.fogEnd) ? fx.rendering.fogEnd : (Array.isArray(range) ? range[1] : undefined) + if (Number.isFinite(start)) fog.start = start + if (Number.isFinite(end)) fog.end = end + } else { + scene.fog = fogValue + + const fogColor = toColor(fx.rendering.fogColor) + if (scene.fogColor?.copy) scene.fogColor.copy(fogColor) + else scene.fogColor = fogColor + + if (Number.isFinite(fx.rendering.fogDensity)) { + scene.fogDensity = fx.rendering.fogDensity + } + + const range = fx.rendering.fogRange + const start = Number.isFinite(fx.rendering.fogStart) ? fx.rendering.fogStart : (Array.isArray(range) ? range[0] : undefined) + const end = Number.isFinite(fx.rendering.fogEnd) ? fx.rendering.fogEnd : (Array.isArray(range) ? range[1] : undefined) + if (Number.isFinite(start)) scene.fogStart = start + if (Number.isFinite(end)) scene.fogEnd = end + } + }, [ + fx.rendering.fog, + fx.rendering.fogColor, + fx.rendering.fogDensity, + fx.rendering.fogRange, + fx.rendering.fogStart, + fx.rendering.fogEnd, + ]) + + useEffect(() => { + const app = appRef.current + if (!app || !appReady) return + + const next = generateSkyboxResources(app, fx.skybox.colorA, fx.skybox.colorB) + if (!next.source && !next.cubemap && !next.atlas) return + + destroyTexture(skyboxSourceRef.current) + destroyTexture(skyboxLightingSourceRef.current) + destroyTexture(skyboxCubemapRef.current) + destroyTexture(skyboxAtlasRef.current) + + skyboxSourceRef.current = next.source + skyboxLightingSourceRef.current = next.lightingSource + skyboxCubemapRef.current = next.cubemap + skyboxAtlasRef.current = next.atlas + setSkyboxVersion((v) => v + 1) + }, [appReady, fx.skybox.colorA, fx.skybox.colorB]) + + useEffect(() => { + const app = appRef.current + if (!app || !appReady) return + + if (envAtlasRef.current && envAtlasImageRef.current) { + const key = [ + fx.envAtlas.brightness, + fx.envAtlas.contrast, + fx.envAtlas.saturation, + fx.envAtlas.toneMapping, + ].join('|') + if (envAtlasAdjustKeyRef.current !== key) { + envAtlasAdjustKeyRef.current = key + const base = envAtlasImageRef.current + const width = + 'width' in base + ? (base as ImageBitmap).width + : (base as HTMLImageElement).naturalWidth + const height = + 'height' in base + ? (base as ImageBitmap).height + : (base as HTMLImageElement).naturalHeight + const canvas = document.createElement('canvas') + canvas.width = width + canvas.height = height + const ctx = canvas.getContext('2d') + if (ctx) { + ctx.drawImage(base as any, 0, 0, width, height) + const img = ctx.getImageData(0, 0, width, height) + const data = img.data + const brightness = fx.envAtlas.brightness + const contrast = fx.envAtlas.contrast + const saturation = fx.envAtlas.saturation + const tone = fx.envAtlas.toneMapping + const neutral = + Math.abs(brightness - 1) < 0.0001 && + Math.abs(contrast - 1) < 0.0001 && + Math.abs(saturation - 1) < 0.0001 && + Math.abs(tone) < 0.0001 + + if (!neutral) { + for (let i = 0; i < data.length; i += 4) { + let r = data[i] / 255 + let g = data[i + 1] / 255 + let b = data[i + 2] / 255 + + r *= brightness + g *= brightness + b *= brightness + + r = (r - 0.5) * contrast + 0.5 + g = (g - 0.5) * contrast + 0.5 + b = (b - 0.5) * contrast + 0.5 + + const l = r * 0.2126 + g * 0.7152 + b * 0.0722 + r = l + (r - l) * saturation + g = l + (g - l) * saturation + b = l + (b - l) * saturation + + r = toneMapColor(r, tone) + g = toneMapColor(g, tone) + b = toneMapColor(b, tone) + + data[i] = Math.round(clamp01(r) * 255) + data[i + 1] = Math.round(clamp01(g) * 255) + data[i + 2] = Math.round(clamp01(b) * 255) + } + ctx.putImageData(img, 0, 0) + } + envAtlasRef.current.setSource(canvas as any) + } + } + } + + const atlasTex = envAtlasRef.current + const skyboxAtlas = skyboxAtlasRef.current + const skyboxCube = skyboxCubemapRef.current + + const atlasActive = !!fx.envAtlas.enabled + const skyboxActive = !atlasActive && !!fx.skybox.enabled + + let intensity = 0 + let mip = 0 + let rotation = 0 + + let sceneEnvAtlas: pc.Texture | null = null + let sceneSkybox: pc.Texture | null = null + let overrideEnvAtlas: pc.Texture | null = null + let overrideUseSkybox: boolean | undefined = false + let overrideReflectivityScale = 1 + let overrideEnabled = true + + if (atlasActive) { + const backgroundOn = !!fx.envAtlas.background && !!atlasTex + const reflectionOn = !!fx.envAtlas.reflection && !!atlasTex + + intensity = fx.envAtlas.intensity * (fx.lighting?.skyBoxIntensity ?? 1) + mip = fx.envAtlas.mip + rotation = fx.envAtlas.rotation + + const reflectionIntensity = fx.envAtlas.reflectionIntensity * (fx.lighting?.skyBoxIntensity ?? 1) + overrideReflectivityScale = reflectionIntensity + + if (backgroundOn) sceneEnvAtlas = atlasTex + overrideEnvAtlas = reflectionOn ? atlasTex : null + } else if (skyboxActive) { + const backgroundOn = !!fx.skybox.background && !!skyboxCube + const reflectionOn = !!fx.skybox.reflection && !!skyboxAtlas + + intensity = fx.skybox.intensity * (fx.lighting?.skyBoxIntensity ?? 1) + mip = fx.skybox.mip + rotation = fx.skybox.rotation + + const reflectionIntensity = fx.skybox.reflectionIntensity * (fx.lighting?.skyBoxIntensity ?? 1) + overrideReflectivityScale = reflectionIntensity + + if (backgroundOn) sceneSkybox = skyboxCube + if (backgroundOn) sceneEnvAtlas = skyboxAtlas + overrideEnvAtlas = reflectionOn ? skyboxAtlas : null + } else { + overrideEnvAtlas = null + overrideReflectivityScale = 1 + } + + applySceneEnvironment(app, { + envAtlas: sceneEnvAtlas, + skybox: sceneSkybox, + intensity, + mip, + rotationDeg: rotation, + }) + + applyMaterialEnvOverride(app, { + enabled: overrideEnabled, + envAtlas: overrideEnvAtlas, + cubeMap: null, + useSkybox: overrideUseSkybox, + reflectivityScale: overrideReflectivityScale, + }) + }, [ + appReady, + envAtlasVersion, + skyboxVersion, + modelVersion, + fx.envAtlas.enabled, + fx.envAtlas.background, + fx.envAtlas.reflection, + fx.envAtlas.intensity, + fx.envAtlas.reflectionIntensity, + fx.envAtlas.brightness, + fx.envAtlas.contrast, + fx.envAtlas.saturation, + fx.envAtlas.toneMapping, + fx.envAtlas.mip, + fx.envAtlas.rotation, + fx.skybox.enabled, + fx.skybox.background, + fx.skybox.reflection, + fx.skybox.intensity, + fx.skybox.reflectionIntensity, + fx.skybox.mip, + fx.skybox.rotation, + fx.lighting?.skyBoxIntensity, + ]) + + useEffect(() => { + const lightEntity = directionalLightRef.current + if (!lightEntity) return + + lightEntity.enabled = !!fx.directionalLight.enabled + applyLightAngles(lightEntity, fx.directionalLight.azimuth, fx.directionalLight.elevation) + + const light = (lightEntity as any).light + if (!light) return + + light.color?.copy?.(toColor(fx.directionalLight.color)) + light.intensity = fx.directionalLight.intensity + light.castShadows = !!fx.directionalLight.castShadows + light.shadowIntensity = fx.directionalLight.shadowIntensity + light.shadowDistance = fx.directionalLight.shadowDistance + light.shadowResolution = fx.directionalLight.shadowResolution + light.shadowBias = fx.directionalLight.shadowBias + light.normalOffsetBias = fx.directionalLight.normalOffsetBias + light.shadowType = resolveShadowType(fx.directionalLight.shadowType) + light.vsmBlurSize = fx.directionalLight.vsmBlurSize + if (light.castShadows && typeof light.shadowUpdateMode === 'number') { + light.shadowUpdateMode = pc.SHADOWUPDATE_REALTIME + } + }, [ + fx.directionalLight.enabled, + fx.directionalLight.color, + fx.directionalLight.intensity, + fx.directionalLight.azimuth, + fx.directionalLight.elevation, + fx.directionalLight.castShadows, + fx.directionalLight.shadowIntensity, + fx.directionalLight.shadowDistance, + fx.directionalLight.shadowResolution, + fx.directionalLight.shadowBias, + fx.directionalLight.normalOffsetBias, + fx.directionalLight.shadowType, + fx.directionalLight.vsmBlurSize, + ]) + + useEffect(() => { + const catcher = shadowCatcherRef.current + const lightEntity = shadowCatcherLightRef.current + const planeEntity = shadowCatcherPlaneRef.current + if (!catcher || !lightEntity || !planeEntity) return + + catcher.enabled = !!fx.shadowCatcher.enabled + catcher.setLocalPosition(0, fx.shadowCatcher.yOffset, 0) + planeEntity.setLocalScale(fx.shadowCatcher.size, 1, fx.shadowCatcher.size) + applyLightAngles(lightEntity, fx.shadowCatcher.lightAzimuth, fx.shadowCatcher.lightElevation) + + const light = (lightEntity as any).light + if (!light) return + + light.color?.copy?.(toColor(fx.shadowCatcher.lightColor)) + light.intensity = fx.shadowCatcher.lightIntensity + light.castShadows = true + light.shadowIntensity = fx.shadowCatcher.shadowIntensity + light.shadowDistance = fx.shadowCatcher.shadowDistance + light.shadowResolution = fx.shadowCatcher.shadowResolution + light.shadowBias = fx.shadowCatcher.shadowBias + light.normalOffsetBias = fx.shadowCatcher.normalOffsetBias + light.shadowType = resolveShadowType(fx.shadowCatcher.shadowType) + light.vsmBlurSize = fx.shadowCatcher.vsmBlurSize + if (typeof light.shadowUpdateMode === 'number') { + light.shadowUpdateMode = pc.SHADOWUPDATE_REALTIME + } + }, [ + fx.shadowCatcher.enabled, + fx.shadowCatcher.size, + fx.shadowCatcher.yOffset, + fx.shadowCatcher.lightIntensity, + fx.shadowCatcher.lightColor, + fx.shadowCatcher.lightAzimuth, + fx.shadowCatcher.lightElevation, + fx.shadowCatcher.shadowIntensity, + fx.shadowCatcher.shadowDistance, + fx.shadowCatcher.shadowResolution, + fx.shadowCatcher.shadowBias, + fx.shadowCatcher.normalOffsetBias, + fx.shadowCatcher.shadowType, + fx.shadowCatcher.vsmBlurSize, + ]) + + // reflection overrides are managed in the environment effect above + + useEffect(() => { + if (!onSelectionChanged) return + onSelectionChanged([]) + }, [onSelectionChanged]) + + return ( +
+ +
+ ) +} diff --git a/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts b/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts new file mode 100644 index 0000000..053e745 --- /dev/null +++ b/apps/control-station/src/components/rover/playcanvas/playcanvasPostFx.ts @@ -0,0 +1,393 @@ + +export type PostFxSettings = { + lighting: { + exposure: number + skyBoxIntensity: number + } + envAtlas: { + enabled: boolean + background: boolean + reflection: boolean + intensity: number + reflectionIntensity: number + brightness: number + contrast: number + saturation: number + toneMapping: number + mip: number + rotation: number + } + skybox: { + enabled: boolean + background: boolean + reflection: boolean + intensity: number + reflectionIntensity: number + mip: number + rotation: number + colorA: string + colorB: string + } + rendering: { + backgroundColor: string + wireframe: boolean + renderFormat: number + renderFormatFallback0: number + renderFormatFallback1: number + stencil: boolean + renderTargetScale: number + samples: number + sharpness: number + toneMapping: number + sceneColorMap: boolean + sceneDepthMap: boolean + fog: 'none' | 'linear' | 'exp' | 'exp2' + fogColor: string + fogRange: [number, number] + fogDensity: number + fogStart: number + fogEnd: number + } + grid: { + enabled: boolean + colorX: string + colorZ: string + colorMain: string + alphaX: number + alphaZ: number + alphaMain: number + dotsEnabled: boolean + dotsColor: string + dotsAlpha: number + dotsDiameter: number + crossEnabled: boolean + crossColor: string + crossAlpha: number + crossLength: number + crossWidth: number + fadeStart: number + fadeEnd: number + } + ssao: { + type: 'none' | 'lighting' | 'combine' + blurEnabled: boolean + intensity: number + radius: number + samples: number + power: number + minAngle: number + scale: number + } + bloom: { + enabled: boolean + intensity: number + lastMipLevel: number + } + chromaticAberration: { + enabled: boolean + intensity: number + } + taa: { + enabled: boolean + jitter: number + } + grading: { + enabled: boolean + brightness: number + contrast: number + saturation: number + tint: string + } + lut: { + intensity: number + textureUrl?: string | null + } + vignette: { + enabled: boolean + intensity: number + inner: number + outer: number + curvature: number + color: string + } + directionalLight: { + enabled: boolean + color: string + intensity: number + azimuth: number + elevation: number + castShadows: boolean + shadowIntensity: number + shadowDistance: number + shadowResolution: number + shadowBias: number + normalOffsetBias: number + shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3' + vsmBlurSize: number + } + shadowCatcher: { + enabled: boolean + size: number + yOffset: number + lightIntensity: number + lightColor: string + lightAzimuth: number + lightElevation: number + shadowIntensity: number + shadowDistance: number + shadowResolution: number + shadowBias: number + normalOffsetBias: number + shadowType: 'vsm16' | 'vsm32' | 'pcf1' | 'pcf3' + vsmBlurSize: number + } +} + +export const DEFAULT_POSTFX: PostFxSettings = { + lighting: { + exposure: 1.21, + skyBoxIntensity: 0.86, + }, + envAtlas: { + enabled: true, + background: false, + reflection: true, + intensity: 0.76, + reflectionIntensity: 0.6, + brightness: 1.65, + contrast: 1, + saturation: 1, + toneMapping: 0, + mip: 1, + rotation: 247, + }, + skybox: { + enabled: false, + background: false, + reflection: true, + intensity: 0.62, + reflectionIntensity: 1, + mip: 0, + rotation: 0, + colorA: '#c5bfbf', + colorB: '#c7bcbc', + }, + rendering: { + backgroundColor: '#111113', + wireframe: false, + renderFormat: 18, + renderFormatFallback0: 12, + renderFormatFallback1: 14, + stencil: false, + renderTargetScale: 1, + samples: 4, + sharpness: 0, + toneMapping: 4, + sceneColorMap: false, + sceneDepthMap: false, + fog: 'exp', + fogColor: '#dcc2ff', + fogRange: [0, 100], + fogDensity: 0.008, + fogStart: 0, + fogEnd: 100, + }, + grid: { + enabled: true, + colorX: '#ffffff', + colorZ: '#ffffff', + colorMain: '#7a3cff', + alphaX: 0.18, + alphaZ: 0.18, + alphaMain: 0.6, + dotsEnabled: false, + dotsColor: '#ffffff', + dotsAlpha: 0.5, + dotsDiameter: 0.06, + crossEnabled: false, + crossColor: '#ffffff', + crossAlpha: 0.5, + crossLength: 0.5, + crossWidth: 0.06, + fadeStart: 0, + fadeEnd: 0, + }, + ssao: { + type: 'none', + blurEnabled: true, + intensity: 0.5, + radius: 30, + samples: 12, + power: 6, + minAngle: 10, + scale: 1, + }, + bloom: { + enabled: true, + intensity: 0.03, + lastMipLevel: 4, + }, + chromaticAberration: { + enabled: true, + intensity: 30, + }, + taa: { + enabled: false, + jitter: 1, + }, + grading: { + enabled: true, + brightness: 0.837, + contrast: 1.1, + saturation: 1.126, + tint: '#ffffff', + }, + lut: { + intensity: 1, + textureUrl: null, + }, + vignette: { + enabled: true, + intensity: 1, + inner: 0.25, + outer: 1.52, + curvature: 0.78, + color: '#000000', + }, + directionalLight: { + enabled: true, + color: '#ffffff', + intensity: 0.4, + azimuth: 0, + elevation: 0, + castShadows: false, + shadowIntensity: 0.5, + shadowDistance: 16, + shadowResolution: 1024, + shadowBias: 0, + normalOffsetBias: 0, + shadowType: 'vsm16', + vsmBlurSize: 8, + }, + shadowCatcher: { + enabled: true, + size: 9, + yOffset: 0.001, + lightIntensity: 0.3, + lightColor: '#ffffff', + lightAzimuth: 20, + lightElevation: 60, + shadowIntensity: 0.29, + shadowDistance: 16, + shadowResolution: 2048, + shadowBias: 0, + normalOffsetBias: 0, + shadowType: 'vsm16', + vsmBlurSize: 8, + }, +} + +function isObject(value: any): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +export function mergePostFxDefaults(raw?: Partial | null): PostFxSettings { + if (!raw) return JSON.parse(JSON.stringify(DEFAULT_POSTFX)) as PostFxSettings + + const out: any = JSON.parse(JSON.stringify(DEFAULT_POSTFX)) + const merge = (target: any, source: any) => { + if (!isObject(source)) return + for (const [key, value] of Object.entries(source)) { + if (isObject(value)) { + if (!isObject(target[key])) target[key] = {} + merge(target[key], value) + } else { + target[key] = value + } + } + } + merge(out, raw) + + const legacy: any = raw as any + if (legacy?.hdrLight) { + const src = legacy.hdrLight + const env = legacy?.envAtlas ?? {} + if (env?.enabled === undefined && typeof src.enabled === 'boolean') out.envAtlas.enabled = src.enabled + if (env?.background === undefined && typeof src.showSkybox === 'boolean') out.envAtlas.background = src.showSkybox + if (env?.intensity === undefined && typeof src.intensity === 'number') out.envAtlas.intensity = src.intensity + if (env?.mip === undefined && typeof src.mip === 'number') out.envAtlas.mip = src.mip + if (env?.rotation === undefined && typeof src.rotation === 'number') out.envAtlas.rotation = src.rotation + } + if (legacy?.hdrReflection && (legacy?.envAtlas?.reflection === undefined)) { + if (typeof legacy.hdrReflection.enabled === 'boolean') { + out.envAtlas.reflection = legacy.hdrReflection.enabled + } + } + if (out.envAtlas && typeof out.envAtlas.reflectionIntensity !== 'number') { + out.envAtlas.reflectionIntensity = out.envAtlas.intensity + } + if (out.envAtlas && typeof out.envAtlas.brightness !== 'number') { + out.envAtlas.brightness = 1 + } + if (out.envAtlas && typeof out.envAtlas.contrast !== 'number') { + out.envAtlas.contrast = 1 + } + if (out.envAtlas && typeof out.envAtlas.saturation !== 'number') { + out.envAtlas.saturation = 1 + } + if (out.envAtlas && typeof out.envAtlas.toneMapping !== 'number') { + out.envAtlas.toneMapping = 0 + } + if (out.skybox && typeof out.skybox.reflectionIntensity !== 'number') { + out.skybox.reflectionIntensity = out.skybox.intensity + } + if (out.grid && typeof out.grid.enabled !== 'boolean') { + out.grid.enabled = DEFAULT_POSTFX.grid.enabled + } + if (out.grid && typeof out.grid.colorX !== 'string') { + out.grid.colorX = DEFAULT_POSTFX.grid.colorX + } + if (out.grid && typeof out.grid.colorZ !== 'string') { + out.grid.colorZ = DEFAULT_POSTFX.grid.colorZ + } + if (out.grid && typeof out.grid.colorMain !== 'string') { + out.grid.colorMain = DEFAULT_POSTFX.grid.colorMain + } + if (out.grid && typeof out.grid.alphaX !== 'number') { + out.grid.alphaX = DEFAULT_POSTFX.grid.alphaX + } + if (out.grid && typeof out.grid.alphaZ !== 'number') { + out.grid.alphaZ = DEFAULT_POSTFX.grid.alphaZ + } + if (out.grid && typeof out.grid.alphaMain !== 'number') { + out.grid.alphaMain = DEFAULT_POSTFX.grid.alphaMain + } + if (out.grid && typeof out.grid.dotsEnabled !== 'boolean') { + out.grid.dotsEnabled = DEFAULT_POSTFX.grid.dotsEnabled + } + if (out.grid && typeof out.grid.dotsColor !== 'string') { + out.grid.dotsColor = DEFAULT_POSTFX.grid.dotsColor + } + if (out.grid && typeof out.grid.dotsAlpha !== 'number') { + out.grid.dotsAlpha = DEFAULT_POSTFX.grid.dotsAlpha + } + if (out.grid && typeof out.grid.dotsDiameter !== 'number') { + out.grid.dotsDiameter = DEFAULT_POSTFX.grid.dotsDiameter + } + if (out.grid && typeof out.grid.crossEnabled !== 'boolean') { + out.grid.crossEnabled = DEFAULT_POSTFX.grid.crossEnabled + } + if (out.grid && typeof out.grid.crossColor !== 'string') { + out.grid.crossColor = DEFAULT_POSTFX.grid.crossColor + } + if (out.grid && typeof out.grid.crossAlpha !== 'number') { + out.grid.crossAlpha = DEFAULT_POSTFX.grid.crossAlpha + } + if (out.grid && typeof out.grid.crossLength !== 'number') { + out.grid.crossLength = DEFAULT_POSTFX.grid.crossLength + } + if (out.grid && typeof out.grid.crossWidth !== 'number') { + out.grid.crossWidth = DEFAULT_POSTFX.grid.crossWidth + } + return out as PostFxSettings +} diff --git a/apps/control-station/src/components/rover/playcanvas/sceneTree.ts b/apps/control-station/src/components/rover/playcanvas/sceneTree.ts new file mode 100644 index 0000000..dacdf59 --- /dev/null +++ b/apps/control-station/src/components/rover/playcanvas/sceneTree.ts @@ -0,0 +1,20 @@ +import type * as pc from 'playcanvas' + +export type SceneTreeNode = { + id: string + name: string + enabled: boolean + children: SceneTreeNode[] +} + +export function buildSceneTree(root: pc.GraphNode, map: Map): SceneTreeNode { + const id = String((root as any).getGuid?.() ?? (root as any)._guid ?? root.name) + const children = (root.children || []).map((c) => buildSceneTree(c, map)) + map.set(id, root as pc.Entity) + return { + id, + name: root.name || 'Entity', + enabled: root.enabled !== false, + children, + } +} diff --git a/apps/control-station/src/components/rover/rover.css b/apps/control-station/src/components/rover/rover.css new file mode 100644 index 0000000..a8d3263 --- /dev/null +++ b/apps/control-station/src/components/rover/rover.css @@ -0,0 +1,20 @@ +.rover-view { position:relative; height:100%; min-height:200px; overflow:hidden; container-type:inline-size; } +.rover-view:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; } +.rover-view .rover-view__scene { position:absolute; inset:0; } +.rover-view__empty { display:grid; place-content:center; height:100%; text-align:center; font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-muted); } +.rover-view__status { position:absolute; top:var(--nodedc-space-3); left:var(--nodedc-space-3); } +.rover-view__controls { position:absolute; right:var(--nodedc-space-3); bottom:var(--nodedc-space-3); display:flex; flex-direction:column; gap:var(--nodedc-space-2); align-items:center; } +.rover-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); } +.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); } +.rover-key-KeyW { grid-column:2; }.rover-key-KeyA { grid-column:1; grid-row:2; }.rover-key-KeyS { grid-column:2; grid-row:2; }.rover-key-KeyD { grid-column:3; grid-row:2; } +.rover-key-KeyQ { grid-column:1; }.rover-key-KeyE { grid-column:3; } +.rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); } +@container (max-width: 380px) { + .rover-view__authority { top:48px; bottom:auto; } +} +.rover-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-3); } +.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); } +.rover-telemetry { height:100%; overflow:auto; padding:var(--nodedc-space-3); display:flex; flex-wrap:wrap; align-content:start; gap:var(--nodedc-space-4); font-size:var(--nodedc-font-size-sm); } +.rover-telemetry section { flex:1; min-width:170px; }.rover-telemetry section > span { display:block; color:var(--nodedc-text-muted); font-size:var(--nodedc-font-size-xs); margin-top:var(--nodedc-space-1); } +.rover-telemetry dl { display:grid; gap:var(--nodedc-space-1); margin-bottom:0; }.rover-telemetry dl > div { display:flex; justify-content:space-between; gap:var(--nodedc-space-3); } +.rover-telemetry dt { color:var(--nodedc-text-secondary); }.rover-telemetry dd { margin:0; font-variant-numeric:tabular-nums; } diff --git a/apps/control-station/src/composition/devicePlugins.ts b/apps/control-station/src/composition/devicePlugins.ts index 2091dcb..c242a83 100644 --- a/apps/control-station/src/composition/devicePlugins.ts +++ b/apps/control-station/src/composition/devicePlugins.ts @@ -1,3 +1,4 @@ +import {vescSensorUi} from '../../../../plugins/vesc/frontend/src/plugin'; import type { DeviceUiPlugin } from "../core/device-plugins/contracts"; import { xgridsK1Plugin } from "@xgrids-k1/frontend/plugin"; import {insta360X4SensorUi} from '../../../../plugins/insta360-x4/frontend/src/plugin'; @@ -10,4 +11,4 @@ export const installedDevicePlugins: readonly DeviceUiPlugin[] = Object.freeze([ // Node-executed camera controls use the existing sensor contribution contract; // they do not register a desktop capture/AI runtime or a new product workspace. -export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi]); +export const installedNodeSensorContributions = Object.freeze([insta360X4SensorUi,vescSensorUi]); diff --git a/apps/control-station/src/core/fleet/boardLayout.ts b/apps/control-station/src/core/fleet/boardLayout.ts new file mode 100644 index 0000000..309eb83 --- /dev/null +++ b/apps/control-station/src/core/fleet/boardLayout.ts @@ -0,0 +1,13 @@ +import {createBoardLayoutStore,type BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout'; +import {fleetRequest} from './useFleet'; + +const layouts=new Map(); +export function boardLayout(vehicleID:string):BoardLayoutStore { + let layout=layouts.get(vehicleID); + if(!layout){ + const path=`/${encodeURIComponent(vehicleID)}/board-layout`; + layout=createBoardLayoutStore({read:()=>fleetRequest(path),patch:(section,open)=>fleetRequest(path,'PATCH',{section,open})}); + layouts.set(vehicleID,layout); + } + return layout; +} diff --git a/apps/control-station/src/core/fleet/roverHoldInput.ts b/apps/control-station/src/core/fleet/roverHoldInput.ts new file mode 100644 index 0000000..7ebd75c --- /dev/null +++ b/apps/control-station/src/core/fleet/roverHoldInput.ts @@ -0,0 +1,66 @@ +import {keyDemand,keysFor,type Demand,type RoverMode} from './roverInput'; + +// The network heartbeat is not evidence that a physical key is still held. +// Allow the initial OS repeat delay, then require continuing keyboard evidence. +export const firstKeyLeaseMs=1000,repeatKeyLeaseMs=300; +export function bindRoverHoldInput(scope:HTMLElement,mode:RoverMode,callbacks:{ + held:(keys:Set)=>void;demand:(value:Demand)=>void;pause:()=>void;stop:()=>void; +},now:()=>number=()=>performance.now()){ + const doc=scope.ownerDocument,win=doc.defaultView!; + const keyboard=new Set(),pointers=new Map(); + let deadline=0,closed=false,suspended=false; + const clear=()=>{keyboard.clear();pointers.clear();deadline=0;callbacks.held(new Set());}; + const pause=()=>{if(closed||suspended)return;suspended=true;clear();callbacks.pause();}; + const stop=()=>{if(closed)return;closed=true;clear();callbacks.stop();}; + const focused=()=>!doc.hidden&&doc.hasFocus()&&scope.contains(doc.activeElement); + const valid=()=>{ + if(closed)return false; + if(!focused()||(keyboard.size>0&&now()>=deadline)){pause();return false;} + return true; + }; + const publish=()=>{const held=new Set([...keyboard,...pointers.values()]);callbacks.held(held);callbacks.demand(keyDemand(mode,held));}; + const down=(e:KeyboardEvent)=>{ + if(!e.isTrusted||closed)return; + if(e.code==='Space'||e.code==='Escape'){e.preventDefault();stop();return;} + if(e.altKey||e.ctrlKey||e.metaKey){pause();return;} + if(!keysFor(mode).includes(e.code)||!valid())return; + if((e.target as HTMLElement)?.closest('input,textarea,select,[role=dialog],[role=listbox]')){pause();return;} + // A repeat delivered after focus returns cannot become a fresh press. + if(e.repeat&&!keyboard.has(e.code))return; + e.preventDefault();suspended=false;keyboard.add(e.code); + deadline=now()+(e.repeat?repeatKeyLeaseMs:firstKeyLeaseMs);publish(); + }; + const up=(e:KeyboardEvent)=>{ + if(!e.isTrusted||closed||!keyboard.delete(e.code))return; + e.preventDefault();if(!keyboard.size)deadline=0;publish(); + }; + const blur=(e:Event)=>{if(e.target===win)pause();}; + const focusOut=(e:FocusEvent)=>{if(scope.contains(e.target as Node)&&!scope.contains(e.relatedTarget as Node|null))pause();}; + const focusIn=()=>{if(!focused())pause();}; + const hidden=()=>{if(doc.hidden)pause();}; + const outside=(e:Event)=>{if(!scope.contains(e.target as Node))pause();}; + const pointerUp=(e:PointerEvent)=>{if(!closed&&pointers.delete(e.pointerId))publish();}; + const pointerCancel=(e:PointerEvent)=>{if(pointers.has(e.pointerId))pause();}; + win.addEventListener('keydown',down,true);win.addEventListener('keyup',up,true); + win.addEventListener('blur',blur,true);win.addEventListener('pagehide',stop); + doc.addEventListener('focusout',focusOut,true);doc.addEventListener('focusin',focusIn,true); + doc.addEventListener('visibilitychange',hidden);doc.addEventListener('pointerdown',outside,true); + doc.addEventListener('pointerup',pointerUp,true);doc.addEventListener('pointercancel',pointerCancel,true); + doc.addEventListener('contextmenu',pause,true); + const timer=win.setInterval(valid,50); + return { + valid, + pointerDown(id:number,code:string){if(valid()){suspended=false;pointers.set(id,code);publish();}}, + pointerUp(id:number){if(!closed&&pointers.delete(id))publish();}, + pointerCancel(id:number){if(pointers.has(id))pause();}, + dispose(){ + closed=true;clear();callbacks.demand({left:0,right:0});win.clearInterval(timer); + win.removeEventListener('keydown',down,true);win.removeEventListener('keyup',up,true); + win.removeEventListener('blur',blur,true);win.removeEventListener('pagehide',stop); + doc.removeEventListener('focusout',focusOut,true);doc.removeEventListener('focusin',focusIn,true); + doc.removeEventListener('visibilitychange',hidden);doc.removeEventListener('pointerdown',outside,true); + doc.removeEventListener('pointerup',pointerUp,true);doc.removeEventListener('pointercancel',pointerCancel,true); + doc.removeEventListener('contextmenu',pause,true); + }, + }; +} diff --git a/apps/control-station/src/core/fleet/roverInput.ts b/apps/control-station/src/core/fleet/roverInput.ts new file mode 100644 index 0000000..547d1f3 --- /dev/null +++ b/apps/control-station/src/core/fleet/roverInput.ts @@ -0,0 +1,20 @@ +export type RoverMode='arcade'|'tank'; +export type Demand={left:number;right:number}; +export const keysFor=(mode:RoverMode)=>mode==='arcade'?['KeyW','KeyA','KeyS','KeyD']:['KeyQ','KeyE','KeyA','KeyD']; +export function keyDemand(mode:RoverMode,held:ReadonlySet):Demand { + const axis=(positive:string,negative:string)=>Number(held.has(positive))-Number(held.has(negative)); + if(mode==='tank')return {left:axis('KeyQ','KeyA'),right:axis('KeyE','KeyD')}; + const forward=axis('KeyW','KeyS'),turn=axis('KeyD','KeyA'); + const scale=Math.max(1,Math.abs(forward)+Math.abs(turn)); + return {left:(forward+turn)/scale,right:(forward-turn)/scale}; +} +export const defaultRoverSettings={version:1,mode:'arcade' as RoverMode,currentA:30,maxErpm:2000,model:''}; +export function roverSettings(value:unknown):typeof defaultRoverSettings { + const v=value as Partial|null; + return {...defaultRoverSettings,...(v?.version===1?{ + mode:v.mode==='tank'?'tank':'arcade', + currentA:typeof v.currentA==='number'&&Number.isFinite(v.currentA)?Math.max(.5,Math.min(30,v.currentA)):30, + maxErpm:typeof v.maxErpm==='number'&&Number.isFinite(v.maxErpm)?Math.max(300,Math.min(3000,v.maxErpm)):2000, + model:v.model==='dcd006-v020'?v.model:'', + }:{} )}; +} diff --git a/apps/control-station/src/core/fleet/sensorTransport.ts b/apps/control-station/src/core/fleet/sensorTransport.ts index 9ee8749..d557b97 100644 --- a/apps/control-station/src/core/fleet/sensorTransport.ts +++ b/apps/control-station/src/core/fleet/sensorTransport.ts @@ -9,6 +9,7 @@ export function createFleetSensorTransport(vehicleID:string):SensorTransport { return {...value.sensor_state,fresh:value.connectivity==='online'&&value.enrollment==='paired'}; }; return { + configurationArchive:{list:(device,before)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations${before?'?before='+encodeURIComponent(before):''}`),read:(device,version)=>fleetRequest(`${path}/${encodeURIComponent(device)}/configurations/${encodeURIComponent(version)}`)}, enrollment:{state:()=>fleetRequest(`${path}/enrollment`),submit:value=>fleetRequest(`${path}/enrollment/operations`,'POST',value),operation:id=>fleetRequest(`${path}/enrollment/operations/${encodeURIComponent(id)}`)}, inventory:async()=>inventory(await fleetRequest()), subscribe:(receive,unavailable)=>{const events=new EventSource('/api/v1/fleet/events');events.onmessage=e=>{try{receive(inventory(JSON.parse(e.data)));}catch{unavailable();}};events.onerror=unavailable;return()=>events.close();}, diff --git a/apps/control-station/src/core/fleet/useRoverControl.ts b/apps/control-station/src/core/fleet/useRoverControl.ts new file mode 100644 index 0000000..a8a8ad1 --- /dev/null +++ b/apps/control-station/src/core/fleet/useRoverControl.ts @@ -0,0 +1,79 @@ +import {useCallback,useEffect,useRef,useState} from 'react'; +import type {Demand} from './roverInput'; + +export interface RoverReading { + id:string;uuid:string;slot:string|null;label:string;age_ms:number; + values:{motor_current_a:number;input_current_a:number;input_voltage_v:number;erpm:number;duty:number;mos_temperature_c:number;fault_code:number}; +} +export interface RoverState { + fresh:boolean;controlling:boolean; + snapshot:{supported?:boolean;instance?:string;state?:string;session_id?:string|null;message?:string|null;devices?:RoverReading[]; + profile?:{layout:null|'1x1'|'2x2';bindings:Record}}; +} +const empty:RoverState={fresh:false,controlling:false,snapshot:{}}; +async function request(url:string,body?:unknown,keepalive=false):Promise{ + const response=await fetch(url,{method:body?'POST':'GET',headers:body?{'Content-Type':'application/json'}:undefined, + body:body?JSON.stringify(body):undefined,cache:'no-store',signal:AbortSignal.timeout(body?350:1000),keepalive}); + const result=await response.json(); + if(!response.ok)throw new Error(typeof result.detail==='string'?result.detail:'Канал управления недоступен.'); + return result; +} +export function useRoverControl(vehicleID:string,visible:boolean){ + const url=`/api/v1/fleet/${encodeURIComponent(vehicleID)}/rover`; + const [state,setState]=useState(empty),[armed,setArmed]=useState(false),[pending,setPending]=useState(false),[error,setError]=useState(null); + const [connecting,setConnecting]=useState(true),armPending=useRef(false); + const session=useRef(null),seq=useRef(0),demand=useRef({left:0,right:0}),sending=useRef(false),generation=useRef(0); + const inputGuard=useRef<(()=>boolean)|null>(null); + const setInputGuard=useCallback((guard:(()=>boolean)|null)=>{inputGuard.current=guard;},[]); + const stop=useCallback(()=>{ + generation.current++;armPending.current=false;const id=session.current;session.current=null;demand.current={left:0,right:0};setArmed(false);setPending(false); + if(id)void request(url+'/command',{session_id:id,sequence:++seq.current,left:0,right:0,stop:true},true).catch(()=>{}); + },[url]); + const send=useCallback(async()=>{ + const id=session.current;if(!id)return; + // Check on every heartbeat, even when a previous request is still pending. + const inputValid=(demand.current.left===0&&demand.current.right===0)||inputGuard.current?.(); + if(document.hidden||!document.hasFocus()||!inputValid)demand.current={left:0,right:0}; + if(sending.current)return; + sending.current=true; + try{await request(url+'/command',{session_id:id,sequence:++seq.current,...demand.current,stop:false});} + catch(e){if(session.current===id){stop();setError(e instanceof Error?e.message:'Команда не подтверждена.');}} + finally{sending.current=false;} + },[url,stop]); + const setDemand=useCallback((next:Demand)=>{demand.current=next;void send();},[send]); + const pauseInput=useCallback(()=>{demand.current={left:0,right:0};void send();},[send]); + const arm=useCallback(async(currentA:number,maxErpm:number)=>{ + if(session.current||armPending.current)return false;const g=++generation.current;armPending.current=true;setError(null);setPending(true); + try{ + const result=await request<{session_id:string}>(url+'/arm',{standstill_confirmed:true,current_a:currentA,max_erpm:maxErpm}); + if(g!==generation.current){void request(url+'/command',{session_id:result.session_id,sequence:1,left:0,right:0,stop:true},true).catch(()=>{});return false;} + // Retire every read started before this acknowledgement, including reads + // begun while POST /arm was pending. They describe the previous authority. + generation.current++;armPending.current=false;setPending(false); + seq.current=0;demand.current={left:0,right:0};session.current=result.session_id;setArmed(true);void send(); + return true; + }catch(e){if(g===generation.current)setError(e instanceof Error?e.message:'Не удалось включить управление.');return false;} + finally{if(g===generation.current){armPending.current=false;setPending(false);}} + },[url,send]); + useEffect(()=>{ + if(!visible){stop();setState(empty);setConnecting(false);return;} + let alive=true,busy=false,lastFault='';const started=performance.now();setConnecting(true); + const poll=async()=>{ + if(busy)return;busy=true;const before=generation.current; + try{const value=await request(url);if(alive&&before===generation.current){setState(value); + setConnecting(!value.fresh&&performance.now()-started<2000); + if(session.current&&(!value.fresh||!value.controlling)){stop();} + const fault=value.snapshot.state==='fault'?value.snapshot.message??'':''; + if(fault&&lastFault!==fault){lastFault=fault;setError(fault);} + }}catch{if(alive&&before===generation.current){setState(empty);setConnecting(false);if(session.current)stop();}}finally{busy=false;} + }; + void poll();const timer=setInterval(()=>void poll(),200),heartbeat=setInterval(()=>void send(),100); + const blur=()=>pauseInput(),hidden=()=>{if(document.hidden)pauseInput();}; + window.addEventListener('blur',blur);window.addEventListener('pagehide',stop);document.addEventListener('visibilitychange',hidden); + return()=>{alive=false;clearInterval(timer);clearInterval(heartbeat);window.removeEventListener('blur',blur);window.removeEventListener('pagehide',stop);document.removeEventListener('visibilitychange',hidden);stop();}; + },[url,visible,stop,send,pauseInput]); + const ready=armed&&state.fresh&&state.snapshot.session_id===session.current&&['ready','driving'].includes(state.snapshot.state??''); + const canArm=state.fresh&&state.snapshot.supported===true&&!state.controlling&&!['preparing','ready','driving','stopping'].includes(state.snapshot.state??''); + return {state,armed,pending,error,connecting,canArm,arm,stop,pauseInput,setDemand,setInputGuard,ready,clearError:()=>setError(null)}; +} +export type RoverController=ReturnType; diff --git a/apps/control-station/src/workspaces/Workspaces.tsx b/apps/control-station/src/workspaces/Workspaces.tsx index 8733927..6f79938 100644 --- a/apps/control-station/src/workspaces/Workspaces.tsx +++ b/apps/control-station/src/workspaces/Workspaces.tsx @@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) { case "missions": return ; case "vehicles": - return ; + return ; case "catalog": return ; case "contour-health": diff --git a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx index 10bcd75..10a64c0 100644 --- a/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx +++ b/apps/control-station/src/workspaces/fleet/VehicleSensors.tsx @@ -1,11 +1,13 @@ -import {useMemo} from 'react'; +import {useMemo,type ReactNode} from 'react'; +import {boardLayout} from '../../core/fleet/boardLayout'; import {useDevicePluginHost} from '../../core/device-plugins/DevicePluginHost'; import {createIsolatedRerunHost} from '../../components/rerun/isolatedRerunHost'; import {SensorWorkspace} from '../../../../../packages/sensor-ui/src/SensorWorkspace'; import {createFleetSensorTransport} from '../../core/fleet/sensorTransport'; -export function VehicleSensors({vehicleID,enabled,onDetailChange}:{vehicleID:string;enabled:boolean;onDetailChange:(open:boolean)=>void}){ +export function VehicleSensors({vehicleID,enabled,computer,description}:{vehicleID:string;enabled:boolean;computer:ReactNode;description:string}){ const {registry}=useDevicePluginHost(); const sensorContributions=registry.sensorContributions; + const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]); const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]); - return ; + return ; } diff --git a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx index 9bde6da..9668bcd 100644 --- a/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx +++ b/apps/control-station/src/workspaces/fleet/VehiclesWorkspace.tsx @@ -10,7 +10,7 @@ const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: " const platformLabel = (value: string) => platforms.find(item => item.value === value)?.label ?? value; function statusLabel(item: Vehicle) { return item.enrollment === "pending" ? "Подтверждаем привязку" : item.enrollment === "revoked" ? "Доверие отозвано" : item.enrollment === "failed" ? "Привязка не завершена" : item.connectivity === "online" ? "В сети" : "Нет связи"; } -export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: number }) { +export function VehiclesWorkspace({ createRequest = 0, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) { const fleet = useFleet(); const [adding, setAdding] = useState(false); const [code, setCode] = useState(""); @@ -20,7 +20,6 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe const [pending, setPending] = useState(false); const [error, setError] = useState(""); const [selected, setSelected] = useState(null); - const [sensorOpen, setSensorOpen] = useState(false); const [monitorOpen,setMonitorOpen]=useState(false); const [observationOpen,setObservationOpen]=useState(false); const [revoking, setRevoking] = useState(null); @@ -44,25 +43,24 @@ export function VehiclesWorkspace({ createRequest = 0 }: { createRequest?: numbe finally { setPending(false); } } const detail = fleet.items?.find(item => item.id === selected); - if(detail&&observationOpen)return {setObservationOpen(false);setSensorOpen(false);}}/>; + if(detail&&observationOpen)return {setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>; if(detail&&monitorOpen)return setMonitorOpen(false)}/>; return
{fleet.error &&

{fleet.error}

} {!adding && error &&

{error}

} {detail ? <> -
- {!sensorOpen && {fleet.error ? "Нет свежих данных" : statusLabel(detail)}}> +
+ {fleet.error ? "Нет свежих данных" : statusLabel(detail)}}> {detail.notice &&

{detail.notice}

}
Бортовой компьютер
{detail.node_id}
Последняя связь
{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}
{detail.host && <>
Имя БК в системе
{detail.host.hostname}
Операционная система
{detail.host.os}
Архитектура
{detail.host.architecture}
Процессоры
{detail.host.cpus}
Память
{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}
}
- + {detail.enrollment !== "revoked" && }
-
} - - : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={<> setSelected(item.id)}> {setSelected(item.id);setObservationOpen(true);}}>} />
  • )}
    } + }/> + : !fleet.items ? : fleet.items.length === 0 ? : {fleet.items.map(item =>
  • } title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={{fleet.error ? "Нет свежих данных" : statusLabel(item)}} actions={<> setSelected(item.id)}> {setSelected(item.id);setObservationOpen(true);}}>} />
  • )}
    } {preview ? : }}>
    setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>} - {source.key!=='map'&&{setFocused(source.key);setLayersOpen(true);}}>} + {source.deviceID&&{setFocused(source.key);setLayersOpen(true);}}>} setFull(value=>value===source.key?null:source.key)}> setVisible(source.key,false)}>
    @@ -63,6 +69,8 @@ export function BoardObservationCenter({vehicleID,name,enabled,back}:{vehicleID: {groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&({id:view.id,layer:layerFor(observationKey(device.id,view.id),view),target:media.current.get(observationKey(device.id,view.id))!,...headers.current.get(observationKey(device.id,view.id))!}))}/>)} {visible.includes('map')&&createPortal(,media.current.get('map')!,'map-session')} + {visible.includes('rover')&&createPortal(,media.current.get('rover')!,'rover-session')} + {visible.includes('telemetry')&&createPortal(,media.current.get('telemetry')!,'telemetry-session')} setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md">
    {!active&&({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/>
    ;})} diff --git a/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx b/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx index 4575e4e..ee52e85 100644 --- a/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx +++ b/apps/control-station/src/workspaces/fleet/observation/ObservationDeck.tsx @@ -11,6 +11,8 @@ export function ObservationMount({element,className='observation-mount'}:{elemen export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[];mounts:Map;layout:ObservationLayout;onSplit:(key:string,value:number)=>void;depth?:number}){ if(!ids.length)return null; if(ids.length===1)return ; - const mid=Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical'; + const defaultColumns=depth===0&&layout.arrangement==='auto'&&!layout.order.some(id=>id==='rover'||id==='telemetry')&&ids.some(id=>!['map','rover','telemetry'].includes(id)); + const cameraCount=ids.filter(id=>!['map','rover','telemetry'].includes(id)).length; + const mid=defaultColumns&&cameraCountonSplit(key,value)} separatorLabel="Изменить размеры окон" primary={} secondary={}/>; } diff --git a/apps/control-station/test/boardLayout.test.mjs b/apps/control-station/test/boardLayout.test.mjs new file mode 100644 index 0000000..e99a342 --- /dev/null +++ b/apps/control-station/test/boardLayout.test.mjs @@ -0,0 +1,57 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import ts from 'typescript'; +const code=ts.transpileModule(readFileSync(new URL('../../../packages/sensor-ui/src/boardLayout.ts',import.meta.url),'utf8'),{compilerOptions:{module:ts.ModuleKind.ESNext,target:ts.ScriptTarget.ES2022}}).outputText; +const {createBoardLayoutStore,defaultBoardLayout}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64')); +const tick=()=>new Promise(resolve=>setImmediate(resolve)); +function server(){ + let value=structuredClone(defaultBoardLayout);const calls=[]; + return {calls,read:async()=>structuredClone(value),patch:async(section,open)=>{ + calls.push({section,open});await tick(); + value={...value,revision:value.revision+1,open_sections:defaultBoardLayout.open_sections.filter(id=>id===section?open:value.open_sections.includes(id))}; + return structuredClone(value); + }}; +} +test('rapid toggles persist after navigation and preserve all-closed state',async()=>{ + const api=server();const store=createBoardLayoutStore(api);await store.load(); + const unsubscribe=store.subscribe(()=>{}); + store.change(['settings','devices']);store.change(['devices']);store.change([]); + unsubscribe(); + while(store.getSnapshot().saving)await tick(); + const reopened=createBoardLayoutStore(api);await reopened.load(); + assert.deepEqual(reopened.getSnapshot().value.open_sections,[]); + assert.equal(api.calls.length,3); +}); +test('a later toggle wins while an older patch is still in flight',async()=>{ + const api=server();const store=createBoardLayoutStore(api);await store.load(); + store.change(['settings','devices']);store.change(['computer','settings','devices']); + while(store.getSnapshot().saving)await tick(); + assert.deepEqual((await api.read()).open_sections,defaultBoardLayout.open_sections); +}); +test('different vehicles and simultaneous section changes do not clobber one another',async()=>{ + const api=server(),other=server();const a=createBoardLayoutStore(api),b=createBoardLayoutStore(api),c=createBoardLayoutStore(other); + await Promise.all([a.load(),b.load(),c.load()]); + a.change(['settings','devices']);b.change(['computer','devices']); + while(a.getSnapshot().saving||b.getSnapshot().saving)await tick(); + assert.deepEqual((await api.read()).open_sections,['devices']); + assert.deepEqual(c.getSnapshot().value.open_sections,defaultBoardLayout.open_sections); +}); +test('failed save reports failure, rolls back, and explicit reload recovers',async()=>{ + const api=server();let fail=true; + const store=createBoardLayoutStore({...api,patch:(...args)=>fail?Promise.reject(new Error('offline')):api.patch(...args)}); + await store.load();store.change([]); + while(store.getSnapshot().saving)await tick(); + assert.ok(store.getSnapshot().error); + assert.deepEqual(store.getSnapshot().value.open_sections,defaultBoardLayout.open_sections); + fail=false;await store.load();store.change(['devices']); + while(store.getSnapshot().saving)await tick(); + assert.equal(store.getSnapshot().error,null); + assert.deepEqual((await api.read()).open_sections,['devices']); +}); +test('invalid layout cannot overwrite stored preferences',async()=>{ + let writes=0; + const store=createBoardLayoutStore({read:async()=>({...defaultBoardLayout,open_sections:['motor-start']}),patch:async()=>{writes++;return defaultBoardLayout;}}); + await store.load();store.change([]); + assert.equal(store.getSnapshot().ready,false);assert.ok(store.getSnapshot().error);assert.equal(writes,0); +}); diff --git a/apps/control-station/test/roverControl.test.mjs b/apps/control-station/test/roverControl.test.mjs new file mode 100644 index 0000000..3dcdd28 --- /dev/null +++ b/apps/control-station/test/roverControl.test.mjs @@ -0,0 +1,157 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build} from 'esbuild'; + +async function moduleAt(path) { + const result = await build({entryPoints:[new URL(path,import.meta.url).pathname],bundle:true,write:false,format:'esm',platform:'node'}); + return import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); +} +const {defaultProfile:base,mix,motorDemands,parseProfile}=await moduleAt('../../../packages/rover-control/src/profile.ts'); +const {AuthorityModel}=await moduleAt('../../../packages/rover-control/src/authority.ts'); +const axes=(leftY=0,rightY=0,leftX=0,rightX=0)=>({leftY,rightY,leftX,rightX}); +const profile={...base,deadband:0}; +const arcade={...profile,mode:'arcade'}; +const policy={maxAgeMs:100,neutralMs:200,maxCommandMs:100,monitoredAxes:['leftY','rightY','leftX','rightX']}; +const motors=['a','b','c','d']; +function frame(now,values=axes(),extra={}) { + return {now,link:{state:'live',at:now}, + axes:Object.fromEntries(Object.entries(values).map(([key,value])=>[key,{value,at:now,sequence:now}])), + drives:Object.fromEntries(motors.map(id=>[id,{at:now,healthy:true,stopped:true}])),...extra}; +} +function ready(p=base) { + const model=new AuthorityModel(p,motors,policy,'boot-A'); + for(let t=0;t<=200;t+=50)model.step(frame(t)); + return model; +} +function core() { + const model=ready(); + const {token}=model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}})); + assert.ok(token);return {model,token}; +} +const command=(token,at,sequence=0)=>({token,at,expires:at+75,sequence,demand:{left:.5,right:.5}}); + +test('tank keeps the two motor sides independent, including reversal',()=>{ + assert.deepEqual(mix(profile,axes(.8,-.4)),{left:.8,right:-.4}); + assert.deepEqual(mix(profile,axes(0,.4)),{left:0,right:.4}); +}); +test('arcade cardinal, diagonal and reverse vectors preserve yaw sign',()=>{ + for(const [y,x,left,right] of [[0,0,0,0],[1,0,1,1],[-1,0,-1,-1],[0,1,1,-1],[0,-1,-1,1],[1,1,1,0],[1,-1,0,1],[-1,1,0,-1],[-1,-1,-1,0]]) + assert.deepEqual(mix(arcade,axes(0,y,0,x)),{left,right}); + assert.deepEqual(mix(arcade,axes(0,.5,0,.5)),{left:.5,right:0}); +}); +test('selected stick defines the axes, not the number or position of motors',()=>{ + assert.deepEqual(mix({...arcade,stick:'left'},axes(.5,-1,.5,-1)),{left:.5,right:0}); +}); +test('deadband is continuous and rescaled; response and output scale are separate',()=>{ + assert.deepEqual(mix(base,axes(-.064,.074)),{left:0,right:0}); + assert.deepEqual(mix({...profile,deadband:.2,response:'squared',outputScale:.5},axes(.6,-1)),{left:.12499999999999997,right:-.5}); + assert.ok(Math.abs(mix(base,axes(.150001)).left)<.000002); +}); +test('every point in the input square stays bounded and changes sign symmetrically',()=>{ + for(let i=-20;i<=20;i++)for(let j=-20;j<=20;j++){ + const a=mix({...arcade,outputScale:.8},axes(0,i/20,0,j/20)); + const b=mix({...arcade,outputScale:.8},axes(0,-i/20,0,-j/20)); + assert.ok(Math.abs(a.left)<=.8+1e-12 && Math.abs(a.right)<=.8+1e-12); + assert.ok(Math.abs(a.left+b.left)<1e-12 && Math.abs(a.right+b.right)<1e-12); + } +}); +test('profile JSON rejects unknown fields, malformed numbers and unknown schema',()=>{ + assert.deepEqual(parseProfile(JSON.parse(JSON.stringify(base))),base); + for(const bad of [null,[],{...base,schema:'future'},{...base,revision:1.5},{...base,revision:-1},{...base,deadband:NaN},{...base,outputScale:1.1},{...base,writeVesc:true}])assert.throws(()=>parseProfile(bad)); + for(const value of [NaN,Infinity,1.01,undefined])assert.throws(()=>mix(profile,{...axes(),leftY:value})); +}); +test('2, 4 and 10 motors route by identity and explicit direction, never USB position',()=>{ + for(const n of [2,4,10]){ + const bindings=Array.from({length:n},(_,i)=>({uuid:i.toString(16).padStart(24,'0'),side:imotorDemands({left:1,right:1},[b])); + assert.throws(()=>motorDemands({left:1,right:1},[b,{...b,side:'right'}])); +}); +test('boot with held stick never permits motion; stable stopped neutral required',()=>{ + const model=new AuthorityModel(base,motors,policy,'boot'); + for(let t=0;t<1000;t+=50){const d=model.step(frame(t,axes(1)));assert.equal(d.state,'hold');assert.deepEqual(d.demand,{left:0,right:0});} + for(let t=1000;t<1200;t+=50)assert.equal(model.step(frame(t)).state,'hold'); + assert.equal(model.step(frame(1200)).state,'rc-ready'); + assert.equal(model.step(frame(1250,axes(1))).state,'rc-manual'); +}); +test('first RC gesture stops Core immediately, held packets never count as second gesture',()=>{ + const {model,token}=core(); + assert.equal(model.step(frame(300,axes(),{command:command(token,300)})).state,'core'); + const first=model.step(frame(350,axes(.5),{command:command(token,350,1)})); + assert.equal(first.reason,'rc-takeover');assert.ok(first.stopAll&&first.flushMotionQueue&&first.cancelMotionTasks); + for(let t=400;t<1500;t+=50)assert.deepEqual(model.step(frame(t,axes(.5))).demand,{left:0,right:0}); + for(let t=1500;t<=1700;t+=50)model.step(frame(t)); + const second=model.step(frame(1750,axes(.5))); + assert.equal(second.state,'rc-manual');assert.ok(second.demand.left>0); + assert.equal(model.step(frame(1800)).state,'rc-manual'); + assert.equal(model.step(frame(1850,axes(0,.5),{command:command(token,1850,2)})).state,'rc-manual'); +}); +test('neutral of one channel or a single moving member never completes rearming',()=>{ + for(const kind of ['axis','drive']){ + const {model}=core();model.step(frame(300,axes(1))); + for(let t=350;t<=1500;t+=50){const f=frame(t,kind==='axis'?axes(0,.2):axes());if(kind==='drive')f.drives.d.stopped=false;assert.equal(model.step(f).state,'hold');} + } +}); +test('loss of any of four controllers stops all sides and invalidates Core',()=>{ + for(const id of motors){const {model,token}=core();const f=frame(300,axes(),{command:command(token,300)});delete f.drives[id];const d=model.step(f);assert.equal(d.reason,'drive-unverified');assert.ok(d.cancelMotionTasks);assert.deepEqual(d.demand,{left:0,right:0});} +}); +test('unknown/lost radio cannot be treated as neutral even with fresh zero PWM reads',()=>{ + for(const state of ['lost','unknown']){const {model,token}=core();const d=model.step(frame(300,axes(),{link:{state,at:300},command:command(token,300)}));assert.equal(d.reason,'receiver-unverified');assert.ok(d.stopAll);} +}); +test('old decoded PPM, future samples, modified repeats and sequence rollback are rejected',()=>{ + for(const patch of [{at:0},{at:351},{sequence:249},{value:.4,at:250,sequence:250},{value:NaN}]){ + const {model}=core();const f=frame(350);Object.assign(f.axes.leftY,patch);assert.equal(model.step(f).reason,'axis-invalid'); + } +}); +test('one repeated neutral sample cannot qualify stable neutral',()=>{ + const model=new AuthorityModel(base,motors,{...policy,neutralMs:50},'boot'); + model.step(frame(0));const f=frame(50);f.axes.leftY={at:0,sequence:0,value:0}; + assert.equal(model.step(f).state,'hold'); +}); +test('receiver reconnect while held stays stopped until neutral and a new gesture',()=>{ + const model=ready();model.step(frame(250,axes(.5)));model.step(frame(300,axes(),{link:{state:'lost',at:300}})); + for(let t=350;t<1000;t+=50)assert.equal(model.step(frame(t,axes(.5))).state,'hold'); + for(let t=1000;t<=1200;t+=50)model.step(frame(t)); + assert.equal(model.step(frame(1250,axes(.5))).state,'rc-manual'); +}); +test('Core expiry/replay/wrong boot token/missing commands all require rearming',()=>{ + for(const change of [c=>({...c,expires:300}),c=>({...c,expires:500}),c=>({...c,token:'old-boot:1'}),()=>undefined,c=>({...c,demand:{left:Infinity,right:0}})]){ + const {model,token}=core();assert.equal(model.step(frame(300,axes(),{command:change(command(token,300))})).reason,'core-command-invalid'); + } + const {model,token}=core();model.step(frame(300,axes(),{command:command(token,300,1)})); + assert.equal(model.step(frame(350,axes(),{command:command(token,350,1)})).reason,'core-command-invalid'); +}); +test('clock rewind and missing observation interval cannot bypass the stop gate',()=>{ + for(const time of [249,500,NaN]){const {model,token}=core();const d=model.step(frame(time,axes(),{command:command(token,time)}));assert.equal(d.state,'hold');assert.ok(d.cancelMotionTasks);} +}); +test('premature or replayed Core request does not acquire authority after neutral',()=>{ + const model=new AuthorityModel(base,motors,policy,'boot'); + for(let t=0;t<=300;t+=50){const d=model.step(frame(t,axes(),{requestCore:{owner:'remote',sequence:1,at:t}}));assert.notEqual(d.state,'core');} + assert.equal(model.step(frame(350,axes(),{requestCore:{owner:'remote',sequence:2,at:350}})).state,'core'); +}); +test('reboot cannot restore authority from serialized profile or old grant',()=>{ + const {token}=core(), model=ready(); + assert.equal(model.step(frame(250,axes(),{command:command(token,250)})).state,'rc-ready'); + const next=new AuthorityModel(base,motors,policy,'boot-B'); + for(let t=0;t<=200;t+=50)next.step(frame(t)); + const grant=next.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}})); + assert.notEqual(grant.token,token); + assert.equal(next.step(frame(300,axes(),{command:command(token,300)})).state,'hold'); +}); +test('profile limits do not mask RC takeover intent at zero output scale',()=>{ + const model=ready({...base,outputScale:0});const grant=model.step(frame(250,axes(),{requestCore:{owner:'remote',sequence:1,at:250}})); + assert.ok(grant.token);assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover'); +}); + +test('non-driving stick still takes over in Arcade and every monitored axis must return',()=>{ + const model=ready({...base,mode:'arcade',stick:'right'}); + model.step(frame(250,axes(),{requestCore:{owner:'autonomy',sequence:1,at:250}})); + assert.equal(model.step(frame(300,axes(.5))).reason,'rc-takeover'); + for(let t=350;t<800;t+=50)assert.equal(model.step(frame(t,axes(0,0,.5))).state,'hold'); + const f=frame(800);delete f.axes.leftX;assert.equal(model.step(f).reason,'axis-invalid'); + assert.throws(()=>new AuthorityModel(base,motors,{...policy,monitoredAxes:['leftY']},'boot')); +}); diff --git a/apps/control-station/test/roverControlFocus.test.mjs b/apps/control-station/test/roverControlFocus.test.mjs new file mode 100644 index 0000000..c3fd7a7 --- /dev/null +++ b/apps/control-station/test/roverControlFocus.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build} from 'esbuild'; +const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){ + b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'})); + b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`})); +}}]}); +const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); +const flush=()=>new Promise(resolve=>setImmediate(resolve)); +async function fixture(run){ + const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]])); + let focus=true,hidden=false;const calls=[],timers=[];globalThis.roverEffects=[]; + globalThis.window={addEventListener(){},removeEventListener(){}}; + globalThis.document={get hidden(){return hidden;},hasFocus:()=>focus,addEventListener(){},removeEventListener(){}}; + globalThis.setInterval=(f,ms)=>{const timer={f,ms};timers.push(timer);return timer;};globalThis.clearInterval=()=>{}; + globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body});return {ok:true,json:async()=>url.endsWith('/arm')?{session_id:'fixture-session'}:{fresh:true,controlling:true,snapshot:{state:'ready'}}};}; + const c=useRoverControl('fixture',true);const cleanup=globalThis.roverEffects.map(f=>f()); + try{await flush();await c.arm(30,2000);await flush();await run({c,calls,timers,blur:()=>{focus=false;},focus:()=>{focus=true;},hide:()=>{hidden=true;}});} + finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}} +} +test('command heartbeat sends neutral on lost blur, keeps session, and accepts new focused input',async()=>fixture(async f=>{ + f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush(); + assert.equal(f.calls.at(-1).body.left,1);f.blur();f.timers.find(t=>t.ms===100).f();await flush(); + assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0); + f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,0); + f.focus();f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.left,0); + f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1); + assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1); +})); +test('expired physical input prevents heartbeat from renewing remembered demand',async()=>fixture(async f=>{ + let valid=true;f.c.setInputGuard(()=>valid);f.c.setDemand({left:1,right:-1});await flush();valid=false; + f.timers.find(t=>t.ms===100).f();await flush();assert.equal(f.calls.at(-1).body.stop,false); +})); +test('no registered input scope cannot issue motion',async()=>fixture(async f=>{ + f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.stop,false); + assert.equal(f.calls.some(x=>x.body?.left===1),false); +})); +test('hidden document sends only neutral without a visibility event',async()=>fixture(async f=>{ + f.c.setInputGuard(()=>true);f.hide();f.c.setDemand({left:1,right:1});await flush(); + assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.some(x=>x.body?.left===1),false); +})); + +test('explicit pause clears demand without revoking session; explicit stop still revokes it',async()=>fixture(async f=>{ + f.c.setInputGuard(()=>true);f.c.setDemand({left:1,right:-1});await flush();f.c.pauseInput();await flush(); + assert.equal(f.calls.at(-1).body.stop,false);assert.equal(f.calls.at(-1).body.left,0); + f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.at(-1).body.left,1); + f.c.stop();await flush();assert.equal(f.calls.at(-1).body.stop,true); + const count=f.calls.length;f.c.setDemand({left:1,right:1});await flush();assert.equal(f.calls.length,count); +})); diff --git a/apps/control-station/test/roverControlStartup.test.mjs b/apps/control-station/test/roverControlStartup.test.mjs new file mode 100644 index 0000000..05b371e --- /dev/null +++ b/apps/control-station/test/roverControlStartup.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build} from 'esbuild'; +const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){ + b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'})); + b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`})); +}}]}); +const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); +const flush=()=>new Promise(resolve=>setImmediate(resolve)); +const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};}; +const response=value=>({ok:true,json:async()=>value}); +const idle={fresh:true,controlling:false,snapshot:{supported:true,state:'observing'}}; +async function fixture(run){ + const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]])); + const calls=[],timers=[];globalThis.roverEffects=[]; + globalThis.window={addEventListener(){},removeEventListener(){}};globalThis.document={hidden:false,hasFocus:()=>true,addEventListener(){},removeEventListener(){}}; + globalThis.setInterval=(f,ms)=>{timers.push({f,ms});return timers.length;};globalThis.clearInterval=()=>{}; + let armResponse=null,nextPoll=null; + globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body}); + if(url.endsWith('/arm'))return armResponse?armResponse.promise:response({session_id:'new-session'}); + if(!body)return nextPoll?nextPoll.promise:response(idle); + return response({accepted_sequence:body.sequence}); + }; + const c=useRoverControl('fixture',true),cleanup=globalThis.roverEffects.map(f=>f()); + try{await flush();await run({c,calls,poll:()=>timers.find(t=>t.ms===200).f(),heartbeat:()=>timers.find(t=>t.ms===100).f(),delayArm:()=>armResponse=deferred(),delayPoll:()=>nextPoll=deferred()});} + finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}} +} +test('idle poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{ + const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll(); + a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush(); + p.resolve(response(idle));await flush();f.heartbeat();await flush(); + assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old idle poll revoked the new session'); + assert.equal(f.calls.at(-1).body.session_id,'new-session'); +})); +test('failed poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{ + const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll(); + a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush(); + p.resolve({ok:false,json:async()=>({detail:'stale read failure'})});await flush(); + assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old failed poll revoked the new session'); +})); +test('a current poll still revokes control when server reports lease lost',async()=>fixture(async f=>{ + assert.equal(await f.c.arm(30,2000),true);await flush();f.poll();await flush(); + assert.equal(f.calls.at(-1).body.stop,true); +})); +test('duplicate arm clicks while request pending create exactly one lease request',async()=>fixture(async f=>{ + const a=f.delayArm();const first=f.c.arm(30,2000),second=f.c.arm(30,2000); + a.resolve(response({session_id:'new-session'}));await Promise.all([first,second]);await flush(); + assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1); +})); diff --git a/apps/control-station/test/roverHoldInput.test.mjs b/apps/control-station/test/roverHoldInput.test.mjs new file mode 100644 index 0000000..5b2af77 --- /dev/null +++ b/apps/control-station/test/roverHoldInput.test.mjs @@ -0,0 +1,85 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build} from 'esbuild'; +const result=await build({entryPoints:[new URL('../src/core/fleet/roverHoldInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'}); +const {bindRoverHoldInput}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); +class Events { + listeners=new Map();timers=new Map();next=0; + addEventListener(type,fn){if(!this.listeners.has(type))this.listeners.set(type,new Set());this.listeners.get(type).add(fn);} + removeEventListener(type,fn){this.listeners.get(type)?.delete(fn);} + emit(type,detail={}){const e={target:this,isTrusted:true,preventDefault(){},...detail};for(const fn of [...(this.listeners.get(type)??[])])fn(e);} + setInterval(fn){const id=++this.next;this.timers.set(id,fn);return id;} + clearInterval(id){this.timers.delete(id);} + tick(){for(const fn of this.timers.values())fn();} +} +function fixture(mode='arcade'){ + let time=0,focus=true;const win=new Events(),doc=new Events();doc.defaultView=win;doc.hidden=false;doc.hasFocus=()=>focus; + const scope={ownerDocument:doc,closest:()=>null},child={closest:()=>null};scope.contains=x=>x===scope||x===child;doc.activeElement=scope; + const states=[],demands=[];let stops=0,pauses=0; + const binding=bindRoverHoldInput(scope,mode,{held:k=>states.push([...k]),demand:d=>demands.push(d),stop:()=>stops++,pause:()=>{pauses++;demands.push({left:0,right:0});}},()=>time); + const key=(code,repeat=false)=>win.emit('keydown',{code,repeat,target:scope}); + return {win,doc,scope,child,binding,states,demands,key,stops:()=>stops,pauses:()=>pauses,advance:ms=>{time+=ms;win.tick();},loseFocus:()=>{focus=false;},restoreFocus:()=>{focus=true;},up:code=>win.emit('keyup',{code,target:scope})}; +} +test('blur clears D, ignores a late repeat, but accepts a fresh press without rearming',()=>{ + const f=fixture();f.key('KeyD');assert.deepEqual(f.demands.at(-1),{left:1,right:-1}); + f.win.emit('blur');assert.equal(f.pauses(),1);assert.equal(f.stops(),0);assert.deepEqual(f.states.at(-1),[]); + assert.deepEqual(f.demands.at(-1),{left:0,right:0});const count=f.demands.length; + f.key('KeyD',true);assert.equal(f.demands.length,count); + f.key('KeyW');assert.deepEqual(f.demands.at(-1),{left:1,right:1});assert.equal(f.binding.valid(),true);f.binding.dispose(); +}); +test('heartbeat validity catches missing blur event using document focus',()=>{ + const f=fixture();f.key('KeyD');f.loseFocus();assert.equal(f.binding.valid(),false);assert.equal(f.pauses(),1); + f.restoreFocus();assert.equal(f.binding.valid(),true);f.binding.dispose(); +}); +test('focus polling catches missing event or moving to another control',()=>{ + const f=fixture();f.key('KeyW');f.doc.activeElement={};f.advance(50);assert.equal(f.pauses(),1);f.binding.dispose(); +}); +test('lost keyup AND missing focus notifications still expire repeated D',()=>{ + const f=fixture();f.key('KeyD');f.advance(500);f.key('KeyD',true);f.advance(299);assert.equal(f.pauses(),0); + f.advance(1);assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.key('KeyD',true);assert.equal(f.pauses(),1);f.binding.dispose(); +}); +test('first press allows OS repeat delay but never an indefinite hold',()=>{ + const f=fixture();f.key('KeyW');f.advance(999);assert.equal(f.pauses(),0);f.advance(1);assert.equal(f.pauses(),1);f.binding.dispose(); +}); +test('continuous repeat renews hold; keyup immediately sends neutral',()=>{ + const f=fixture();f.key('KeyW');f.advance(600); + for(let i=0;i<30;i++){f.key('KeyW',true);f.advance(100);} + assert.equal(f.pauses(),0);f.up('KeyW');assert.deepEqual(f.demands.at(-1),{left:0,right:0});f.advance(1200);assert.equal(f.pauses(),0);f.binding.dispose(); +}); +test('diagonal input survives single-key OS repeat; release recomputes demand',()=>{ + const f=fixture();f.key('KeyW');f.key('KeyA'); + for(let i=0;i<20;i++){f.key('KeyA',true);f.advance(100);} + assert.equal(f.pauses(),0);assert.deepEqual(f.demands.at(-1),{left:0,right:1}); + f.up('KeyA');assert.deepEqual(f.demands.at(-1),{left:1,right:1});f.up('KeyW');f.binding.dispose(); +}); +test('repeat without a fresh press and untrusted events never command motion',()=>{ + const f=fixture();f.key('KeyD',true);f.win.emit('keydown',{code:'KeyW',isTrusted:false,target:f.scope});assert.equal(f.demands.length,0);f.binding.dispose(); +}); +test('focus inside the view is allowed; leaving or hiding it stops',()=>{ + for(const event of ['focusout','visibilitychange','pagehide','outside']){ + const f=fixture();f.key('KeyW');f.doc.emit('focusout',{target:f.scope,relatedTarget:f.child});assert.equal(f.pauses(),0); + if(event==='focusout')f.doc.emit(event,{target:f.scope,relatedTarget:null}); + else if(event==='visibilitychange'){f.doc.hidden=true;f.doc.emit(event);} + else if(event==='pagehide')f.win.emit(event); + else f.doc.emit('pointerdown',{target:{}}); + assert.equal(event==='pagehide'?f.stops():f.pauses(),1,event);f.binding.dispose(); + } +}); +test('pointer capture lost or cancelled releases every input',()=>{ + for(const mode of ['arcade','tank']){ + const f=fixture(mode);f.binding.pointerDown(1,mode==='arcade'?'KeyW':'KeyQ');f.binding.pointerCancel(1); + assert.equal(f.pauses(),1);assert.deepEqual(f.states.at(-1),[]);f.binding.dispose(); + } +}); +test('document pointerup releases even if button handler does not receive it',()=>{ + const f=fixture();f.binding.pointerDown(1,'KeyD');f.doc.emit('pointerup',{pointerId:1});assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.pauses(),0);f.binding.dispose(); +}); +test('modifier shortcuts pause; cleanup removes listeners, timer and held demand',()=>{ + const f=fixture();f.key('KeyD');f.win.emit('keydown',{code:'Tab',metaKey:true,target:f.scope});assert.equal(f.pauses(),1);f.binding.dispose(); + assert.deepEqual(f.demands.at(-1),{left:0,right:0});assert.equal(f.win.timers.size,0); + assert.equal([...f.win.listeners.values(),...f.doc.listeners.values()].reduce((sum,x)=>sum+x.size,0),0); +}); + +test('Space and Escape remain explicit session stops',()=>{ + for(const code of ['Space','Escape']){const f=fixture();f.key('KeyW');f.key(code);assert.equal(f.stops(),1);assert.equal(f.binding.valid(),false);f.binding.dispose();} +}); diff --git a/apps/control-station/test/roverInput.test.mjs b/apps/control-station/test/roverInput.test.mjs new file mode 100644 index 0000000..f1d41f0 --- /dev/null +++ b/apps/control-station/test/roverInput.test.mjs @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {build} from 'esbuild'; +const result=await build({entryPoints:[new URL('../src/core/fleet/roverInput.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'}); +const {keyDemand,roverSettings}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); +const keys=(...k)=>new Set(k.map(v=>'Key'+v)); +test('arcade mixes directions and opposite keys cancel',()=>{ + assert.deepEqual(keyDemand('arcade',keys('W')),{left:1,right:1}); + assert.deepEqual(keyDemand('arcade',keys('A')),{left:-1,right:1}); + assert.deepEqual(keyDemand('arcade',keys('W','A')),{left:0,right:1}); + assert.deepEqual(keyDemand('arcade',keys('W','S','A','D')),{left:0,right:0}); +}); +test('tank sides and backwards remain independent',()=>{ + assert.deepEqual(keyDemand('tank',keys('Q','D')),{left:1,right:-1}); + assert.deepEqual(keyDemand('tank',keys('A','D')),{left:-1,right:-1}); + assert.deepEqual(keyDemand('tank',keys('Q','A')),{left:0,right:0}); +}); +test('invalid stored settings never grant motion or invalid limits',()=>{ + assert.equal(roverSettings({version:1,currentA:Infinity}).currentA,30); + assert.equal(roverSettings({version:1,maxErpm:999999}).maxErpm,3000); + assert.equal(roverSettings({version:1,model:'https://other/model.glb'}).model,''); + assert.deepEqual(keyDemand('arcade',new Set()),{left:0,right:0}); +}); diff --git a/apps/control-station/test/vescUi.test.mjs b/apps/control-station/test/vescUi.test.mjs new file mode 100644 index 0000000..b599a23 --- /dev/null +++ b/apps/control-station/test/vescUi.test.mjs @@ -0,0 +1,70 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import ts from 'typescript'; + +const source=readFileSync(new URL('../../../plugins/vesc/frontend/src/model.ts',import.meta.url),'utf8'); +const code=ts.transpileModule(source,{compilerOptions:{module:ts.ModuleKind.ESNext}}).outputText; +const {vescLabel,testInput,speedInput,rotationResult,driveTestIds}=await import('data:text/javascript;base64,'+Buffer.from(code).toString('base64')); +const controller={online:true,prepared:true,verified:true,vesc_status:{identity:{uuid:'synthetic'},readable:true}}; + +test('speed mode requires board capability and reports measured rotation time',()=>{ + assert.ok(speedInput('1200',undefined).error); + const bounds={min_erpm:300,max_erpm:3000,duration_basis:'measured_speed'}; + assert.equal(speedInput('1200',bounds).error,null); + for(const value of ['', 'NaN','299','3001'])assert.ok(speedInput(value,bounds).error); + const text=rotationResult({outcome:'stopped',rotation_s:2.5,release_confirmed:true,limits_restored:true}); + assert.match(text,/2,5 с/); + assert.match(text,/Проверка остановлена/); + assert.doesNotMatch(text,/время вращения набрано/); +}); + +test('USB discovery and model preparation never claim a verified VESC',()=>{ + assert.equal(vescLabel({...controller,prepared:false},true).label,'Требуется подготовка'); + assert.equal(vescLabel({...controller,vesc_status:{identity:null,readable:false}},true).tone,'warning'); +}); +test('fresh read capability is distinct from unsupported firmware and offline state',()=>{ + assert.equal(vescLabel(controller,true).label,'Готов к чтению'); + assert.equal(vescLabel(controller,false).label,'Нет связи'); + assert.equal(vescLabel({...controller,online:false},true).label,'Нет связи'); + assert.equal(vescLabel({...controller,vesc_status:{identity:{uuid:'synthetic'},readable:false}},true).label,'Прошивка не поддерживается'); +}); + +test('test fields accept entered values only inside the board capability bounds',()=>{ + const bounds={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10}; + assert.equal(testInput('3.7','7.2',bounds).valid,true); + assert.equal(testInput('5','10',bounds).valid,true); + for(const [amps,seconds] of [['','5'],['5',''],['60','10'],['5','11'],['NaN','1'],['Infinity','1']])assert.equal(testInput(amps,seconds,bounds).valid,false); + assert.equal(testInput('2','1.5',undefined).valid,false); + const overlong=testInput('5','15',bounds); + assert.equal(overlong.valid,false); + assert.equal(overlong.currentError,null); + assert.equal(overlong.durationError,'Длительность должна быть от 0,5 до 10 с.'); + assert.equal(testInput('5','10',bounds).durationError,null); +}); + +// Capabilities come from the installed board: a newer Core must retain old bounds. +test('extended board accepts 30 A / 30 s without changing older board bounds',()=>{ + const old={min_current_a:0.5,max_current_a:5,min_duration_s:0.5,max_duration_s:10}; + const expanded={...old,max_current_a:30,max_duration_s:30,continuous_current:true}; + assert.equal(testInput('30','30',expanded).valid,true); + assert.equal(testInput('30.1','30',expanded).valid,false); + assert.equal(testInput('30','30.1',expanded).valid,false); + assert.equal(testInput('30','30',old).valid,false); +}); + + +test('group spin requires a complete unique profile containing the selected controller',()=>{ + const profile={layout:'1x1',revision:3,bindings:{'left.1':{device_id:'left',uuid:'a'},'right.1':{device_id:'right',uuid:'b'}}}; + assert.deepEqual(driveTestIds(profile,'left'),['left','right']); + assert.deepEqual(driveTestIds(profile,'unassigned'),[]); + assert.deepEqual(driveTestIds({...profile,bindings:{'left.1':profile.bindings['left.1']}},'left'),[]); + assert.deepEqual(driveTestIds({...profile,bindings:{...profile.bindings,'right.1':profile.bindings['left.1']}},'left'),[]); + assert.deepEqual(driveTestIds(undefined,'left'),[]); +}); + +test('four-wheel profile preserves front and rear membership for common testing',()=>{ + const ids=['lf','lr','rf','rr']; + const bindings=Object.fromEntries(['left.1','left.2','right.1','right.2'].map((slot,i)=>[slot,{device_id:ids[i],uuid:ids[i]}])); + assert.deepEqual(driveTestIds({layout:'2x2',revision:4,bindings},'rf'),ids); +}); diff --git a/apps/control-station/tools/rover-control-preview/build.mjs b/apps/control-station/tools/rover-control-preview/build.mjs new file mode 100644 index 0000000..aa32ec2 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/build.mjs @@ -0,0 +1,18 @@ +import {build} from 'esbuild'; +import {mkdir,writeFile} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import {resolve,join} from 'node:path'; + +const root=fileURLToPath(new URL('.',import.meta.url)); +const destination=process.argv[2]; +if(!destination)throw Error('Pass an artifact output directory; no server is started.'); +const result=await build({entryPoints:[join(root,'main.tsx')],bundle:true,write:false,minify:true, + format:'iife',platform:'browser',jsx:'automatic',outfile:'preview.js',define:{'process.env.NODE_ENV':'"production"'}, + alias:{'@nodedc/ui-react':resolve(root,'../../node_modules/@nodedc/ui-react/dist/index.js')}, + nodePaths:[resolve(root,'../../node_modules')],logLevel:'warning'}); +const js=result.outputFiles.find(f=>f.path.endsWith('.js')).text.replaceAll('f.path.endsWith('.css')).text.replaceAll('Mission Core · Профили управления · Прототип
    `; +await writeFile(join(destination,'rover-control-preview.html'),html); +console.log(JSON.stringify({artifact:join(destination,'rover-control-preview.html'),bytes:Buffer.byteLength(html),network:'disabled by CSP',hardware:'no adapter'})); diff --git a/apps/control-station/tools/rover-control-preview/main.tsx b/apps/control-station/tools/rover-control-preview/main.tsx new file mode 100644 index 0000000..ab36fa7 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/main.tsx @@ -0,0 +1,69 @@ +import {useState} from 'react'; +import {createRoot} from 'react-dom/client'; +import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react'; +import '@nodedc/ui-core/styles.css'; +import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile'; +import {trace} from './trace'; +import './preview.css'; + +const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0}; +const percent=(n:number)=>`${Math.round(n*100)}%`; +const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'}; +const storageKey='missioncore.rover-control-preview.v1'; +function load():ControlProfile { + try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};} + catch{return {...defaultProfile};} +} +function App(){ + const [profile,setProfile]=useState(load); + const [axes,setAxes]=useState(emptyAxes); + const [open,setOpen]=useState(['settings','preview']); + const [saved,setSaved]=useState(''); + const [light,setLight]=useState(false); + const change=(patch:Partial)=>{ + setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved(''); + }; + const result=mix(profile,axes); + const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY'; + const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX'; + const field=(key:keyof Axes,label:string)=>setAxes({...axes,[key]:value})}/>; + function save(){ + try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');} + catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');} + } + function download(){ + const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'})); + const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click(); + setTimeout(()=>URL.revokeObjectURL(url),1000); + } + return
    +

    MISSION CORE · ПРОТОТИП

    Настройки управления ровером

    +

    Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.

    + + change({mode})}/> + {profile.mode==='arcade'&&change({stick})}/>} +

    {profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}

    + change({response})}/> + change({deadband})}/> + change({outputScale})}/> +

    Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.

    +
    + {saved&&

    {saved}

    } + }, + {id:'preview',label:'Проверка профиля на экране',content: + {field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')} + {field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')} +
    +
    {(['left','right'] as const).map(side=>
    {side==='left'?'Левая сторона':'Правая сторона'}{percent(result[side])}{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}
    )}
    +

    Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.

    +
    }, + {id:'takeover',label:'Перехват пультом · проверка сценария',content: +
    {trace(profile).map(({label,decision},i)=>
    {i+1}. {label}{states[decision.state]}{percent(decision.demand.left)} / {percent(decision.demand.right)}
    )}
    +

    Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.

    +
    }, + ]}/> +
    ; +} +document.documentElement.dataset.nodedcTheme='dark'; +createRoot(document.getElementById('root')!).render(); diff --git a/apps/control-station/tools/rover-control-preview/preview.css b/apps/control-station/tools/rover-control-preview/preview.css new file mode 100644 index 0000000..6a26ea5 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/preview.css @@ -0,0 +1,16 @@ +/* Domain composition only; all controls come from the Design Guideline. */ +body { margin:0; background:var(--nodedc-canvas); color:var(--nodedc-text-primary); font-family:var(--nodedc-font-family); font-size:var(--nodedc-font-size-md); } +main { max-width:960px; margin:0 auto; padding:32px 24px 64px; } +main>header { display:flex; align-items:center; justify-content:space-between; gap:24px; margin-bottom:24px; } +h1 { font-size:var(--nodedc-font-size-title); font-weight:600; margin:8px 0; } +p { font-size:var(--nodedc-font-size-sm); line-height:1.6; color:var(--nodedc-text-secondary); } +main>header p { font-size:var(--nodedc-font-size-xs); } +.notice { margin-bottom:24px; } +.actions { display:flex; flex-wrap:wrap; gap:12px; margin:16px 0; } +.demands { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:24px; margin:24px 0; } +.demands>div { display:flex; flex-direction:column; gap:8px; } +.demands strong { font-size:var(--nodedc-font-size-lg); font-variant-numeric:tabular-nums; } +.demands span { font-size:var(--nodedc-font-size-sm); } +.trace { display:grid; gap:20px; } +.trace-row { display:grid; grid-template-columns:minmax(0,1fr) 250px 100px; align-items:center; gap:16px; font-size:var(--nodedc-font-size-sm); } +@media (max-width:720px) { main {padding:20px 12px 40px;} .trace-row {grid-template-columns:1fr;} } diff --git a/apps/control-station/tools/rover-control-preview/trace.ts b/apps/control-station/tools/rover-control-preview/trace.ts new file mode 100644 index 0000000..ee8765b --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/trace.ts @@ -0,0 +1,38 @@ +import {AuthorityModel, type Decision} from '../../../../packages/rover-control/src/authority'; +import {type ControlProfile} from '../../../../packages/rover-control/src/profile'; + +/** Synthetic event replay, with no wall clock, I/O, timers or hardware adapter. */ +export function trace(profile: ControlProfile): {label: string; decision: Decision}[] { + const model = new AuthorityModel(profile, ['left', 'right'], { + maxAgeMs:100, neutralMs:200, maxCommandMs:100, monitoredAxes:['leftY','rightY','leftX','rightX'], + }, 'preview'); + const rows: {label:string;decision:Decision}[] = []; + let now = -50, token = ''; + function step(value=0, link: 'live'|'lost'='live', core=false, request=false) { + now+=50; + const axes = {leftY:0,rightY:0,leftX:0,rightX:0}; + axes[profile.mode==='tank'?'leftY':profile.stick==='left'?'leftY':'rightY']=value; + return model.step({now, link:{state:link,at:now}, + axes:Object.fromEntries(Object.entries(axes).map(([key,v])=>[key,{value:v,at:now,sequence:now}])), + drives:{left:{healthy:true,stopped:value===0,at:now},right:{healthy:true,stopped:value===0,at:now}}, + requestCore:request?{owner:'autonomy',at:now,sequence:1}:undefined, + command:core?{token,at:now,expires:now+75,sequence:now,demand:{left:.4,right:.4}}:undefined, + }); + } + const add=(label:string,decision:Decision)=>rows.push({label,decision}); + for(let i=0;i<5;i++)step(); + const grant=step(0,'live',false,true);token=grant.token??''; + add('Mission Core получил явный допуск',grant); + add('Автономная команда движения',step(0,'live',true)); + add('Первое отклонение рычага',step(.8,'live',true)); + for(let i=0;i<20;i++)step(.8); + add('Тот же рычаг удерживается',step(.8)); + add('Остановка подтверждена, оба рычага отпущены',step()); + for(let i=0;i<3;i++)step(); + add('Непрерывная нейтраль подтверждена',step()); + add('Второе отклонение — ручное движение',step(.8)); + add('Запоздавшая команда прежней задачи',step(.8,'live',true)); + add('Радиосвязь потеряна',step(0,'lost')); + add('Связь вернулась с отклонённым рычагом',step(.8)); + return rows; +} diff --git a/apps/control-station/tools/rover-control-preview/tsconfig.json b/apps/control-station/tools/rover-control-preview/tsconfig.json new file mode 100644 index 0000000..f19d0c0 --- /dev/null +++ b/apps/control-station/tools/rover-control-preview/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends":"../../tsconfig.app.json", + "compilerOptions":{"incremental":false,"tsBuildInfoFile":null}, + "include":["*.tsx","*.ts","../../../../packages/rover-control/src/*.ts"] +} diff --git a/tools/rover-scene/export_rover.py b/tools/rover-scene/export_rover.py new file mode 100644 index 0000000..401ef69 --- /dev/null +++ b/tools/rover-scene/export_rover.py @@ -0,0 +1,49 @@ +"""Immutable-source evaluated geometry export, owner-selected complete v020.""" +import bpy,json,hashlib,struct +from pathlib import Path +from mathutils import Vector,Matrix +root=Path.cwd();source=Path(bpy.data.filepath) +donor=root.parent/'NODEDC_ENGINE_INFRA/nodedc-source/public/3dassetnode/model.glb' +with donor.open('rb') as f: + f.read(12);n,t=struct.unpack('