Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd5873ef34 | ||
|
|
aecae4a986 | ||
|
|
ee7576b7b4 | ||
|
|
e2b0d839eb | ||
|
|
d2ec9e95b1 | ||
|
|
8283f9ba85 | ||
|
|
db3b1d7b67 | ||
|
|
a993eda6b8 |
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -152,6 +152,7 @@ export default function App() {
|
||||
contentExpanded: true,
|
||||
});
|
||||
|
||||
const [navigationRevision, setNavigationRevision] = useState(0);
|
||||
const [activeRoot, setActiveRoot] = useState<RootId | null>(
|
||||
polygonDatasetRoute.active ? "data" : null,
|
||||
);
|
||||
@@ -159,6 +160,7 @@ export default function App() {
|
||||
const [launchProfile, setLaunchProfile] = useState<WorkspaceLaunchProfile>('direct');
|
||||
const [sourceUrl, setSourceUrl] = useState("");
|
||||
const [workspaceHeaderToolsHost, setWorkspaceHeaderToolsHost] = useState<HTMLDivElement | null>(null);
|
||||
const [workspaceHeaderTitleHost, setWorkspaceHeaderTitleHost] = useState<HTMLSpanElement | null>(null);
|
||||
const [recordedReplay, setRecordedReplay] = useState<ObservationSessionReplayLaunch | null>(null);
|
||||
const [recordedReplayLabel, setRecordedReplayLabel] = useState<string | null>(null);
|
||||
const [replayTransitioning, setReplayTransitioning] = useState(false);
|
||||
@@ -457,6 +459,17 @@ export default function App() {
|
||||
workspace.openView(viewId);
|
||||
};
|
||||
|
||||
// Explicit menu navigation always returns to that workspace's entry screen.
|
||||
const openNavigationView = (viewId: string) => {
|
||||
if (!workspaceById(viewId)) return;
|
||||
setNavigationRevision(value => value + 1);
|
||||
sessionOverview.close();
|
||||
setSourceWindowOpen(false);
|
||||
setDisplayWindowOpen(false);
|
||||
setLayerInspectorOpen(false);
|
||||
openView(viewId);
|
||||
};
|
||||
|
||||
const openSource = () => {
|
||||
if (!sceneWorkspaceActive) return;
|
||||
setSourceDraft(sourceUrl);
|
||||
@@ -645,11 +658,8 @@ export default function App() {
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [layoutSaveNotice]);
|
||||
|
||||
const [fleetCreateRequest, setFleetCreateRequest] = useState(0);
|
||||
const sessionOverview = useSessionOverviewMode(activeDefinition?.kind === "recordings");
|
||||
const onAddVehicle = useCallback(() => setFleetCreateRequest(value => value + 1), []);
|
||||
const contentActions = useApplicationPanelActions({
|
||||
onAddVehicle,
|
||||
definition: activeDefinition,
|
||||
refreshRuntime: runtime.refresh,
|
||||
resetConnectionScenario: runtime.resetConnectionScenario,
|
||||
@@ -729,6 +739,7 @@ export default function App() {
|
||||
<SystemNavigationPanel
|
||||
title={currentRoot.title}
|
||||
onAdd={computeContourSettings.openCreate}
|
||||
onNavigateRoot={() => openNavigationView("modules")}
|
||||
onClose={workspace.closeNavigation}
|
||||
/>
|
||||
) : currentRoot ? (
|
||||
@@ -755,7 +766,7 @@ export default function App() {
|
||||
icon: <Icon name={item.icon} />,
|
||||
}))}
|
||||
activeId={workspace.activeView ?? undefined}
|
||||
onItemChange={openView}
|
||||
onItemChange={openNavigationView}
|
||||
footer={
|
||||
<>
|
||||
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true">
|
||||
@@ -765,7 +776,8 @@ export default function App() {
|
||||
{rootWorkspaces.length}{" "}
|
||||
{rootWorkspaces.length === 1
|
||||
? "рабочая поверхность"
|
||||
: "рабочих поверхностей"}
|
||||
: rootWorkspaces.length >= 2 && rootWorkspaces.length <= 4
|
||||
? "рабочие поверхности" : "рабочих поверхностей"}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
@@ -776,7 +788,9 @@ export default function App() {
|
||||
key={activeDefinition.id}
|
||||
eyebrow={activeDefinition.eyebrow}
|
||||
title={
|
||||
activeDefinition.kind === "recordings"
|
||||
activeDefinition.kind === "vehicles"
|
||||
? <>{activeDefinition.title}<span className="fleet-header-title" ref={setWorkspaceHeaderTitleHost}/></>
|
||||
: activeDefinition.kind === "recordings"
|
||||
&& replayPresented
|
||||
&& recordedReplayLabel
|
||||
? `${activeDefinition.title}: ${recordedReplayLabel}`
|
||||
@@ -822,16 +836,16 @@ export default function App() {
|
||||
/>
|
||||
) : activeDefinition.kind === "missions" ? (
|
||||
<div ref={setWorkspaceHeaderToolsHost} />
|
||||
) : activeDefinition.kind === "vehicles" ? <div ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||
) : activeDefinition.kind === "vehicles" ? <div className="fleet-header-tools" ref={setWorkspaceHeaderToolsHost} /> : activeDefinition.kind === "simulations" ? null : activeDefinition.kind === "datasets" ? (
|
||||
<StatusBadge tone="neutral">Offline evaluation</StatusBadge>
|
||||
) : activeDefinition.kind === "lab-archive" ? (
|
||||
laboratoryAnnotation.control
|
||||
) : activeDefinition.kind === "observatory" ? (
|
||||
<StatusBadge tone="neutral">Только наблюдение</StatusBadge>
|
||||
) : activeDefinition.root === "system" ? (
|
||||
) : activeDefinition.kind === "contour-health" ? null : activeDefinition.root === "system" ? (
|
||||
<SystemWorkspaceSelector
|
||||
value={activeDefinition.id}
|
||||
onChange={openView}
|
||||
onChange={openNavigationView}
|
||||
/>
|
||||
) : (
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
@@ -840,7 +854,7 @@ export default function App() {
|
||||
utilityActions={contentActions}
|
||||
onClose={workspace.closeView}
|
||||
>
|
||||
<PlanningCaptureGuard
|
||||
<PlanningCaptureGuard key={`${activeDefinition.id}:${navigationRevision}`}
|
||||
enabled={launchProfile === 'direct' && (activeDefinition.kind === 'device' || activeDefinition.kind === 'spatial')}
|
||||
onResume={() => openView('local-device', 'planning')}
|
||||
>
|
||||
@@ -860,8 +874,8 @@ export default function App() {
|
||||
<WorkspaceRenderer
|
||||
launchProfile={launchProfile}
|
||||
definition={activeDefinition}
|
||||
fleetCreateRequest={fleetCreateRequest}
|
||||
headerToolsHost={workspaceHeaderToolsHost}
|
||||
headerTitleHost={workspaceHeaderTitleHost}
|
||||
state={activeRuntimeState}
|
||||
backendStatus={runtime.backendStatus}
|
||||
sourceUrl={effectiveSourceUrl}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import {createPortal} from 'react-dom';
|
||||
import {Icon,IconButton} from '@nodedc/ui-react';
|
||||
|
||||
/** A nested workspace returns through the shared panel header. */
|
||||
export function WorkspaceBackButton({label,onClick,host}:{label:string;onClick:()=>void;host?:HTMLElement|null}) {
|
||||
const button=<IconButton label={label} onClick={onClick}><Icon name="chevron-left"/></IconButton>;
|
||||
return host?createPortal(button,host):button;
|
||||
}
|
||||
@@ -6,40 +6,41 @@ 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 Scene=lazy(()=>import('./editor/RoverScene'));
|
||||
const labels:Record<string,string>={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<Set<string>>(new Set());
|
||||
const [editing,setEditing]=useState(false);
|
||||
const [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
|
||||
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|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;
|
||||
if(!c.ready||!active||open||editing||!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);};
|
||||
},[c.ready,active,open,editing,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
|
||||
const showSettings=()=>{setEditing(false);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(<IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton>,header.actionsTarget)}
|
||||
{createPortal(<><IconButton label={editing?"Завершить редактирование сцены":"Редактировать сцену"} aria-pressed={editing} onClick={()=>{c.pauseInput();setHeld(new Set());setEditing(v=>!v);}}><Icon name="edit"/></IconButton><IconButton label="Настройки управления ровером" onClick={showSettings}><Icon name="settings"/></IconButton></>,header.actionsTarget)}
|
||||
{createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
|
||||
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером"
|
||||
onBlur={e=>{if(c.armed&&!e.currentTarget.contains(e.relatedTarget as Node|null))c.pauseInput();}}>
|
||||
{settings.model==='dcd006-v020'?<LoadingRegion className="rover-view__scene" loading={modelState==='loading'} label="Загрузка модели ровера">
|
||||
<Suspense fallback={null}><Scene modelUrl="/rover-scene/dcd006-v020.glb" cameraAutoRotate={false} onModelState={setModelState}/></Suspense>
|
||||
<Suspense fallback={null}><Scene key={vehicleID} vehicleID={vehicleID} editing={editing&&active} onModelState={setModelState}/></Suspense>
|
||||
{modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
|
||||
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
|
||||
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div>
|
||||
{c.ready&&<div className="rover-view__controls">
|
||||
<div className="rover-view__status"><StatusBadge tone={editing?'neutral':tone}>{editing?'Редактирование сцены':status}</StatusBadge></div>
|
||||
{c.ready&&!editing&&<div className="rover-view__controls">
|
||||
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}>
|
||||
{keysFor(settings.mode).map(code=><KeyButton key={code} className={`rover-key-${code}`} label={settings.mode==='arcade'?({KeyW:'Вперёд · W',KeyS:'Назад · S',KeyA:'Налево · A',KeyD:'Направо · D'}[code]??code):({KeyQ:'Левая сторона вперёд · Q',KeyA:'Левая сторона назад · A',KeyE:'Правая сторона вперёд · E',KeyD:'Правая сторона назад · D'}[code]??code)}
|
||||
disabled={!c.ready||!active} pressed={held.has(code)}
|
||||
@@ -50,9 +51,9 @@ export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:stri
|
||||
</div>
|
||||
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
|
||||
</div>}
|
||||
<div className="rover-view__authority">
|
||||
{!editing&&<div className="rover-view__authority">
|
||||
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings">
|
||||
<Select label="Тип управления" value={settings.mode} options={[{value:'arcade',label:'Аркадный · W A S D'},{value:'tank',label:'Танковый · Q A / E D'}]} onChange={mode=>setSettings(v=>({...v,mode:mode as RoverMode}))}/>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import {LutField} from './LutField';
|
||||
import {useRef} from 'react';
|
||||
import {Button,ColorField,ControlRow,Inspector,InspectorSelectField,RangeControl,Switch,TextField} from '@nodedc/ui-react';
|
||||
import {environmentFields,fieldValue,fieldDisabled,type SceneDocument,type SceneField} from '../../../core/roverScene/sceneDocument';
|
||||
const labels:Record<string,string>={'Камера':'Камера',Lighting:'Освещение',Rendering:'Рендеринг',Grid:'Сетка',SSAO:'Контактное затенение · SSAO',Bloom:'Свечение · Bloom','Chromatic Aberration':'Хроматическая аберрация',TAA:'Временное сглаживание · TAA',Grading:'Цветокоррекция',LUT:'Цветовая таблица · LUT',Vignette:'Виньетка','Environment Atlas':'Карта окружения','Skybox':'Небо','Directional Light':'Направленный свет','Shadow Catcher':'Тень на поверхности'};
|
||||
export function EnvironmentInspector({document,onChange,onResetCamera,onError}:{document:SceneDocument;onChange:(key:string,value:unknown)=>void;onResetCamera:()=>void;onError:(error:string)=>void}){
|
||||
const groups=[...new Set(environmentFields.map(f=>f.group))];
|
||||
return <Inspector defaultOpen={['Камера','PostProcess/Lighting']} sections={groups.map(group=>({id:group,label:labels[group.split('/').at(-1)!]??group,content:<div className="rover-scene-fields">{environmentFields.filter(f=>f.group===group).map(f=><EnvironmentField key={f.key} field={f} value={fieldValue(document,f.key)} disabled={fieldDisabled(document,f)} onChange={v=>onChange(f.key,v)} onResetCamera={onResetCamera} onError={onError}/>)}</div>}))}/>;
|
||||
}
|
||||
function EnvironmentField({field:f,value,disabled,onChange,onResetCamera,onError}:{field:SceneField;value:unknown;disabled:boolean;onChange:(v:unknown)=>void;onResetCamera:()=>void;onError:(error:string)=>void}){
|
||||
if(f.type==='texture')return <LutField value={value as string|null} onChange={onChange} onError={onError}/>;
|
||||
if(f.type==='action')return <Button onClick={onResetCamera}>Сбросить камеру</Button>;
|
||||
if(f.type==='toggle')return <Switch label={f.label} checked={!!value} disabled={disabled} onChange={onChange}/>;
|
||||
if(f.type==='color')return <ControlRow label={f.label}><ColorField label={f.label} value={String(value)} disabled={disabled} onChange={onChange}/></ControlRow>;
|
||||
if(f.type==='select')return <InspectorSelectField label={f.label} value={String(value)} options={f.options??[]} disabled={disabled} onChange={onChange}/>;
|
||||
if(f.type==='doubleSlider'){const range=value as number[];return <>{['Начало тумана, м','Конец тумана, м'].map((label,i)=><RangeControl key={i} label={label} min={f.min??0} max={f.max??100} step={f.step??1} exactValueBounds={{min:f.min,max:f.max}} value={range[i]} disabled={disabled} onChange={v=>onChange(i?[range[0],v]:[v,range[1]])}/>)}</>;}
|
||||
if(f.type==='number')return <TextField type="number" label={f.label} min={f.min} max={f.max} step={f.step??.01} value={Number(value)} disabled={disabled} onChange={e=>onChange(Number(e.target.value))}/>;
|
||||
return <RangeControl label={f.label} value={Number(value)} min={f.min??0} max={f.max??100} step={f.step??.01} exactValueBounds={{min:f.min,max:f.max}} disabled={disabled} onChange={onChange}/>;
|
||||
}
|
||||
export function SceneFileActions({onImport,onExport,onReset}:{onImport:(file:File)=>void;onExport:()=>void;onReset:()=>void}){
|
||||
const input=useRef<HTMLInputElement>(null);
|
||||
return <div className="rover-scene-actions"><Button size="compact" onClick={onExport}>Экспорт JSON</Button><Button size="compact" onClick={()=>input.current?.click()}>Импорт JSON</Button><Button size="compact" onClick={onReset}>Окружение NodeDC</Button><input ref={input} hidden type="file" accept=".json,application/json" onChange={e=>{const f=e.currentTarget.files?.[0];if(f)onImport(f);e.currentTarget.value='';}}/></div>;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,Icon,IconButton} from '@nodedc/ui-react';
|
||||
import {textureFromFile} from '../../../core/roverScene/sceneStorage';
|
||||
export function LutField({value,onChange,onError}:{value:string|null|undefined;onChange:(value:string|null)=>void;onError:(error:string)=>void}){
|
||||
const input=useRef<HTMLInputElement>(null),[loading,setLoading]=useState(false);
|
||||
return <div className="rover-scene-fields"><p className="rover-scene-note">PNG 256 × 16 · горизонтальная таблица 16³</p><div className="rover-scene-actions"><Button loading={loading} onClick={()=>input.current?.click()}>{value?'Заменить LUT':'Загрузить LUT'}</Button>{value&&<IconButton label="Удалить LUT" onClick={()=>onChange(null)}><Icon name="trash"/></IconButton>}</div><input hidden ref={input} type="file" accept="image/png" onChange={async e=>{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setLoading(true);try{const bitmap=await createImageBitmap(file);const valid=bitmap.width===256&&bitmap.height===16;bitmap.close();if(!valid||file.type!=='image/png')throw new Error('Нужна PNG-таблица LUT размером 256 × 16.');onChange((await textureFromFile(file)).data);}catch(error){onError(error instanceof Error?error.message:'Не удалось прочитать LUT.');}finally{setLoading(false);}}}/></div>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useRef,useState} from 'react';
|
||||
import {Button,ColorField,ControlRow,Icon,IconButton,Inspector,RangeControl,Switch,TextField} from '@nodedc/ui-react';
|
||||
import {textureSlots,type TextureSlot,type MaterialSpec} from '../../../core/roverScene/sceneDocument';
|
||||
import {textureFromFile} from '../../../core/roverScene/sceneStorage';
|
||||
const slotLabels:Record<TextureSlot,string>={diffuse:'Базовый цвет',normal:'Нормали',roughness:'Шероховатость',metalness:'Металличность',opacity:'Прозрачность',emissive:'Свечение',ao:'Окклюзия · AO'};
|
||||
export function MaterialInspector({material,onChange,onReset,onError}:{material:MaterialSpec;onChange:(patch:Partial<MaterialSpec>)=>void;onReset:()=>void;onError:(message:string)=>void}){
|
||||
const range=(key:keyof MaterialSpec,label:string,max=1)=><RangeControl label={label} value={Number(material[key])} min={0} max={max} step={.01} exactValueBounds={{min:0,max}} onChange={v=>onChange({[key]:v})}/>;
|
||||
return <><TextField label="Название материала" value={material.name} maxLength={120} onChange={e=>onChange({name:e.target.value})}/><p className="rover-scene-note">Изменения применяются ко всем деталям с этим материалом.</p><Inspector defaultOpen={['surface']} sections={[
|
||||
{id:'surface',label:'Поверхность',content:<div className="rover-scene-fields"><ControlRow label="Базовый цвет"><ColorField label="Базовый цвет" value={material.color} onChange={color=>onChange({color})}/></ControlRow>{range('metalness','Металличность')}{range('roughness','Шероховатость')}{range('reflectivity','Отражение')}{range('opacity','Непрозрачность')}<Switch label="Двусторонняя поверхность" checked={material.doubleSided} onChange={doubleSided=>onChange({doubleSided})}/></div>},
|
||||
{id:'coat',label:'Лак и свечение',content:<div className="rover-scene-fields">{range('clearCoat','Слой лака')}{range('clearCoatRoughness','Шероховатость лака')}<ControlRow label="Цвет свечения"><ColorField label="Цвет свечения" value={material.emissive} onChange={emissive=>onChange({emissive})}/></ControlRow>{range('emissiveIntensity','Сила свечения',10)}</div>},
|
||||
{id:'textures',label:'Текстуры',description:'PNG / JPEG / WebP · до 4096 px',content:<div className="rover-scene-fields">{textureSlots.map(slot=><TextureField key={slot} slot={slot} value={material.textures[slot]} onChange={texture=>onChange({textures:{...material.textures,[slot]:texture}})} onError={onError}/>)}{range('normalStrength','Сила нормалей',2)}<RangeControl label="Повторение текстуры" min={.01} max={20} step={.01} exactValueBounds={{min:.01,max:20}} value={material.tiling} onChange={tiling=>onChange({tiling})}/></div>},
|
||||
]}/><Button onClick={onReset}>Исходный материал</Button></>;
|
||||
}
|
||||
function TextureField({slot,value,onChange,onError}:{slot:TextureSlot;value:MaterialSpec['textures'][TextureSlot];onChange:(value:MaterialSpec['textures'][TextureSlot])=>void;onError:(message:string)=>void}){
|
||||
const input=useRef<HTMLInputElement>(null),[pending,setPending]=useState(false);
|
||||
return <div className="rover-scene-texture"><ControlRow label={slotLabels[slot]} layout="stack"><div className="rover-scene-texture__row">{value&&<img src={value.data} alt=""/>}<Button size="compact" loading={pending} onClick={()=>input.current?.click()}>{value?.name??'Загрузить текстуру'}</Button>{value&&<IconButton label={`Удалить текстуру: ${slotLabels[slot]}`} onClick={()=>onChange(undefined)}><Icon name="trash"/></IconButton>}</div></ControlRow><input hidden ref={input} type="file" accept="image/png,image/jpeg,image/webp" onChange={async e=>{const file=e.currentTarget.files?.[0];e.currentTarget.value='';if(!file)return;setPending(true);try{onChange(await textureFromFile(file));}catch(error){onError(error instanceof Error?error.message:'Не удалось загрузить текстуру.');}finally{setPending(false);}}}/></div>;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {useCallback,useEffect,useState,type CSSProperties} from 'react';
|
||||
import {Button,GlassSurface,Icon,IconButton,LoadingRegion,StatusBadge,ToastStack,Window} from '@nodedc/ui-react';
|
||||
import {assignMaterial,createMaterial,changeField,defaultScene,textureBytes,type MaterialSpec} from '../../../core/roverScene/sceneDocument';
|
||||
import {useSceneDocument} from '../../../core/roverScene/useSceneDocument';
|
||||
import PlayCanvasViewer from '../playcanvas/PlayCanvasViewer';
|
||||
import {EnvironmentInspector,SceneFileActions} from './EnvironmentInspector';
|
||||
import {MaterialInspector} from './MaterialInspector';
|
||||
import './sceneEditor.css';
|
||||
export default function RoverScene({vehicleID,editing,onModelState}:{vehicleID:string;editing:boolean;onModelState:(state:'loading'|'ready'|'error')=>void}){
|
||||
const scene=useSceneDocument(vehicleID),[palette,setPalette]=useState(false),[environment,setEnvironment]=useState(false),[selected,setSelected]=useState<string|null>(null),[materialOpen,setMaterialOpen]=useState(false),[cameraReset,setCameraReset]=useState(0),[textureState,setTextureState]=useState<'loading'|'ready'|'error'>('ready');
|
||||
const [selectedGroup,setSelectedGroup]=useState<string|null>(null);
|
||||
const pick=useCallback((id:string,group?:string)=>{setSelected(id);if(group)setSelectedGroup(group);setMaterialOpen(true);},[]);
|
||||
const addMaterial=()=>{if(Object.keys(scene.document.materials).length>=128){scene.setError('В сцене может быть не больше 128 материалов.');return;}const id=`custom/${crypto.randomUUID()}`;scene.update(doc=>createMaterial(doc,id));pick(id);};
|
||||
const patchMaterial=(id:string,patch:Partial<MaterialSpec>)=>{const next={...scene.document,materials:{...scene.document.materials,[id]:{...scene.document.materials[id],...patch}}};if(textureBytes(next)>28_000_000){scene.setError('Суммарный объём текстур превышает 20 МБ. Удалите ненужную текстуру.');return;}scene.update(doc=>({...doc,materials:{...doc.materials,[id]:{...doc.materials[id],...patch}}}));};
|
||||
useEffect(()=>{if(textureState==='error')scene.setError('Не удалось применить текстуру к сцене. Проверьте файл и загрузите его заново.');},[textureState,scene.setError]);
|
||||
const saved=scene.saveState==='saved',status=textureState==='loading'?'Загрузка текстур':scene.saveState==='loading'?'Загрузка настроек сцены':scene.saveState==='saving'?'Сохранение сцены':scene.saveState==='error'?'Изменения не сохранены':'Сохранено на этом компьютере';
|
||||
const material=selected?scene.document.materials[selected]:null;
|
||||
return <>
|
||||
<LoadingRegion className="rover-view__scene" style={{bottom:editing&&palette?150:0}} loading={!scene.ready} label="Загрузка настроек сцены">
|
||||
{scene.ready&&<PlayCanvasViewer modelUrl="/rover-scene/dcd006-v020.glb?materials-v2" postFx={scene.document.postFx} cameraAutoRotate={scene.document.cameraAutoRotate} cameraZoomStrength={scene.document.cameraZoomStrength} cameraResetToken={cameraReset} materials={scene.document.materials} assignments={scene.document.assignments} editing={editing} onMaterialPick={pick} onModelState={onModelState} onTextureState={setTextureState}/>}
|
||||
</LoadingRegion>
|
||||
{editing&&<>
|
||||
<div className="rover-scene-tools"><GlassSurface className="rover-scene-tools__surface"><IconButton label="Материалы сцены" aria-pressed={palette} onClick={()=>setPalette(v=>!v)}><Icon name="layers"/></IconButton><IconButton label="Окружение сцены" aria-pressed={environment} onClick={()=>setEnvironment(v=>!v)}><Icon name="globe"/></IconButton></GlassSurface></div>
|
||||
<div className="rover-scene-save"><StatusBadge tone={saved&&textureState!=='loading'?'neutral':'warning'}>{status}</StatusBadge></div>
|
||||
{palette&&<GlassSurface className="rover-material-palette"><div className="rover-material-palette__header"><span>Материалы · выберите в палитре или на модели</span><IconButton label="Скрыть палитру материалов" onClick={()=>setPalette(false)}><Icon name="close"/></IconButton></div><div className="rover-material-palette__items">{Object.entries(scene.document.materials).map(([id,m])=><Button key={id} variant={selected===id?'primary':'secondary'} tone="neutral" aria-pressed={selected===id} title={m.name} onClick={()=>pick(id)}><span className="rover-material-tile"><span aria-hidden="true" className="rover-material-swatch" style={{'--material-color':m.color,'--material-gloss':1-m.roughness} as CSSProperties}/><span>{m.name}</span></span></Button>)}<Button aria-label="Добавить материал" title="Добавить материал" onClick={addMaterial}><span className="rover-material-tile"><Icon name="plus"/><span>Новый материал</span></span></Button></div></GlassSurface>}
|
||||
</>}
|
||||
<Window open={editing&&environment} title="Окружение сцены" placement="end" draggable onClose={()=>setEnvironment(false)} footer={<SceneFileActions onImport={scene.importDocument} onExport={scene.exportDocument} onReset={()=>scene.update(doc=>({...doc,postFx:defaultScene().postFx}))}/>}>
|
||||
<EnvironmentInspector onError={scene.setError} document={scene.document} onChange={(key,value)=>scene.update(doc=>changeField(doc,key,value))} onResetCamera={()=>setCameraReset(v=>v+1)}/>
|
||||
</Window>
|
||||
<Window open={editing&&materialOpen&&!!material} title={material?.name||'Материал'} subtitle="Материал сцены" placement="end" draggable style={{position:'fixed',left:'var(--nodedc-space-4)',top:'6rem',right:'auto'}} onClose={()=>setMaterialOpen(false)}>
|
||||
{material&&selected&&<>
|
||||
{selectedGroup?<div className="rover-scene-fields"><p className="rover-scene-note">Выбранная группа: {defaultScene().materials[selectedGroup]?.name}</p><Button disabled={(scene.document.assignments[selectedGroup]??selectedGroup)===selected} onClick={()=>scene.update(doc=>assignMaterial(doc,selectedGroup,selected))}>Назначить выбранной группе</Button></div>:<p className="rover-scene-note">Нажмите на деталь в сцене, затем выберите материал в палитре, чтобы назначить его группе.</p>}
|
||||
<MaterialInspector key={selected} material={material} onChange={patch=>patchMaterial(selected,patch)} onReset={()=>{const base=defaultScene().materials[selected]??createMaterial(defaultScene(),selected).materials[selected];if(base)patchMaterial(selected,{...base,name:material.name});}} onError={scene.setError}/>
|
||||
</>}
|
||||
</Window>
|
||||
<ToastStack items={scene.error?[{id:'rover-scene-error',tone:'error',title:'Настройки сцены',description:scene.error}]:[]} onDismiss={()=>scene.setError('')}/>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
.rover-scene-tools {position:absolute;right:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3));}
|
||||
.rover-scene-tools__surface,.rover-scene-actions {display:flex;align-items:center;gap:var(--nodedc-space-2);flex-wrap:wrap;}
|
||||
.rover-scene-tools__surface {padding:var(--nodedc-space-1);}
|
||||
.rover-scene-save {position:absolute;left:var(--nodedc-space-3);top:calc(var(--observation-overlay-header-space, 0px) + 3rem);pointer-events:none;}
|
||||
.rover-material-palette {position:absolute;bottom:var(--nodedc-space-3);left:var(--nodedc-space-3);right:var(--nodedc-space-3);padding:var(--nodedc-space-2);}
|
||||
.rover-material-palette__header {display:flex;align-items:center;justify-content:space-between;gap:var(--nodedc-space-2);font-size:var(--nodedc-font-size-xs);color:var(--nodedc-text-secondary);}
|
||||
.rover-material-palette__items {display:flex;gap:var(--nodedc-space-2);overflow:auto;}
|
||||
.rover-material-palette__items > * {flex:0 0 auto;}
|
||||
.rover-material-tile {display:grid;justify-items:center;gap:var(--nodedc-space-1);width:100px;white-space:normal;font-size:var(--nodedc-font-size-xs);}
|
||||
/* Domain material thumbnail: a swatch, not a replacement control. */
|
||||
.rover-material-swatch {display:block;width:38px;height:38px;border-radius:50%;background:radial-gradient(ellipse at 35% 25%,#ffffffb0,transparent 45%),radial-gradient(ellipse at 60% 75%,transparent,var(--material-color) 70%),var(--material-color);box-shadow:inset -5px -5px 12px #0008;}
|
||||
.rover-scene-fields {display:flex;flex-direction:column;gap:var(--nodedc-space-3);}
|
||||
.rover-scene-note {font-size:var(--nodedc-font-size-sm);color:var(--nodedc-text-secondary);}
|
||||
.rover-scene-texture__row {display:flex;gap:var(--nodedc-space-2);align-items:center;min-width:0;}
|
||||
.rover-scene-texture__row img {width:36px;height:36px;object-fit:cover;}
|
||||
.rover-scene-texture__row .nodedc-button {min-width:0;overflow:hidden;}
|
||||
@container(max-width:460px){.rover-scene-save{top:calc(var(--observation-overlay-header-space, 0px) + 4.5rem)}.rover-material-tile{width:82px}.rover-material-palette__header>span{max-width:210px}}
|
||||
@@ -11,6 +11,13 @@
|
||||
"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"
|
||||
]
|
||||
"Frame the complete rover using the donor 28-degree FOV and current viewport aspect",
|
||||
"Scene editing uses canonical modeless Design Guideline inspectors; all 109 donor camera/post-FX fields retained as a versioned field catalog",
|
||||
"Original NodeDC environment preset restored by owner request; editor reset changes environment only",
|
||||
"Semantic material groups, UV texture editing, GPU surface picking and bounded texture disposal",
|
||||
"Live grid step/diameter and ambient fill added; wireframe application and LUT loading corrected",
|
||||
"Responsive scene framing and palette space without drive authority"
|
||||
],
|
||||
"inspector_source_sha256": "8f1df16bd8c12fb1b8481480690ff4ed0e6b2645cb3fba527888c0f9acb59192",
|
||||
"model_revision": "dcd006-v020-materials-v2"
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@ import * as pc from 'playcanvas'
|
||||
import { buildSceneTree } from './sceneTree'
|
||||
import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx'
|
||||
|
||||
import {useLutTexture} from './useLutTexture'
|
||||
import {useSceneMaterials,type SceneMaterialsProps} from './useSceneMaterials'
|
||||
|
||||
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 = {
|
||||
export type PlayCanvasViewerProps = SceneMaterialsProps & {
|
||||
modelUrl: string | null
|
||||
postFx?: Partial<PostFxSettings> | null
|
||||
viewportSize?: { width: number; height: number }
|
||||
@@ -390,16 +393,9 @@ function toColor(value: ColorInput, fallback?: pc.Color): pc.Color {
|
||||
}
|
||||
|
||||
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?.()
|
||||
}
|
||||
}
|
||||
const root = app.root.findByName('ModelRoot') as pc.Entity | null
|
||||
for (const mi of collectMeshInstances(root)) {
|
||||
mi.renderStyle = enabled ? pc.RENDERSTYLE_WIREFRAME : pc.RENDERSTYLE_SOLID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -805,7 +801,7 @@ function applyPostFx(
|
||||
}
|
||||
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.sceneColorMap = !!fx.rendering.sceneColorMap
|
||||
frame.rendering.sceneDepthMap = !!fx.rendering.sceneDepthMap
|
||||
frame.rendering.toneMapping = fx.rendering.toneMapping
|
||||
frame.rendering.sharpness = fx.rendering.sharpness
|
||||
@@ -882,6 +878,7 @@ function applyPostFx(
|
||||
|
||||
export default function PlayCanvasViewer({
|
||||
modelUrl,
|
||||
materials, assignments, editing, onMaterialPick, onTextureState,
|
||||
postFx,
|
||||
viewportSize,
|
||||
onSceneReady,
|
||||
@@ -928,7 +925,11 @@ export default function PlayCanvasViewer({
|
||||
const [skyboxVersion, setSkyboxVersion] = useState(0)
|
||||
const [modelVersion, setModelVersion] = useState(0)
|
||||
|
||||
useSceneMaterials(appRef,cameraEntityRef,canvasRef,modelVersion,{materials,assignments,editing,onMaterialPick,onTextureState})
|
||||
|
||||
const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx])
|
||||
const lutError = useCallback(()=>onTextureState?.('error'),[onTextureState])
|
||||
useLutTexture(appRef,cameraFrameRef,lutTextureRef,fx.lut.textureUrl,appReady,lutError)
|
||||
|
||||
useEffect(() => {
|
||||
cameraStateRef.current = cameraState ?? null
|
||||
@@ -962,7 +963,7 @@ export default function PlayCanvasViewer({
|
||||
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))
|
||||
orbit.setDistance(Math.max(1.8, bounds.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 0.88))
|
||||
}
|
||||
}, [])
|
||||
|
||||
@@ -1112,6 +1113,8 @@ export default function PlayCanvasViewer({
|
||||
`,
|
||||
fragmentGLSL: `
|
||||
uniform vec2 uHalfExtents;
|
||||
uniform float uGridStep;
|
||||
uniform float uGridRadius;
|
||||
uniform vec3 uColorX;
|
||||
uniform vec3 uColorZ;
|
||||
uniform vec3 uColorMain;
|
||||
@@ -1182,7 +1185,9 @@ export default function PlayCanvasViewer({
|
||||
void main(void) {
|
||||
vec2 uv = uv0;
|
||||
|
||||
vec2 pos = (uv * 2.0 - 1.0) * uHalfExtents;
|
||||
vec2 worldPos = (uv * 2.0 - 1.0) * uHalfExtents;
|
||||
if (length(worldPos) > uGridRadius) discard;
|
||||
vec2 pos = worldPos / uGridStep;
|
||||
vec2 ddx = dFdx(pos);
|
||||
vec2 ddy = dFdy(pos);
|
||||
|
||||
@@ -1262,7 +1267,7 @@ export default function PlayCanvasViewer({
|
||||
float fadeEnd = max(0.0, uFadeEnd);
|
||||
float fade = 1.0;
|
||||
if (fadeEnd > fadeStart && fadeEnd > 0.0) {
|
||||
float d = length(pos);
|
||||
float d = length(worldPos);
|
||||
fade = 1.0 - smoothstep(fadeStart, fadeEnd, d);
|
||||
}
|
||||
outAlpha *= fade;
|
||||
@@ -1312,6 +1317,8 @@ export default function PlayCanvasViewer({
|
||||
gridMaterial.setParameter('uFadeStart', 0)
|
||||
gridMaterial.setParameter('uFadeEnd', 0)
|
||||
gridMaterial.setParameter('uResolution', 2)
|
||||
gridMaterial.setParameter('uGridStep', fx.grid.step)
|
||||
gridMaterial.setParameter('uGridRadius', fx.grid.diameter / 2)
|
||||
const gridHalfExtents = new pc.Vec2(500, 500)
|
||||
gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y])
|
||||
gridMaterial.update()
|
||||
@@ -1528,7 +1535,7 @@ export default function PlayCanvasViewer({
|
||||
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))
|
||||
orbit.setDistance(Math.max(1.8, aabb.halfExtents.length() / Math.sin(Math.min(vertical, horizontal)) * 0.88))
|
||||
}
|
||||
|
||||
if (exposeSceneGraph || onSceneGraph) {
|
||||
@@ -1617,7 +1624,7 @@ export default function PlayCanvasViewer({
|
||||
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),
|
||||
distance: Math.max(1.8,aabb.halfExtents.length() / Math.sin(Math.min(14*Math.PI/180,Math.atan(Math.tan(14*Math.PI/180)*(cameraEntityRef.current?.camera?.aspectRatio??1))))*.88),
|
||||
azimuthDeg: 45,
|
||||
elevationDeg: 10,
|
||||
})
|
||||
@@ -1638,6 +1645,9 @@ export default function PlayCanvasViewer({
|
||||
|
||||
const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled
|
||||
grid.enabled = gridVisible
|
||||
grid.setLocalScale(fx.grid.diameter,1,fx.grid.diameter)
|
||||
mat.setParameter('uGridStep',fx.grid.step)
|
||||
mat.setParameter('uGridRadius',fx.grid.diameter/2)
|
||||
const cX = toColor(fx.grid.colorX)
|
||||
const cZ = toColor(fx.grid.colorZ)
|
||||
const cMain = toColor(fx.grid.colorMain)
|
||||
@@ -1661,6 +1671,7 @@ export default function PlayCanvasViewer({
|
||||
mat.setParameter('uFadeEnd', Math.max(0, Number(fx.grid.fadeEnd) || 0))
|
||||
mat.update()
|
||||
}, [
|
||||
fx.grid.step,fx.grid.diameter,
|
||||
fx.grid.enabled,
|
||||
fx.grid.colorX,
|
||||
fx.grid.colorZ,
|
||||
@@ -1690,7 +1701,7 @@ export default function PlayCanvasViewer({
|
||||
const app = appRef.current
|
||||
if (!app) return
|
||||
setWireframeForScene(app, fx.rendering.wireframe)
|
||||
}, [fx.rendering.wireframe])
|
||||
}, [fx.rendering.wireframe,modelVersion])
|
||||
|
||||
useEffect(() => {
|
||||
const frame: any = cameraFrameRef.current
|
||||
@@ -1703,9 +1714,10 @@ export default function PlayCanvasViewer({
|
||||
const app = appRef.current
|
||||
if (!app) return
|
||||
if (fx.lighting) {
|
||||
app.scene.ambientLight = new pc.Color(fx.lighting.ambientIntensity,fx.lighting.ambientIntensity,fx.lighting.ambientIntensity)
|
||||
app.scene.exposure = fx.lighting.exposure
|
||||
}
|
||||
}, [fx.lighting?.exposure])
|
||||
}, [fx.lighting?.exposure,fx.lighting.ambientIntensity])
|
||||
|
||||
useEffect(() => {
|
||||
const app = appRef.current
|
||||
@@ -1924,6 +1936,8 @@ export default function PlayCanvasViewer({
|
||||
envAtlasVersion,
|
||||
skyboxVersion,
|
||||
modelVersion,
|
||||
materials,
|
||||
assignments,
|
||||
fx.envAtlas.enabled,
|
||||
fx.envAtlas.background,
|
||||
fx.envAtlas.reflection,
|
||||
|
||||
@@ -1,393 +1,2 @@
|
||||
|
||||
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<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | 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
|
||||
}
|
||||
// Shared serializable contract; donor defaults remain available.
|
||||
export * from '../../../core/roverScene/postFx';
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useEffect,type RefObject} from 'react';
|
||||
import * as pc from 'playcanvas';
|
||||
export function useLutTexture(appRef:RefObject<pc.Application|null>,frameRef:RefObject<any>,textureRef:RefObject<pc.Texture|null>,url:string|null|undefined,ready:boolean,onError?:()=>void){
|
||||
useEffect(()=>{
|
||||
const app=appRef.current;let cancelled=false;
|
||||
const clear=()=>{if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=null;frameRef.current.update?.();}textureRef.current?.destroy();textureRef.current=null;};
|
||||
clear();if(!app||!ready||!url)return;
|
||||
const img=new Image();img.onload=()=>{
|
||||
if(cancelled)return;
|
||||
if(img.width!==256||img.height!==16){onError?.();return;}
|
||||
const texture=new pc.Texture(app.graphicsDevice,{name:'Rover color LUT',format:pc.PIXELFORMAT_SRGBA8,mipmaps:false,minFilter:pc.FILTER_LINEAR,magFilter:pc.FILTER_LINEAR,addressU:pc.ADDRESS_CLAMP_TO_EDGE,addressV:pc.ADDRESS_CLAMP_TO_EDGE});
|
||||
texture.setSource(img);textureRef.current=texture;
|
||||
if(frameRef.current?.colorLUT){frameRef.current.colorLUT.texture=texture;frameRef.current.update?.();}
|
||||
};img.onerror=()=>{if(!cancelled)onError?.();};img.src=url;
|
||||
return()=>{cancelled=true;clear();};
|
||||
},[appRef,frameRef,textureRef,url,ready,onError]);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import {useEffect,useRef,type RefObject} from 'react';
|
||||
import * as pc from 'playcanvas';
|
||||
import type {MaterialSpec,TextureSlot} from '../../../core/roverScene/sceneDocument';
|
||||
export type SceneMaterialsProps={materials?:Record<string,MaterialSpec>;assignments?:Record<string,string>;editing?:boolean;onMaterialPick?:(id:string,group:string)=>void;onTextureState?:(state:'loading'|'ready'|'error')=>void};
|
||||
const maps:Record<TextureSlot,string>={diffuse:'diffuseMap',normal:'normalMap',metalness:'metalnessMap',roughness:'glossMap',opacity:'opacityMap',emissive:'emissiveMap',ao:'aoMap'};
|
||||
export function useSceneMaterials(appRef:RefObject<pc.Application|null>,cameraRef:RefObject<pc.Entity|null>,canvasRef:RefObject<HTMLCanvasElement|null>,modelVersion:number,{materials,assignments,editing,onMaterialPick,onTextureState}:SceneMaterialsProps){
|
||||
const latest=useRef({materials,onMaterialPick,onTextureState});latest.current={materials,onMaterialPick,onTextureState};
|
||||
const textures=useRef(new Map<string,{texture?:pc.Texture;promise:Promise<pc.Texture>}>());
|
||||
const generation=useRef(0);
|
||||
const originalGroups=useRef(new WeakMap<pc.MeshInstance,{id:string;material:pc.StandardMaterial}>());
|
||||
const customMaterials=useRef(new Map<string,pc.StandardMaterial>());
|
||||
useEffect(()=>{
|
||||
const app=appRef.current;if(!app||!modelVersion||!materials)return;
|
||||
const current=++generation.current;let pending=0,failed=false;
|
||||
const map=new Map<string,pc.StandardMaterial>();
|
||||
for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){
|
||||
if(!(mi.material instanceof pc.StandardMaterial))continue;
|
||||
if(!originalGroups.current.has(mi))originalGroups.current.set(mi,{id:mi.material.name,material:mi.material});
|
||||
const source=originalGroups.current.get(mi)!;map.set(source.id,source.material);
|
||||
}
|
||||
for(const id of Object.keys(materials))if(id.startsWith('custom/')){
|
||||
if(!customMaterials.current.has(id)){const m=new pc.StandardMaterial();m.name=id;customMaterials.current.set(id,m);}
|
||||
map.set(id,customMaterials.current.get(id)!);
|
||||
}
|
||||
for(const component of (app.root.findByName('ModelRoot') as pc.Entity|null)?.findComponents('render')??[])for(const mi of (component as pc.RenderComponent).meshInstances){
|
||||
const source=originalGroups.current.get(mi);if(source)mi.material=map.get(assignments?.[source.id]??source.id)??source.material;
|
||||
}
|
||||
for(const [id,m] of customMaterials.current)if(!Object.hasOwn(materials,id)){m.destroy();customMaterials.current.delete(id);}
|
||||
const applyTexture=(m:pc.StandardMaterial,slot:TextureSlot,texture:pc.Texture|null,spec:MaterialSpec)=>{
|
||||
(m as any)[maps[slot]]=texture;
|
||||
const prefix=maps[slot];(m as any)[prefix+'Tiling']=new pc.Vec2(spec.tiling,spec.tiling);
|
||||
if(['metalness','roughness','opacity','ao'].includes(slot))(m as any)[prefix+'Channel']='r';
|
||||
m.update();
|
||||
};
|
||||
const used=new Set<string>();
|
||||
for(const [id,spec] of Object.entries(materials)){
|
||||
const m=map.get(id);if(!m)continue;
|
||||
m.diffuse.fromString(spec.color);m.useMetalness=true;m.metalness=spec.metalness;
|
||||
m.glossInvert=true;m.gloss=spec.roughness;m.opacity=spec.opacity;
|
||||
// Specular factor is independent from the donor's global IBL multiplier.
|
||||
m.specularityFactor=spec.reflectivity;m.clearCoat=spec.clearCoat;
|
||||
m.clearCoatGlossInvert=true;m.clearCoatGloss=spec.clearCoatRoughness;
|
||||
m.emissive.fromString(spec.emissive);m.emissiveIntensity=spec.emissiveIntensity;
|
||||
m.bumpiness=spec.normalStrength;m.cull=spec.doubleSided?pc.CULLFACE_NONE:pc.CULLFACE_BACK;
|
||||
m.blendType=spec.opacity<1||spec.textures.opacity?pc.BLEND_NORMAL:pc.BLEND_NONE;
|
||||
m.depthWrite=m.blendType===pc.BLEND_NONE;
|
||||
for(const slot of Object.keys(maps) as TextureSlot[]){
|
||||
const data=spec.textures[slot]?.data;if(!data){applyTexture(m,slot,null,spec);continue;}
|
||||
const srgb=slot==='diffuse'||slot==='emissive';const key=`${srgb}:${data}`;used.add(key);
|
||||
let item=textures.current.get(key);
|
||||
if(!item){
|
||||
const promise=new Promise<pc.Texture>((resolve,reject)=>{const img=new Image();img.onload=()=>{if(generation.current<0){reject(new Error('Scene disposed'));return;}const tex=new pc.Texture(app.graphicsDevice,{name:spec.textures[slot]?.name,format:srgb?pc.PIXELFORMAT_SRGBA8:pc.PIXELFORMAT_RGBA8,mipmaps:true,addressU:pc.ADDRESS_REPEAT,addressV:pc.ADDRESS_REPEAT});tex.setSource(img);resolve(tex);};img.onerror=()=>reject(new Error('Texture decode failed'));img.src=data;});
|
||||
item={promise};textures.current.set(key,item);const cached=item;
|
||||
promise.then(tex=>{cached.texture=tex;if(textures.current.get(key)!==cached){tex.destroy();}}).catch(()=>{if(textures.current.get(key)===cached)textures.current.delete(key);});
|
||||
}
|
||||
if(item.texture)applyTexture(m,slot,item.texture,spec);
|
||||
else{
|
||||
pending++;item.promise.then(tex=>{if(generation.current===current)applyTexture(m,slot,tex,spec);}).catch(()=>{failed=true;}).finally(()=>{pending--;if(generation.current===current&&!pending)latest.current.onTextureState?.(failed?'error':'ready');});
|
||||
}
|
||||
}
|
||||
m.update();
|
||||
}
|
||||
for(const [key,item] of textures.current)if(!used.has(key)){item.texture?.destroy();textures.current.delete(key);}
|
||||
onTextureState?.(pending?'loading':'ready');
|
||||
return()=>{generation.current++;};
|
||||
},[materials,assignments,modelVersion,appRef,onTextureState]);
|
||||
useEffect(()=>()=>{generation.current=-1;for(const item of textures.current.values())item.texture?.destroy();textures.current.clear();for(const m of customMaterials.current.values())m.destroy();customMaterials.current.clear();},[]);
|
||||
useEffect(()=>{
|
||||
const app=appRef.current,canvas=canvasRef.current,camera=cameraRef.current;
|
||||
if(!editing||!app||!canvas||!camera?.camera||!modelVersion)return;
|
||||
const picker=new pc.Picker(app,1,1);let start:{x:number;y:number}|null=null;
|
||||
const down=(e:PointerEvent)=>{start=e.button===0?{x:e.clientX,y:e.clientY}:null;};
|
||||
const up=(e:PointerEvent)=>{
|
||||
const p=start;start=null;if(!p||Math.hypot(p.x-e.clientX,p.y-e.clientY)>4)return;
|
||||
const rect=canvas.getBoundingClientRect();picker.resize(Math.max(1,Math.floor(rect.width)),Math.max(1,Math.floor(rect.height)));
|
||||
picker.prepare(camera.camera!,app.scene);
|
||||
const hit=picker.getSelection(e.clientX-rect.left,e.clientY-rect.top).find(mi=>!!latest.current.materials?.[mi.material.name]);
|
||||
if(hit instanceof pc.MeshInstance){const source=originalGroups.current.get(hit);if(source)latest.current.onMaterialPick?.(hit.material.name,source.id);}
|
||||
};
|
||||
canvas.addEventListener('pointerdown',down);canvas.addEventListener('pointerup',up);
|
||||
return()=>{canvas.removeEventListener('pointerdown',down);canvas.removeEventListener('pointerup',up);picker.destroy();};
|
||||
},[editing,modelVersion,appRef,cameraRef,canvasRef]);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
.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__status { position:absolute; top:calc(var(--observation-overlay-header-space, 0px) + 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); }
|
||||
@@ -10,7 +10,7 @@
|
||||
.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-view:has(.rover-view__controls) .rover-view__authority { top:calc(var(--observation-overlay-header-space, 0px) + 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); }
|
||||
|
||||
@@ -9,12 +9,14 @@ import { useComputeContours } from "../../core/system/ComputeContourContext";
|
||||
interface SystemNavigationPanelProps {
|
||||
title: string;
|
||||
onAdd: () => void;
|
||||
onNavigateRoot: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SystemNavigationPanel({
|
||||
title,
|
||||
onAdd,
|
||||
onNavigateRoot,
|
||||
onClose,
|
||||
}: SystemNavigationPanelProps) {
|
||||
const { contours, selectedContour, selectContour } = useComputeContours();
|
||||
@@ -40,7 +42,7 @@ export function SystemNavigationPanel({
|
||||
description: contour.expected_node_id,
|
||||
icon: <Icon name="apps" />,
|
||||
active: selectedContour?.contour_id === contour.contour_id,
|
||||
onSelect: () => selectContour(contour.contour_id),
|
||||
onSelect: () => { selectContour(contour.contour_id); onNavigateRoot(); },
|
||||
}))}
|
||||
items={[]}
|
||||
onItemChange={() => undefined}
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { WorkspaceDefinition } from "../productModel";
|
||||
|
||||
interface ApplicationPanelActionsOptions {
|
||||
definition: WorkspaceDefinition | null;
|
||||
onAddVehicle?: () => void;
|
||||
refreshRuntime: () => void;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
connectionScenarioResetting: boolean;
|
||||
@@ -45,7 +44,6 @@ export function deviceRuntimeUtilityAction({
|
||||
|
||||
export function useApplicationPanelActions({
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
@@ -56,7 +54,6 @@ export function useApplicationPanelActions({
|
||||
}: ApplicationPanelActionsOptions): ApplicationPanelUtilityAction[] {
|
||||
return useMemo(() => {
|
||||
const actions: ApplicationPanelUtilityAction[] = [];
|
||||
if (definition?.kind === "vehicles" && onAddVehicle) actions.push({ label: "Добавить аппарат", icon: "plus", onClick: onAddVehicle });
|
||||
if (definition?.kind === "device") {
|
||||
actions.push(deviceRuntimeUtilityAction({
|
||||
refreshRuntime,
|
||||
@@ -81,7 +78,6 @@ export function useApplicationPanelActions({
|
||||
return actions;
|
||||
}, [
|
||||
definition,
|
||||
onAddVehicle,
|
||||
refreshRuntime,
|
||||
resetConnectionScenario,
|
||||
connectionScenarioResetting,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export interface ObservationLayout {
|
||||
version:1; order:string[]; hidden:string[]; layers:Record<string,string>;
|
||||
splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
catalogVisible?:string[]; splits:Record<string,number>; arrangement:'auto'|'columns'|'rows';
|
||||
}
|
||||
export const emptyObservationLayout=():ObservationLayout=>({version:1,order:[],hidden:[],layers:{},splits:{},arrangement:'auto'});
|
||||
const record=(value:unknown):Record<string,unknown>=>value!==null&&typeof value==='object'&&!Array.isArray(value)?value as Record<string,unknown>:{};
|
||||
export function decodeObservationLayout(value:unknown):ObservationLayout {
|
||||
const source=record(value),result=emptyObservationLayout();if(source.version!==1)return result;
|
||||
const ids=(value:unknown)=>Array.isArray(value)?[...new Set(value.filter((v):v is string=>typeof v==='string'&&v.length<512))].slice(0,128):[];
|
||||
if(source.catalogVisible!==undefined)result.catalogVisible=ids(source.catalogVisible);
|
||||
result.order=ids(source.order);result.hidden=ids(source.hidden);
|
||||
result.layers=Object.fromEntries(Object.entries(record(source.layers)).filter(([k,v])=>k.length<512&&typeof v==='string'&&v.length<128).slice(0,128)) as Record<string,string>;
|
||||
result.splits=Object.fromEntries(Object.entries(record(source.splits)).filter(([k,v])=>k.length<4096&&typeof v==='number'&&Number.isFinite(v)).slice(0,256).map(([k,v])=>[k,Math.min(80,Math.max(20,v as number))]));
|
||||
|
||||
@@ -57,5 +57,8 @@ export function useFleet() {
|
||||
events.onerror = unavailable;
|
||||
return () => { active = false; events.close(); if (fallback) clearInterval(fallback); };
|
||||
}, []);
|
||||
return { items, error, refresh };
|
||||
const acceptVehicle = useCallback((vehicle: Vehicle) => {
|
||||
setItems(current => current?.map(item => item.id === vehicle.id ? vehicle : item) ?? null);
|
||||
}, []);
|
||||
return { items, error, refresh, acceptVehicle };
|
||||
}
|
||||
|
||||
@@ -37,5 +37,5 @@ export function useSessionOverviewMode(enabled: boolean) {
|
||||
window.addEventListener("keydown", close, true);
|
||||
return () => window.removeEventListener("keydown", close, true);
|
||||
}, [open]);
|
||||
return { open: enabled && open, toggle: useCallback(() => setOpen(v => !v), []) };
|
||||
return { open: enabled && open, close: useCallback(() => setOpen(false), []), toggle: useCallback(() => setOpen(v => !v), []) };
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
|
||||
export type PostFxSettings = {
|
||||
lighting: {
|
||||
ambientIntensity: number
|
||||
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: {
|
||||
step: number
|
||||
diameter: number
|
||||
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: {
|
||||
ambientIntensity: 0,
|
||||
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: {
|
||||
step: 1,
|
||||
diameter: 1000,
|
||||
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<string, any> {
|
||||
return !!value && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export function mergePostFxDefaults(raw?: Partial<PostFxSettings> | 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
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {mergePostFxDefaults,type PostFxSettings} from './postFx';
|
||||
import donorFields from './environmentFields.json';
|
||||
export type SceneField={key:string;type:string;label:string;group:string;default?:unknown;min?:number;max?:number;step?:number;options?:{label:string;value:string}[];disabledIf?:string};
|
||||
export const environmentFields:SceneField[]=[...donorFields,
|
||||
{key:'postFx.lut.textureUrl',type:'texture',label:'Таблица LUT',group:'PostProcess/LUT'},
|
||||
{key:'postFx.lighting.ambientIntensity',type:'sliderNumber',label:'Заполняющий свет',group:'PostProcess/Lighting',min:0,max:1,step:.01},
|
||||
{key:'postFx.grid.step',type:'sliderNumber',label:'Шаг основной сетки, м',group:'PostProcess/Grid',min:.01,max:10,step:.01},
|
||||
{key:'postFx.grid.diameter',type:'sliderNumber',label:'Диаметр сетки, м',group:'PostProcess/Grid',min:1,max:1000,step:1},
|
||||
];
|
||||
export const textureSlots=['diffuse','normal','metalness','roughness','opacity','emissive','ao'] as const;
|
||||
export type TextureSlot=typeof textureSlots[number];
|
||||
export type MaterialSpec={name:string;color:string;metalness:number;roughness:number;opacity:number;reflectivity:number;clearCoat:number;clearCoatRoughness:number;emissive:string;emissiveIntensity:number;normalStrength:number;doubleSided:boolean;tiling:number;textures:Partial<Record<TextureSlot,{name:string;data:string}>>};
|
||||
export type SceneDocument={schema:'missioncore.rover-scene/v1';model:'dcd006-v020-materials-v2';environmentVersion:2;postFx:PostFxSettings;cameraZoomStrength:number;cameraAutoRotate:boolean;materials:Record<string,MaterialSpec>;assignments:Record<string,string>};
|
||||
const material=(name:string,color:string,metalness:number,roughness:number):MaterialSpec=>({name,color,metalness,roughness,opacity:1,reflectivity:1,clearCoat:0,clearCoatRoughness:.3,emissive:'#000000',emissiveIntensity:0,normalStrength:1,doubleSided:false,tiling:1,textures:{}});
|
||||
export function defaultScene():SceneDocument{
|
||||
const postFx=mergePostFxDefaults();
|
||||
return {schema:'missioncore.rover-scene/v1',model:'dcd006-v020-materials-v2',environmentVersion:2,postFx,cameraZoomStrength:.04,cameraAutoRotate:false,assignments:{},materials:{
|
||||
'rover/body':material('Корпус и подвеска','#2c2f31',.12,.53),
|
||||
'rover/rubber':material('Резина гусениц и катков','#202328',0,.72),
|
||||
'rover/track-steel':material('Нержавеющие проставки','#c4c8cd',1,.17),
|
||||
'rover/box':material('Бортовой ящик','#34373a',.08,.4),
|
||||
'rover/molle':material('MOLLE панель','#52575b',.3,.44),
|
||||
'rover/motor':material('Алюминий моторов','#95999c',.68,.38),
|
||||
'rover/hardware':material('Чёрный крепёж','#24272b',.45,.38),
|
||||
'rover/polymer':material('Пластик и кожухи','#24292d',0,.44),
|
||||
'rover/drive':material('Ведущие звёзды','#81652f',0,.46),
|
||||
}};
|
||||
}
|
||||
export function createMaterial(doc:SceneDocument,id:string):SceneDocument{
|
||||
if(!/^custom\/[a-zA-Z0-9-]{1,80}$/.test(id)||Object.hasOwn(doc.materials,id)||Object.keys(doc.materials).length>=128)return doc;
|
||||
const used=new Set(Object.values(doc.materials).map(m=>m.name));let n=1;while(used.has(`Материал ${n}`))n++;
|
||||
return {...doc,materials:{...doc.materials,[id]:material(`Материал ${n}`,'#808080',0,.5)}};
|
||||
}
|
||||
export function assignMaterial(doc:SceneDocument,group:string,id:string):SceneDocument{
|
||||
if(!Object.hasOwn(defaultScene().materials,group)||!Object.hasOwn(doc.materials,id))return doc;
|
||||
return {...doc,assignments:{...doc.assignments,[group]:id}};
|
||||
}
|
||||
export function fieldValue(doc:SceneDocument,key:string):unknown{return key.split('.').reduce<unknown>((v,k)=>v&&typeof v==='object'?(v as Record<string,unknown>)[k]:undefined,doc);}
|
||||
export function fieldDisabled(doc:SceneDocument,f:SceneField):boolean{
|
||||
const rule=f.disabledIf;if(!rule)return false;
|
||||
if(rule.startsWith('!'))return !fieldValue(doc,rule.slice(1));
|
||||
const atlas=doc.postFx.envAtlas.enabled,sky=doc.postFx.skybox.enabled;
|
||||
return ({ssaoDisabled:doc.postFx.ssao.type==='none',fogDisabled:doc.postFx.rendering.fog==='none',envAtlasBlocked:sky,skyboxBlocked:atlas,envAtlasFieldsDisabled:sky||!atlas,skyboxFieldsDisabled:atlas||!sky,gridDotsDisabled:!doc.postFx.grid.dotsEnabled,gridCrossDisabled:!doc.postFx.grid.crossEnabled} as Record<string,boolean>)[rule]??false;
|
||||
}
|
||||
export function changeField(doc:SceneDocument,key:string,value:unknown):SceneDocument{
|
||||
const f=environmentFields.find(x=>x.key===key);if(!f||f.type==='action')return doc;
|
||||
if(f.type==='texture'&&value!==null&&(typeof value!=='string'||value.length>1_000_000||!/^data:image\/png;base64,[A-Za-z0-9+/=]+$/.test(value)))return doc;
|
||||
const old=fieldValue(doc,key);
|
||||
if(typeof old==='number'){
|
||||
value=Number(value);if(!Number.isFinite(value))return doc;
|
||||
if(f.min!==undefined)value=Math.max(f.min,value as number);if(f.max!==undefined)value=Math.min(f.max,value as number);
|
||||
}else if(typeof old==='boolean'){if(typeof value!=='boolean')return doc;}
|
||||
else if(f.type==='color'){if(typeof value!=='string'||!/^#[0-9a-f]{6}$/i.test(value))return doc;}
|
||||
if(f.options&&!f.options.some(o=>o.value===String(value)))return doc;
|
||||
if(f.type==='doubleSlider'){
|
||||
if(!Array.isArray(value)||value.length!==2||!value.every(Number.isFinite))return doc;
|
||||
value=[Math.max(0,Math.min(100,value[0])),Math.max(0,Math.min(100,value[1]))].sort((a,b)=>a-b);
|
||||
}
|
||||
const result={...doc,postFx:structuredClone(doc.postFx)};const parts=key.split('.');const tail=parts.pop()!;
|
||||
const target=parts.reduce((v,k)=>v[k],result as any);target[tail]=value;
|
||||
if(key==='postFx.rendering.fogRange'){[result.postFx.rendering.fogStart,result.postFx.rendering.fogEnd]=value as [number,number];}
|
||||
if(key==='postFx.envAtlas.enabled'&&value)result.postFx.skybox.enabled=false;
|
||||
if(key==='postFx.skybox.enabled'&&value)result.postFx.envAtlas.enabled=false;
|
||||
return result;
|
||||
}
|
||||
export const materialRanges:Record<string,[number,number]>={metalness:[0,1],roughness:[0,1],opacity:[0,1],reflectivity:[0,1],clearCoat:[0,1],clearCoatRoughness:[0,1],emissiveIntensity:[0,10],normalStrength:[0,2],tiling:[.01,20]};
|
||||
export function textureBytes(doc:SceneDocument):number{return Object.values(doc.materials).reduce((sum,m)=>sum+Object.values(m.textures).reduce((n,t)=>n+(t?.data.length??0),0),0);}
|
||||
export function parseScene(raw:unknown):SceneDocument{
|
||||
const r=raw as SceneDocument;if(!r||r.schema!=='missioncore.rover-scene/v1'||r.model!=='dcd006-v020-materials-v2')throw new Error('Файл сцены не подходит к этой модели.');
|
||||
let doc=defaultScene();
|
||||
for(const f of environmentFields){const v=fieldValue(r,f.key);if(v!==undefined)doc=changeField(doc,f.key,v);}
|
||||
if(Object.keys(r.materials??{}).length>128)throw new Error('В сцене может быть не больше 128 материалов.');
|
||||
for(const id of Object.keys(r.materials??{}))if(id.startsWith('custom/'))doc=createMaterial(doc,id);
|
||||
for(const [id,base] of Object.entries(doc.materials)){
|
||||
const m=r.materials?.[id];if(!m)continue;
|
||||
if(typeof m.name==='string')base.name=m.name.trim().slice(0,120)||base.name;
|
||||
for(const key of ['color','emissive'] as const)if(/^#[0-9a-f]{6}$/i.test(m[key]))base[key]=m[key];
|
||||
for(const [key,[lo,hi]] of Object.entries(materialRanges)){const v=m[key as keyof MaterialSpec];if(typeof v==='number'&&Number.isFinite(v))(base as any)[key]=Math.max(lo,Math.min(hi,v));}
|
||||
if(typeof m.doubleSided==='boolean')base.doubleSided=m.doubleSided;
|
||||
for(const slot of textureSlots){const t=m.textures?.[slot];if(!t)continue;if(typeof t.name!=='string'||typeof t.data!=='string'||t.data.length>12_000_000||!/^data:image\/(png|jpeg|webp);base64,[A-Za-z0-9+/=]+$/.test(t.data))throw new Error('Некорректная текстура в файле сцены.');base.textures[slot]={name:t.name.slice(0,200),data:t.data};}
|
||||
}
|
||||
for(const [group,id] of Object.entries(r.assignments??{}))if(typeof id==='string')doc=assignMaterial(doc,group,id);
|
||||
if(textureBytes(doc)>28_000_000)throw new Error('Суммарный объём текстур превышает 20 МБ.');
|
||||
return doc;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import {defaultScene,parseScene,type SceneDocument} from './sceneDocument';
|
||||
|
||||
// Fingerprint of the first editor's withdrawn neutral preset. Match the entire
|
||||
// environment: independently edited environments must never be reset on load.
|
||||
function withdrawnEnvironment(){
|
||||
const fx=defaultScene().postFx;
|
||||
Object.assign(fx.lighting,{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22});
|
||||
Object.assign(fx.envAtlas,{reflectionIntensity:1,brightness:1.2});
|
||||
Object.assign(fx.rendering,{backgroundColor:'#202227',fog:'none',toneMapping:4});
|
||||
Object.assign(fx.grid,{colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12});
|
||||
fx.chromaticAberration.enabled=false;fx.vignette.enabled=false;fx.grading.enabled=false;fx.bloom.enabled=false;
|
||||
Object.assign(fx.directionalLight,{intensity:1.8,azimuth:135,elevation:45});
|
||||
Object.assign(fx.shadowCatcher,{size:4,lightIntensity:.5,shadowIntensity:.25});
|
||||
return fx;
|
||||
}
|
||||
export function loadStoredScene(raw:unknown):{document:SceneDocument;updated:boolean;restoredEnvironment:boolean}{
|
||||
const doc=parseScene(raw);
|
||||
const updated=(raw as Partial<SceneDocument>).environmentVersion!==2;
|
||||
const restoredEnvironment=updated&&JSON.stringify(doc.postFx)===JSON.stringify(withdrawnEnvironment());
|
||||
return {document:restoredEnvironment?{...doc,postFx:defaultScene().postFx}:doc,updated,restoredEnvironment};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type {SceneDocument} from './sceneDocument';
|
||||
import {loadStoredScene} from './sceneMigration';
|
||||
const key=(vehicle:string)=>`${vehicle}:dcd006-v020-materials-v2`;
|
||||
function database():Promise<IDBDatabase>{return new Promise((resolve,reject)=>{const r=indexedDB.open('missioncore-rover-scenes',1);r.onupgradeneeded=()=>r.result.createObjectStore('scenes');r.onerror=()=>reject(r.error);r.onblocked=()=>reject(new Error('Хранилище сцены занято другим окном.'));r.onsuccess=()=>resolve(r.result);});}
|
||||
export async function readScene(vehicle:string):Promise<SceneDocument|null>{
|
||||
const db=await database();try{
|
||||
const raw=await new Promise<unknown>((resolve,reject)=>{const r=db.transaction('scenes').objectStore('scenes').get(key(vehicle));r.onerror=()=>reject(r.error);r.onsuccess=()=>resolve(r.result);});
|
||||
if(!raw)return null;
|
||||
const result=loadStoredScene(raw);
|
||||
if(result.updated)await new Promise<void>((resolve,reject)=>{
|
||||
const tx=db.transaction('scenes','readwrite'),store=tx.objectStore('scenes');
|
||||
if(result.restoredEnvironment)store.put(raw,`${key(vehicle)}:before-environment-v2`);
|
||||
store.put(result.document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);
|
||||
});
|
||||
return result.document;
|
||||
}finally{db.close();}
|
||||
}
|
||||
export async function saveScene(vehicle:string,document:SceneDocument):Promise<void>{const db=await database();try{await new Promise<void>((resolve,reject)=>{const tx=db.transaction('scenes','readwrite');tx.objectStore('scenes').put(document,key(vehicle));tx.oncomplete=()=>resolve();tx.onabort=()=>reject(tx.error);tx.onerror=()=>reject(tx.error);});}finally{db.close();}}
|
||||
export async function textureFromFile(file:File):Promise<{name:string;data:string}>{
|
||||
if(!['image/png','image/jpeg','image/webp'].includes(file.type)||file.size>8_000_000)throw new Error('Выберите PNG, JPEG или WebP до 8 МБ.');
|
||||
const bitmap=await createImageBitmap(file);try{if(bitmap.width>4096||bitmap.height>4096)throw new Error('Максимальный размер текстуры — 4096 × 4096.');}finally{bitmap.close();}
|
||||
return new Promise((resolve,reject)=>{const r=new FileReader();r.onerror=()=>reject(r.error);r.onload=()=>resolve({name:file.name,data:String(r.result)});r.readAsDataURL(file);});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {defaultScene,parseScene,type SceneDocument} from './sceneDocument';
|
||||
import {readScene,saveScene} from './sceneStorage';
|
||||
let writes=Promise.resolve();
|
||||
const enqueue=(vehicle:string,doc:SceneDocument)=>{const next=writes.catch(()=>{}).then(()=>saveScene(vehicle,doc));writes=next;return next;};
|
||||
export function useSceneDocument(vehicle:string){
|
||||
const [document,setDocument]=useState(defaultScene),[ready,setReady]=useState(false),[revision,setRevision]=useState(0);
|
||||
const [saveState,setSaveState]=useState<'loading'|'saved'|'saving'|'error'>('loading'),[error,setError]=useState('');
|
||||
const latest=useRef({document,revision,vehicle});latest.current={document,revision,vehicle};
|
||||
const savedRevision=useRef(0);
|
||||
useEffect(()=>{let cancelled=false;readScene(vehicle).then(value=>{if(!cancelled){setDocument(value??defaultScene());setSaveState('saved');}}).catch(()=>{if(!cancelled){setError('Не удалось прочитать сохранённую сцену. Изменения можно экспортировать в JSON.');setSaveState('error');}}).finally(()=>{if(!cancelled)setReady(true);});return()=>{cancelled=true;};},[vehicle]);
|
||||
const update=useCallback((fn:(doc:SceneDocument)=>SceneDocument)=>{setDocument(d=>fn(d));setRevision(v=>v+1);setSaveState('saving');},[]);
|
||||
useEffect(()=>{
|
||||
if(!ready||revision===0)return;
|
||||
let cancelled=false;const timer=setTimeout(()=>{enqueue(vehicle,document).then(()=>{savedRevision.current=revision;if(!cancelled)setSaveState('saved');}).catch(()=>{if(!cancelled){setSaveState('error');setError('Не удалось сохранить сцену на этом компьютере. Экспортируйте JSON, чтобы сохранить изменения.');}});},400);
|
||||
return()=>{cancelled=true;clearTimeout(timer);};
|
||||
},[ready,revision,vehicle,document]);
|
||||
useEffect(()=>()=>{const v=latest.current;if(v.revision>savedRevision.current)void enqueue(v.vehicle,v.document).catch(()=>{});},[]);
|
||||
const importDocument=async(file:File)=>{try{if(file.size>32_000_000)throw new Error('Файл сцены больше 32 МБ.');const next=parseScene(JSON.parse(await file.text()));update(()=>next);}catch(e){setError(e instanceof Error?e.message:'Не удалось загрузить сцену.');}};
|
||||
const exportDocument=()=>{const blob=new Blob([JSON.stringify(document,null,2)],{type:'application/json'});const url=URL.createObjectURL(blob);const a=globalThis.document.createElement('a');a.href=url;a.download='rover-scene.json';a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);};
|
||||
return {document,ready,saveState,error,setError,update,importDocument,exportDocument};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {fetchComputeContours,type ComputeContour} from './computeContours';
|
||||
import {fetchWorkerTelemetry,type WorkerTelemetry} from './workerTelemetry';
|
||||
import type {Vehicle} from '../fleet/useFleet';
|
||||
|
||||
export interface CoreHealth {
|
||||
ok:boolean; service:string; version:string;
|
||||
plugin_runtimes:{ready:number;total:number};
|
||||
}
|
||||
export interface ContourSnapshot {
|
||||
core:CoreHealth|null; latency:number|null;
|
||||
workers:{contour:ComputeContour;telemetry:WorkerTelemetry|null}[]|null;
|
||||
vehicles:Vehicle[]|null; errors:string[]; observedAt:number;
|
||||
}
|
||||
async function json(url:string,signal:AbortSignal):Promise<unknown>{
|
||||
const response=await fetch(url,{signal,cache:'no-store',headers:{Accept:'application/json'}});
|
||||
if(!response.ok)throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
export async function readContourSnapshot(signal:AbortSignal):Promise<ContourSnapshot>{
|
||||
const bounded=AbortSignal.any([signal,AbortSignal.timeout(8000)]);
|
||||
const errors:string[]=[];
|
||||
const [coreResult,workersResult,fleetResult]=await Promise.allSettled([
|
||||
(async()=>{
|
||||
const start=performance.now();
|
||||
const data=await json('/api/health',bounded) as CoreHealth;
|
||||
if(!data||typeof data.ok!=='boolean'||data.service!=='mission-core-control-plane'||typeof data.version!=='string'||!Number.isFinite(data.plugin_runtimes?.ready)||!Number.isFinite(data.plugin_runtimes?.total))throw new Error('Invalid health');
|
||||
return {data,latency:performance.now()-start};
|
||||
})(),
|
||||
(async()=>{
|
||||
const contours=await fetchComputeContours(bounded);
|
||||
return Promise.all(contours.map(async contour=>{
|
||||
try{return {contour,telemetry:await fetchWorkerTelemetry(contour.contour_id,bounded)};}
|
||||
catch{errors.push(`Не удалось проверить вычислитель «${contour.display_name}».`);return {contour,telemetry:null};}
|
||||
}));
|
||||
})(),
|
||||
(async()=>{
|
||||
const data=await json('/api/v1/fleet',bounded) as {items:Vehicle[]};
|
||||
if(!data||!Array.isArray(data.items)||data.items.some(item=>!item||typeof item.id!=='string'||typeof item.name!=='string'||!['online','offline'].includes(item.connectivity)))throw new Error('Invalid fleet');
|
||||
return data.items;
|
||||
})(),
|
||||
]);
|
||||
if(coreResult.status==='rejected')errors.push('Не удалось проверить Mission Core.');
|
||||
if(workersResult.status==='rejected')errors.push('Не удалось получить список вычислителей.');
|
||||
if(fleetResult.status==='rejected')errors.push('Не удалось получить бортовые компьютеры.');
|
||||
return {
|
||||
core:coreResult.status==='fulfilled'?coreResult.value.data:null,
|
||||
latency:coreResult.status==='fulfilled'?coreResult.value.latency:null,
|
||||
workers:workersResult.status==='fulfilled'?workersResult.value:null,
|
||||
vehicles:fleetResult.status==='fulfilled'?fleetResult.value:null,
|
||||
errors,observedAt:Date.now(),
|
||||
};
|
||||
}
|
||||
export function workerAvailable(telemetry:WorkerTelemetry|null):boolean{
|
||||
return Boolean(telemetry?.connection.reachable&&telemetry.connection.identity_matches&&telemetry.node);
|
||||
}
|
||||
export function boardAvailable(vehicle:Vehicle):boolean{
|
||||
return vehicle.enrollment==='paired'&&vehicle.connectivity==='online';
|
||||
}
|
||||
export function contourSummary(snapshot:ContourSnapshot|null){
|
||||
if(!snapshot)return {tone:'warning' as const,label:'Проверяем состояние',online:null,total:null};
|
||||
const total=snapshot.workers!==null&&snapshot.vehicles!==null?1+snapshot.workers.length+snapshot.vehicles.length:null;
|
||||
const online=Number(Boolean(snapshot.core?.ok))+(snapshot.workers??[]).filter(item=>workerAvailable(item.telemetry)).length+(snapshot.vehicles??[]).filter(boardAvailable).length;
|
||||
if(snapshot.errors.length)return {tone:'warning' as const,label:'Проверка неполная',online,total};
|
||||
if(!snapshot.core?.ok)return {tone:'danger' as const,label:'Mission Core недоступен',online,total};
|
||||
return {tone:online===total?'success' as const:'warning' as const,label:online===total?'Все узлы на связи':'Часть узлов не на связи',online,total};
|
||||
}
|
||||
@@ -186,7 +186,7 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
label: "Состояние контура",
|
||||
title: "Состояние контура",
|
||||
eyebrow: "ПАРК / СОСТОЯНИЕ",
|
||||
description: "Узлы, процессы, сеть и подключённые устройства по данным живого контура.",
|
||||
description: "Связь с Mission Core, вычислителями и бортовыми компьютерами аппаратов.",
|
||||
icon: "shield",
|
||||
kind: "contour-health",
|
||||
groups: [],
|
||||
@@ -213,50 +213,6 @@ export const workspaces: WorkspaceDefinition[] = [
|
||||
kind: "device",
|
||||
groups: [],
|
||||
},
|
||||
{
|
||||
id: "payload",
|
||||
root: "fleet",
|
||||
label: "Сенсоры и каналы",
|
||||
title: "Сенсоры и каналы",
|
||||
eyebrow: "ПАРК / ПОЛЕЗНАЯ НАГРУЗКА",
|
||||
description: "Полезная нагрузка аппарата и каналы данных, которые она публикует.",
|
||||
icon: "grid",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Пространственные сенсоры",
|
||||
description: "Единый контракт для разных источников.",
|
||||
capabilities: [
|
||||
active("Облако точек", "Доказанный декодированный поток точек текущего устройства."),
|
||||
active("Поза и траектория", "Доказанный поток позы и накопление пути."),
|
||||
contract("Камеры", "Изображение, видео, глубина, внутренние и внешние параметры."),
|
||||
contract("Навигация", "IMU, GNSS/RTK, одометрия и система координат."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "configurations",
|
||||
root: "fleet",
|
||||
label: "Компоновки",
|
||||
title: "Компоновки борта",
|
||||
eyebrow: "ПАРК / КОМПОНОВКИ",
|
||||
description: "Версионируемые сочетания сенсоров, вычислителей, транспорта и питания.",
|
||||
icon: "settings",
|
||||
kind: "catalog",
|
||||
groups: [
|
||||
{
|
||||
title: "Шаблон компоновки",
|
||||
description: "Повторяемая конфигурация для одинаковых аппаратов.",
|
||||
capabilities: [
|
||||
contract("Состав модулей", "Сенсоры, адаптеры, обработчики и зависимости."),
|
||||
contract("Калибровки", "Системы координат, преобразования, внутренние и внешние параметры."),
|
||||
contract("Профиль сети", "Локальная сеть, ячеистая сеть, LTE и серверный маршрут."),
|
||||
later("Развёртывание на борт", "Пакет конфигурации и контролируемое обновление."),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "spatial-scene",
|
||||
root: "polygon",
|
||||
|
||||
@@ -2173,6 +2173,7 @@
|
||||
|
||||
.contour-health-summary {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -2203,7 +2204,7 @@
|
||||
|
||||
.contour-health-kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 10rem), 1fr));
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
@@ -2235,7 +2236,7 @@
|
||||
|
||||
.contour-node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
@@ -2251,6 +2252,7 @@
|
||||
|
||||
.contour-node header {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
@@ -2262,7 +2264,7 @@
|
||||
|
||||
.contour-node dl {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
border-radius: 0.75rem;
|
||||
background: rgb(255 255 255 / 0.022);
|
||||
@@ -2277,11 +2279,10 @@
|
||||
|
||||
.contour-node dd {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--nodedc-text-secondary);
|
||||
font-size: 0.62rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--nodedc-font-size-sm);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.contour-process-list {
|
||||
|
||||
@@ -1,41 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Button, Icon, StatusBadge } from "@nodedc/ui-react";
|
||||
|
||||
import type { MissionRuntimeState } from "../core/runtime/contracts";
|
||||
import {
|
||||
fetchPolygonWorkerStatus,
|
||||
type PolygonWorkerStatus,
|
||||
} from "../core/polygon/liveWorker";
|
||||
|
||||
interface ControlPlaneHealth {
|
||||
ok: boolean;
|
||||
status: string;
|
||||
service: string;
|
||||
version: string;
|
||||
plugin_runtimes: {
|
||||
ready: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface PluginRuntimeHealth {
|
||||
runtime_instance_id: string;
|
||||
plugin_id: string;
|
||||
plugin_version: string;
|
||||
status: string;
|
||||
observed_at: string;
|
||||
detail_code: string | null;
|
||||
}
|
||||
|
||||
interface ContourSnapshot {
|
||||
controlPlane: ControlPlaneHealth | null;
|
||||
pluginRuntimes: PluginRuntimeHealth[];
|
||||
simulationWorker: PolygonWorkerStatus | null;
|
||||
controlPlaneLatencyMs: number | null;
|
||||
simulationGatewayLatencyMs: number | null;
|
||||
observedAt: Date;
|
||||
error: string | null;
|
||||
}
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,Icon,StatusBadge} from '@nodedc/ui-react';
|
||||
import type {MissionRuntimeState} from '../core/runtime/contracts';
|
||||
import {boardAvailable,contourSummary,readContourSnapshot,workerAvailable,type ContourSnapshot} from '../core/system/contourHealth';
|
||||
import {startSequentialPolling} from '../core/system/sequentialPolling';
|
||||
|
||||
export function contourRuntimeAuthorityPresentation(
|
||||
state: MissionRuntimeState | null,
|
||||
@@ -56,310 +23,61 @@ export function contourRuntimeAuthorityPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
function validControlPlaneHealth(value: unknown): value is ControlPlaneHealth {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Partial<ControlPlaneHealth>;
|
||||
return typeof item.ok === "boolean"
|
||||
&& typeof item.status === "string"
|
||||
&& typeof item.service === "string"
|
||||
&& typeof item.version === "string"
|
||||
&& Boolean(item.plugin_runtimes)
|
||||
&& typeof item.plugin_runtimes?.ready === "number"
|
||||
&& typeof item.plugin_runtimes?.total === "number";
|
||||
function observed(value:number|string|null|undefined){
|
||||
if(value===null||value===undefined)return '—';
|
||||
const date=new Date(value);
|
||||
return Number.isFinite(date.getTime())?date.toLocaleString('ru-RU'):'—';
|
||||
}
|
||||
|
||||
function validPluginRuntime(value: unknown): value is PluginRuntimeHealth {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
const item = value as Partial<PluginRuntimeHealth>;
|
||||
return typeof item.runtime_instance_id === "string"
|
||||
&& typeof item.plugin_id === "string"
|
||||
&& typeof item.plugin_version === "string"
|
||||
&& typeof item.status === "string"
|
||||
&& typeof item.observed_at === "string";
|
||||
}
|
||||
|
||||
async function timedJson(
|
||||
url: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<{ payload: unknown; latencyMs: number }> {
|
||||
const started = performance.now();
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
});
|
||||
const latencyMs = performance.now() - started;
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return { payload: await response.json(), latencyMs };
|
||||
}
|
||||
|
||||
function formatLatency(value: number | null): string {
|
||||
if (value === null) return "—";
|
||||
return `${Math.max(1, Math.round(value))} мс`;
|
||||
}
|
||||
|
||||
function formatObservedAt(value: Date | string | null): string {
|
||||
if (!value) return "—";
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (!Number.isFinite(date.getTime())) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function ContourHealthWorkspace({
|
||||
state,
|
||||
}: {
|
||||
state: MissionRuntimeState | null;
|
||||
}) {
|
||||
const [snapshot, setSnapshot] = useState<ContourSnapshot | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const refresh = useCallback(() => setGeneration((value) => value + 1), []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
void Promise.allSettled([
|
||||
timedJson("/api/health", controller.signal),
|
||||
timedJson("/api/v1/device-plugin-runtimes", controller.signal),
|
||||
(async () => {
|
||||
const started = performance.now();
|
||||
const worker = await fetchPolygonWorkerStatus({ signal: controller.signal });
|
||||
return { worker, latencyMs: performance.now() - started };
|
||||
})(),
|
||||
]).then(([healthResult, runtimesResult, workerResult]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const healthPayload = healthResult.status === "fulfilled"
|
||||
? healthResult.value.payload
|
||||
: null;
|
||||
const runtimesPayload = runtimesResult.status === "fulfilled"
|
||||
? runtimesResult.value.payload
|
||||
: null;
|
||||
const runtimeItems = runtimesPayload
|
||||
&& typeof runtimesPayload === "object"
|
||||
&& Array.isArray((runtimesPayload as { items?: unknown }).items)
|
||||
? (runtimesPayload as { items: unknown[] }).items.filter(validPluginRuntime)
|
||||
: [];
|
||||
const failures = [healthResult, runtimesResult, workerResult]
|
||||
.filter((result) => result.status === "rejected").length;
|
||||
setSnapshot({
|
||||
controlPlane: validControlPlaneHealth(healthPayload) ? healthPayload : null,
|
||||
pluginRuntimes: runtimeItems,
|
||||
simulationWorker: workerResult.status === "fulfilled"
|
||||
? workerResult.value.worker
|
||||
: null,
|
||||
controlPlaneLatencyMs: healthResult.status === "fulfilled"
|
||||
? healthResult.value.latencyMs
|
||||
: null,
|
||||
simulationGatewayLatencyMs: workerResult.status === "fulfilled"
|
||||
? workerResult.value.latencyMs
|
||||
: null,
|
||||
observedAt: new Date(),
|
||||
error: failures === 0
|
||||
? null
|
||||
: `Не ответили ${failures} из 3 диагностических контрактов.`,
|
||||
});
|
||||
}).finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [generation]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(refresh, 5_000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [refresh]);
|
||||
|
||||
const {
|
||||
aiActive,
|
||||
deviceControlConnectivity,
|
||||
controlledDevice,
|
||||
} = contourRuntimeAuthorityPresentation(state);
|
||||
const simulationWorker = snapshot?.simulationWorker ?? null;
|
||||
const controlPlaneReady = Boolean(snapshot?.controlPlane?.ok);
|
||||
const runtimeReady = snapshot?.pluginRuntimes.filter(
|
||||
(runtime) => runtime.status === "ready",
|
||||
).length ?? 0;
|
||||
const processCount = (snapshot?.pluginRuntimes.length ?? 0) + 2;
|
||||
const readyProcessCount = runtimeReady
|
||||
+ (controlPlaneReady ? 1 : 0)
|
||||
+ (simulationWorker?.available ? 1 : 0);
|
||||
const nodeStatus = useMemo(() => {
|
||||
if (!snapshot) return "Проверяем";
|
||||
if (controlPlaneReady && snapshot.error === null) return "Контур отвечает";
|
||||
if (controlPlaneReady) return "Частично доступен";
|
||||
return "Нет связи";
|
||||
}, [controlPlaneReady, snapshot]);
|
||||
|
||||
return (
|
||||
<div className="contour-health-dashboard">
|
||||
<section className="contour-health-summary">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЖИВОЙ ДИАГНОСТИЧЕСКИЙ СРЕЗ</span>
|
||||
<h2>Локальный вычислительный контур</h2>
|
||||
<p>
|
||||
Статусы читаются из Control Plane, plugin runtime и шлюза Simulation Worker.
|
||||
Пустые значения не подменяются демонстрационными числами.
|
||||
</p>
|
||||
</div>
|
||||
<div className="contour-health-summary__status">
|
||||
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
|
||||
{nodeStatus}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={loading}
|
||||
onClick={refresh}
|
||||
>
|
||||
<Icon name="refresh" size={14} />
|
||||
{loading ? "Проверяем" : "Обновить"}
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
|
||||
<div>
|
||||
<span>Узлы</span>
|
||||
<strong>{snapshot ? 2 : "—"}</strong>
|
||||
<small>локальный + внешний worker</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Процессы готовы</span>
|
||||
<strong>{snapshot ? `${readyProcessCount} / ${processCount}` : "—"}</strong>
|
||||
<small>по живым health-контрактам</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Локальный API</span>
|
||||
<strong>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</strong>
|
||||
<small>браузер → Control Plane</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Последняя проверка</span>
|
||||
<strong>{formatObservedAt(snapshot?.observedAt ?? null)}</strong>
|
||||
<small>автообновление каждые 5 секунд</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="contour-node-grid" aria-label="Вычислительные узлы">
|
||||
<article className="contour-node" data-state={controlPlaneReady ? "online" : "offline"}>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">УЗЕЛ 01 · ЛОКАЛЬНЫЙ</span>
|
||||
<h3>Mission Core Control Plane</h3>
|
||||
</div>
|
||||
<StatusBadge tone={controlPlaneReady ? "success" : "danger"}>
|
||||
{controlPlaneReady ? "Доступен" : "Недоступен"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>Версия</dt><dd>{snapshot?.controlPlane?.version ?? "—"}</dd></div>
|
||||
<div><dt>API latency</dt><dd>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</dd></div>
|
||||
<div><dt>Plugin runtimes</dt><dd>{snapshot ? `${runtimeReady} / ${snapshot.pluginRuntimes.length}` : "—"}</dd></div>
|
||||
<div><dt>Активный режим</dt><dd>{state?.sourceMode ?? "idle"}</dd></div>
|
||||
</dl>
|
||||
<div className="contour-process-list">
|
||||
{snapshot?.pluginRuntimes.map((runtime) => (
|
||||
<div key={runtime.runtime_instance_id}>
|
||||
<i data-state={runtime.status === "ready" ? "online" : "offline"} />
|
||||
<span>
|
||||
<strong>Device runtime · {runtime.plugin_id}</strong>
|
||||
<small>v{runtime.plugin_version} · {formatObservedAt(runtime.observed_at)}</small>
|
||||
</span>
|
||||
<em>{runtime.status}</em>
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<i data-state={aiActive ? "online" : "idle"} />
|
||||
<span>
|
||||
<strong>AI perception</strong>
|
||||
<small>
|
||||
{aiActive
|
||||
? `${Math.round(state?.metrics?.aiFrameRateHz ?? 0)} кадр/с`
|
||||
: "Нет активного задания"}
|
||||
</small>
|
||||
</span>
|
||||
<em>{aiActive ? "active" : "idle"}</em>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article
|
||||
className="contour-node"
|
||||
data-state={simulationWorker?.available ? "online" : "offline"}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<span className="section-eyebrow">УЗЕЛ 02 · ВНЕШНИЙ</span>
|
||||
<h3>{simulationWorker?.workerId ?? "Simulation Worker"}</h3>
|
||||
</div>
|
||||
<StatusBadge tone={simulationWorker?.available ? "success" : "neutral"}>
|
||||
{simulationWorker?.available ? "Доступен" : "Offline"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl>
|
||||
<div><dt>Gateway latency</dt><dd>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</dd></div>
|
||||
<div><dt>Сеть worker</dt><dd>{simulationWorker?.isolation.network ?? "unavailable"}</dd></div>
|
||||
<div><dt>Политика данных</dt><dd>{simulationWorker?.isolation.artifactPolicy ?? "d-only"}</dd></div>
|
||||
<div><dt>Активный прогон</dt><dd>{simulationWorker?.activeRunId ?? "нет"}</dd></div>
|
||||
</dl>
|
||||
<div className="contour-process-list">
|
||||
<div>
|
||||
<i data-state={simulationWorker?.available ? "online" : "offline"} />
|
||||
<span>
|
||||
<strong>Simulation orchestration</strong>
|
||||
<small>
|
||||
{simulationWorker?.runState
|
||||
? `Прогон: ${simulationWorker.runState}`
|
||||
: "Нет активного прогона"}
|
||||
</small>
|
||||
</span>
|
||||
<em>{simulationWorker?.available ? "ready" : "offline"}</em>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="contour-network-strip" aria-label="Состояние сети">
|
||||
<div>
|
||||
<span className="section-eyebrow">СЕТЬ</span>
|
||||
<strong>Браузер</strong>
|
||||
<small>127.0.0.1:8000</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div>
|
||||
<i data-state={controlPlaneReady ? "online" : "offline"} />
|
||||
<strong>Control Plane</strong>
|
||||
<small>{formatLatency(snapshot?.controlPlaneLatencyMs ?? null)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div>
|
||||
<i data-state={simulationWorker?.available ? "online" : "offline"} />
|
||||
<strong>Worker gateway</strong>
|
||||
<small>{formatLatency(snapshot?.simulationGatewayLatencyMs ?? null)}</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{controlledDevice ? (
|
||||
<section className="contour-connected-device">
|
||||
<span className="section-eyebrow">ПОДТВЕРЖДЁННАЯ УПРАВЛЯЮЩАЯ СЕССИЯ</span>
|
||||
<strong>{controlledDevice.displayName}</strong>
|
||||
<small>
|
||||
{controlledDevice.endpointLabel ?? controlledDevice.modelId}
|
||||
{deviceControlConnectivity === "degraded"
|
||||
? " · управление подтверждено, поток данных нарушен"
|
||||
: " · управление подтверждено"}
|
||||
</small>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{snapshot?.error ? (
|
||||
<p className="contour-health-error" role="status">{snapshot.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
export function ContourHealthWorkspace({state}:{state:MissionRuntimeState|null}){
|
||||
const [snapshot,setSnapshot]=useState<ContourSnapshot|null>(null);
|
||||
const [loading,setLoading]=useState(true),[generation,setGeneration]=useState(0);
|
||||
useEffect(()=>{
|
||||
const controller=new AbortController();
|
||||
const stop=startSequentialPolling(async()=>{
|
||||
setLoading(true);
|
||||
try{const next=await readContourSnapshot(controller.signal);if(!controller.signal.aborted)setSnapshot(next);}
|
||||
finally{if(!controller.signal.aborted)setLoading(false);}
|
||||
},5000);
|
||||
return ()=>{stop();controller.abort();};
|
||||
},[generation]);
|
||||
const summary=contourSummary(snapshot);
|
||||
const core=snapshot?.core;
|
||||
const {controlledDevice,deviceControlConnectivity}=contourRuntimeAuthorityPresentation(state);
|
||||
return <div className="contour-health-dashboard">
|
||||
<section className="contour-health-summary">
|
||||
<div><h2>Узлы и связь</h2><p>Mission Core, зарегистрированные вычислители и бортовые компьютеры аппаратов.</p></div>
|
||||
<div className="contour-health-summary__status"><StatusBadge tone={loading?'warning':summary.tone}>{loading?'Проверяем состояние':summary.label}</StatusBadge><Button size="compact" variant="secondary" disabled={loading} onClick={()=>setGeneration(value=>value+1)}><Icon name="refresh" size={14}/>{loading?'Проверяем':'Обновить'}</Button></div>
|
||||
</section>
|
||||
<section className="contour-health-kpis" aria-label="Сводка состояния контура">
|
||||
<div><span>Узлы на связи</span><strong>{summary.online??'—'} / {summary.total??'—'}</strong><small>Core, вычислители и бортовые ПК</small></div>
|
||||
<div><span>Вычислители</span><strong>{snapshot?.workers?.length??'—'}</strong><small>из настроек системы</small></div>
|
||||
<div><span>Бортовые компьютеры</span><strong>{snapshot?.vehicles?.length??'—'}</strong><small>из реестра аппаратов</small></div>
|
||||
<div><span>Последняя проверка</span><strong>{observed(snapshot?.observedAt)}</strong><small>обновление каждые 5 секунд</small></div>
|
||||
</section>
|
||||
<section className="contour-node-grid" aria-label="Узлы контура">
|
||||
<article className="contour-node" data-state={core?.ok?'online':'offline'}>
|
||||
<header><div><span className="section-eyebrow">КОМПЬЮТЕР ОПЕРАТОРА</span><h3>Mission Core</h3></div><StatusBadge tone={core?.ok?'success':core?'danger':'warning'}>{core?(core.ok?'Доступен':'Недоступен'):snapshot?'Нет свежих данных':'Проверяем'}</StatusBadge></header>
|
||||
<dl><div><dt>Версия</dt><dd>{core?.version??'—'}</dd></div><div><dt>Время ответа</dt><dd>{snapshot?.latency==null?'—':`${Math.max(1,Math.round(snapshot.latency))} мс`}</dd></div><div><dt>Обработчики устройств готовы</dt><dd>{core?`${core.plugin_runtimes.ready} / ${core.plugin_runtimes.total}`:'—'}</dd></div></dl>
|
||||
</article>
|
||||
{snapshot?.workers?.map(({contour,telemetry})=>{
|
||||
const online=workerAvailable(telemetry);
|
||||
const mismatch=telemetry?.connection.reachable&&!telemetry.connection.identity_matches;
|
||||
return <article key={contour.contour_id} className="contour-node" data-state={online?'online':'offline'}>
|
||||
<header><div><span className="section-eyebrow">ВЫЧИСЛИТЕЛЬ</span><h3>{contour.display_name}</h3></div><StatusBadge tone={online?'success':!telemetry||mismatch?'warning':'neutral'}>{online?'На связи':mismatch?'Другой узел':telemetry?'Нет свежих данных':'Проверка недоступна'}</StatusBadge></header>
|
||||
<dl><div><dt>Узел</dt><dd>{contour.expected_node_id}</dd></div><div><dt>Телеметрия</dt><dd>{contour.telemetry_mode==='agent-mqtt'?'Агент':'Диагностика SSH'}</dd></div><div><dt>Проверено</dt><dd>{observed(telemetry?.connection.observed_at_utc)}</dd></div><div><dt>Последнее измерение</dt><dd>{observed(telemetry?.node?.observed_at_utc)}</dd></div><div><dt>Операционная система</dt><dd>{telemetry?.node?.os.caption??'—'}</dd></div></dl>
|
||||
</article>;
|
||||
})}
|
||||
{snapshot?.vehicles?.map(vehicle=>{
|
||||
const online=boardAvailable(vehicle);
|
||||
const status=vehicle.enrollment==='revoked'?'Привязка отозвана':vehicle.enrollment==='pending'?'Подключение':vehicle.enrollment==='failed'?'Не подключён':online?'На связи':'Нет связи';
|
||||
return <article key={vehicle.id} className="contour-node" data-state={online?'online':'offline'}>
|
||||
<header><div><span className="section-eyebrow">БОРТОВОЙ КОМПЬЮТЕР</span><h3>{vehicle.name}</h3></div><StatusBadge tone={online?'success':'neutral'}>{status}</StatusBadge></header>
|
||||
<dl><div><dt>Имя БК</dt><dd>{vehicle.host?.hostname??'—'}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host?.os??'—'}</dd></div><div><dt>Последняя связь</dt><dd>{observed(vehicle.last_seen===null?null:vehicle.last_seen*1000)}</dd></div></dl>
|
||||
{!online&&vehicle.host&&<small>Сведения о БК сохранены с последнего подключения.</small>}
|
||||
</article>;
|
||||
})}
|
||||
</section>
|
||||
{controlledDevice&&<section className="contour-connected-device"><span className="section-eyebrow">ПОДКЛЮЧЁННОЕ УСТРОЙСТВО ОПЕРАТОРА</span><strong>{controlledDevice.displayName}</strong><small>{deviceControlConnectivity==='degraded'?'Поток данных нарушен':'Управляющая связь подтверждена'}</small></section>}
|
||||
{snapshot?.errors.map(error=><p key={error} className="contour-health-error" role="status">{error}</p>)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -431,7 +431,7 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
case "missions":
|
||||
return <MissionPlannerWorkspace openView={props.navigation.openView} headerToolsHost={props.headerToolsHost} />;
|
||||
case "vehicles":
|
||||
return <VehiclesWorkspace createRequest={props.fleetCreateRequest} headerToolsHost={props.headerToolsHost} />;
|
||||
return <VehiclesWorkspace headerToolsHost={props.headerToolsHost} headerTitleHost={props.headerTitleHost} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "contour-health":
|
||||
|
||||
@@ -37,8 +37,8 @@ export interface LaboratoryViewAction {
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
launchProfile?: WorkspaceLaunchProfile;
|
||||
fleetCreateRequest?: number;
|
||||
headerToolsHost?: HTMLElement | null;
|
||||
headerTitleHost?: HTMLElement | null;
|
||||
definition: WorkspaceDefinition;
|
||||
state: MissionRuntimeState | null;
|
||||
backendStatus: BackendStatus;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import {Button,Icon,IconButton,ResourceRow,StatusBadge,Window} from '@nodedc/ui-react';
|
||||
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||
|
||||
export const boardStatus=(item:Vehicle,available=true)=>item.enrollment==='pending'?'Подтверждаем привязку':item.enrollment==='revoked'?'Не привязан':item.enrollment==='failed'?'Привязка не завершена':item.connectivity==='online'?(available?'В сети':'Нет свежих данных'):'Нет связи';
|
||||
export function BoardComputerRow({vehicle,enabled,showingSavedDevices,onSettings,onMonitor,onAdd,open,onToggle}:{vehicle:Vehicle;enabled:boolean;showingSavedDevices:boolean;onSettings:()=>void;onMonitor:()=>void;onAdd?:()=>void;open:boolean;onToggle:()=>void}){
|
||||
const label=boardStatus(vehicle,enabled);
|
||||
return <ResourceRow icon={<Icon name="apps"/>} title="Бортовой компьютер" description={vehicle.host?.hostname||'Mission Core Node'} metadata={`${label}${showingSavedDevices?' (показаны последние сохранённые устройства)':''}`} statusPlacement="leading"
|
||||
status={<StatusBadge variant="indicator" tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'} aria-label={label} title={label}/>}
|
||||
actions={<>{onAdd&&<IconButton label="Добавить устройство к БК" onClick={onAdd}><Icon name="plus"/></IconButton>}<IconButton label="Настройки бортового компьютера" onClick={onSettings}><Icon name="settings"/></IconButton><IconButton label="Мониторинг системы БК" onClick={onMonitor}><Icon name="activity"/></IconButton><IconButton label={open?'Свернуть устройства БК':'Показать устройства БК'} aria-expanded={open} onClick={onToggle}><Icon name={open?'chevron-down':'chevron-right'}/></IconButton></>}/>;
|
||||
}
|
||||
export function BoardComputerSettings({vehicle,enabled,onClose,onRevoke}:{vehicle:Vehicle;enabled:boolean;onClose:()=>void;onRevoke:()=>void}){
|
||||
return <Window open title="Бортовой компьютер" subtitle={vehicle.host?.hostname||'Mission Core Node'} onClose={onClose}>
|
||||
<div className="fleet-form"><StatusBadge tone={enabled?'success':vehicle.enrollment==='pending'?'warning':'neutral'}>{boardStatus(vehicle,enabled)}</StatusBadge>
|
||||
{vehicle.notice&&<p role="status">{vehicle.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{vehicle.node_id}</dd></div><div><dt>Последняя связь</dt><dd>{vehicle.last_seen?new Date(vehicle.last_seen*1000).toLocaleString('ru-RU'):'Соединение ещё не получено'}</dd></div>
|
||||
{vehicle.host&&<><div><dt>Имя БК в системе</dt><dd>{vehicle.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{vehicle.host.os}</dd></div><div><dt>Архитектура</dt><dd>{vehicle.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{vehicle.host.cpus}</dd></div><div><dt>Память</dt><dd>{vehicle.host.memory_kib?`${(vehicle.host.memory_kib/1048576).toFixed(1)} ГиБ`:'Нет сведений'}</dd></div></>}
|
||||
</dl>
|
||||
{vehicle.enrollment!=='revoked'&&<Button onClick={onRevoke}>Отозвать привязку БК</Button>}
|
||||
</div>
|
||||
</Window>;
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
|
||||
import {useState} from 'react';
|
||||
import {ActivityIndicator,Button,Icon,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||
import {ActivityIndicator,Button,ResourceList,ResourceRow,Select,SettingsCard,StatusBadge,TextField,ToastStack} from '@nodedc/ui-react';
|
||||
import {useBoardMonitor,monitorSeries,monitorValue} from '../../core/fleet/boardMonitor';
|
||||
import {TelemetrySeries} from '../../components/system/TelemetrySeries';
|
||||
import '../../styles/system-telemetry.css';
|
||||
@@ -7,7 +8,7 @@ import './board-monitor.css';
|
||||
|
||||
const eventLabels:Record<string,string>={'ui-error':'Ошибка интерфейса','ui-rejection':'Ошибка асинхронной операции','ui-render-error':'Ошибка представления','system-oom':'Нехватка памяти','gpu-reset':'Сброс GPU','disk-error':'Ошибка диска','usb-disconnected':'Отключение USB','usb-error':'Ошибка USB','service-failed':'Сбой службы','ui-process-exit':'Завершение процесса окна','ui-load-failed':'Ошибка загрузки окна'};
|
||||
const time=(value:number)=>new Date(value*1000).toLocaleString('ru-RU');
|
||||
export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string;back:()=>void}) {
|
||||
export function BoardMonitorView({vehicle,name,back,headerToolsHost}:{vehicle:string;name:string;back:()=>void;headerToolsHost?:HTMLElement|null}) {
|
||||
const [metric,setMetric]=useState('cpu.usage'),[group,setGroup]=useState('CPU'),[window,setWindow]=useState(900),[end,setEnd]=useState<number|null>(null),[mode,setMode]=useState<'min'|'max'|'mean'>('max');
|
||||
const {value,error,dismissError}=useBoardMonitor(vehicle,metric,window,end);
|
||||
const definitions=value?.definitions??{},definition=definitions[metric];
|
||||
@@ -15,7 +16,7 @@ export function BoardMonitorView({vehicle,name,back}:{vehicle:string;name:string
|
||||
const metrics=Object.entries(definitions).filter(([,item])=>item.group===group);
|
||||
const latest=value?.latest;
|
||||
return <div className="board-monitor">
|
||||
<div><Button onClick={back}><Icon name="chevron-left"/>К аппарату</Button></div>
|
||||
<WorkspaceBackButton label="К аппарату" onClick={back} host={headerToolsHost}/>
|
||||
<SettingsCard title="Мониторинг системы БК" description={name} actions={<StatusBadge tone={value?.fresh&&!error?'success':'neutral'}>{value?.fresh&&!error?'Данные поступают':'Нет свежих данных'}</StatusBadge>}>
|
||||
{latest&&<p>Измерено на БК: {time(latest.at)}. {value?.storage==='ready'?'Локальная история записывается.':'Запись локальной истории недоступна.'} {value?.backlog?'История догружается в Mission Core.':''}</p>}
|
||||
{value?.database_bytes!=null&&<p>Архив на БК: {monitorValue(value.database_bytes,'bytes')} из {monitorValue(value.database_budget_bytes,'bytes')}. {value.archive_since?`Копия в Mission Core с ${time(value.archive_since)}.`:''}</p>}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import {useId,useState,type FormEvent} from 'react';
|
||||
import {Button,Select,SettingsCard,TextAreaField,TextField,StatusBadge} from '@nodedc/ui-react';
|
||||
import {fleetRequest,type FleetPreview,type Vehicle} from '../../core/fleet/useFleet';
|
||||
import type {SensorEnrollmentProps} from '../../../../../packages/sensor-ui/src/extensions';
|
||||
|
||||
export const platforms=[{value:'ugv',label:'Наземный (UGV)'},{value:'uav',label:'Воздушный (UAV)'},{value:'stationary',label:'Стационарный'},{value:'other',label:'Другой'}];
|
||||
export const platformLabel=(value:string)=>platforms.find(item=>item.value===value)?.label??value;
|
||||
|
||||
/** The invitation admits a computer; an existing apparatus keeps its identity. */
|
||||
export function NodeInvitationForm({vehicle,onClose,onComplete,renderWindow}:{vehicle?:Vehicle;onClose:()=>void;onComplete:(vehicle:Vehicle)=>void;renderWindow:SensorEnrollmentProps['renderWindow']}){
|
||||
const form=useId();
|
||||
const [revision]=useState(vehicle?.revision);
|
||||
const [code,setCode]=useState(''),[name,setName]=useState(''),[platform,setPlatform]=useState('ugv');
|
||||
const [preview,setPreview]=useState<FleetPreview|null>(null),[pending,setPending]=useState(false),[error,setError]=useState('');
|
||||
async function inspect(event:FormEvent){
|
||||
event.preventDefault();if(pending)return;setPending(true);setError('');
|
||||
try{const value=await fleetRequest<FleetPreview>('/preview','POST',{code:code.trim()});setPreview(value);setName(current=>current||value.name);setCode('');}
|
||||
catch(e){setError(e instanceof Error?e.message:'Не удалось проверить приглашение.');}
|
||||
finally{setPending(false);}
|
||||
}
|
||||
async function attach(){
|
||||
if(!preview||pending)return;setPending(true);setError('');
|
||||
try{
|
||||
const result=vehicle
|
||||
?await fleetRequest<Vehicle>(`/${encodeURIComponent(vehicle.id)}/computer`,'POST',{preview_id:preview.preview_id,expected_revision:revision})
|
||||
:await fleetRequest<Vehicle>('','POST',{preview_id:preview.preview_id,name:name.trim(),platform});
|
||||
onComplete(result);
|
||||
}catch(e){setError(e instanceof Error?e.message:'Не удалось подключить БК.');}
|
||||
finally{setPending(false);}
|
||||
}
|
||||
const replacing=!!vehicle&&vehicle.enrollment!=='revoked';
|
||||
return renderWindow({busy:pending,actions:<><Button disabled={pending} onClick={onClose}>Отмена</Button>{preview?<Button loading={pending} disabled={pending||(!vehicle&&!name.trim())} onClick={()=>void attach()}>{vehicle?(replacing?'Заменить БК':'Подключить БК'):'Добавить аппарат'}</Button>:<Button loading={pending} type="submit" form={form} disabled={pending||!code.trim()}>Проверить БК</Button>}</>,content:
|
||||
<form id={form} className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
{!vehicle&&<><Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending}/><TextField label="Название аппарата" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={pending} autoComplete="off"/></>}
|
||||
{vehicle&&<SettingsCard title={vehicle.name} description={replacing?'Новый БК заменит текущий. Аппарат, его название и раскладки сохранятся; прежняя привязка будет отозвана.':'Подключите БК к этому аппарату по приглашению Mission Core Node.'}/>}
|
||||
{preview?<SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={()=>setPreview(null)}>Другое приглашение</Button></SettingsCard>:<TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={e=>setCode(e.target.value)}/>}
|
||||
{pending&&<StatusBadge tone="warning">{preview?'Подключение БК':'Проверка приглашения БК'}</StatusBadge>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
</form>});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {useEffect,useSyncExternalStore} from 'react';
|
||||
import {Button,Icon,IconButton,Inspector,LoadingRegion} from '@nodedc/ui-react';
|
||||
import type {BoardLayoutStore} from '../../../../../packages/sensor-ui/src/boardLayout';
|
||||
import type {SensorBoardContent} from '../../../../../packages/sensor-ui/src/SensorWorkspace';
|
||||
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||
import {VehicleIdentitySettings} from './VehicleIdentitySettings';
|
||||
import {BoardComputerRow} from './BoardComputer';
|
||||
|
||||
export function VehicleEquipment({vehicle,enabled,layout,content,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;layout:BoardLayoutStore;content:SensorBoardContent;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){
|
||||
const state=useSyncExternalStore(layout.subscribe,layout.getSnapshot);
|
||||
useEffect(()=>{void layout.load();},[layout]);
|
||||
const computerOpen=state.value.open_sections.includes('computer');
|
||||
// The former computer-section preference now controls its nested device branch.
|
||||
const changeSections=(ids:string[])=>layout.change([...ids,...(computerOpen?['computer']:[])]);
|
||||
const toggleComputer=()=>layout.change(computerOpen?state.value.open_sections.filter(id=>id!=='computer'):[...state.value.open_sections,'computer']);
|
||||
return <div className="sensor-content">
|
||||
{state.error&&<div role="alert"><p>{state.error}</p><Button onClick={()=>void layout.load()}>Повторить</Button></div>}
|
||||
<LoadingRegion loading={!state.ready&&!state.error} label="Загрузка раскладки аппарата">
|
||||
<Inspector variant="panel" openSections={state.value.open_sections.filter(id=>id!=='computer')} onOpenSectionsChange={changeSections} sections={[
|
||||
{id:'settings',label:'Настройки аппарата',icon:<Icon name="sliders"/>,disabled:!state.ready,content:<div className="sensor-content"><VehicleIdentitySettings key={vehicle.id} vehicle={vehicle} onUpdated={onUpdated}/>{content.settings}</div>},
|
||||
{id:'devices',label:'Оборудование',icon:<Icon name="apps"/>,disabled:!state.ready,content:<div className="sensor-content">
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><span className="sensor-note">Бортовой компьютер и подключённые к нему устройства</span><div className="sensor-actions"><IconButton label="Подключить бортовой компьютер" onClick={onAddComputer}><Icon name="plus"/></IconButton><IconButton label="Обновить оборудование" onClick={()=>void content.refresh()}><Icon name="refresh"/></IconButton></div></div>
|
||||
<div><BoardComputerRow vehicle={vehicle} enabled={enabled} showingSavedDevices={content.showingSavedDevices} open={computerOpen} onToggle={toggleComputer} onSettings={onSettings} onMonitor={onMonitor} onAdd={content.canAddDevice?content.addDevice:undefined}/>
|
||||
{computerOpen&&<div className="fleet-computer-devices">{content.devices}</div>}
|
||||
</div>
|
||||
</div>},
|
||||
]}/>
|
||||
</LoadingRegion>
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import {useEffect,useState} from 'react';
|
||||
import {Button,SettingsCard,TextField} from '@nodedc/ui-react';
|
||||
import {fleetRequest,type Vehicle} from '../../core/fleet/useFleet';
|
||||
|
||||
export function VehicleIdentitySettings({vehicle,onUpdated}:{vehicle:Vehicle;onUpdated:(vehicle:Vehicle)=>void}){
|
||||
const [base,setBase]=useState(vehicle),[name,setName]=useState(vehicle.name);
|
||||
const [saving,setSaving]=useState(false),[message,setMessage]=useState(''),[error,setError]=useState('');
|
||||
const dirty=name!==base.name;
|
||||
useEffect(()=>{if(!dirty&&!saving){setBase(vehicle);setName(vehicle.name);}},[vehicle,dirty,saving]);
|
||||
const reset=()=>{setBase(vehicle);setName(vehicle.name);setError('');setMessage('');};
|
||||
const save=async()=>{
|
||||
if(saving||!name.trim()||!dirty)return;
|
||||
setSaving(true);setError('');setMessage('');
|
||||
try{
|
||||
const updated=await fleetRequest<Vehicle>(`/${encodeURIComponent(vehicle.id)}`,'PATCH',{name:name.trim(),expected_revision:base.revision});
|
||||
onUpdated(updated);setBase(updated);setName(updated.name);setMessage('Название сохранено');
|
||||
}catch(reason){setError(reason instanceof Error?reason.message:'Не удалось сохранить название.');}
|
||||
finally{setSaving(false);}
|
||||
};
|
||||
return <SettingsCard title="Основные параметры">
|
||||
<form className="fleet-form" onSubmit={event=>{event.preventDefault();void save();}}>
|
||||
<TextField label="Название аппарата" value={name} maxLength={80} autoComplete="off" disabled={saving} onChange={event=>{setName(event.target.value);setMessage('');}}/>
|
||||
<div className="sensor-actions"><Button type="submit" disabled={!dirty||!name.trim()||saving}>{saving?'Сохранение…':'Сохранить'}</Button>{dirty&&<Button type="button" variant="secondary" disabled={saving} onClick={reset}>Отмена</Button>}</div>
|
||||
{error&&<p role="alert">{error}</p>}{message&&<p role="status">{message}</p>}
|
||||
</form>
|
||||
</SettingsCard>;
|
||||
}
|
||||
@@ -1,13 +1,16 @@
|
||||
import {useMemo,type ReactNode} from 'react';
|
||||
import {useMemo} 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,computer,description}:{vehicleID:string;enabled:boolean;computer:ReactNode;description:string}){
|
||||
import type {Vehicle} from '../../core/fleet/useFleet';
|
||||
import {VehicleEquipment} from './VehicleEquipment';
|
||||
export function VehicleSensors({vehicle,enabled,onAddComputer,onSettings,onMonitor,onUpdated}:{vehicle:Vehicle;enabled:boolean;onAddComputer:()=>void;onSettings:()=>void;onMonitor:()=>void;onUpdated:(vehicle:Vehicle)=>void}){
|
||||
const vehicleID=vehicle.id;
|
||||
const {registry}=useDevicePluginHost();
|
||||
const sensorContributions=registry.sensorContributions;
|
||||
const layout=useMemo(()=>boardLayout(vehicleID),[vehicleID]);
|
||||
const transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={vehicleID} transport={transport} enabled={enabled} board={{layout,computer,description}}/>;
|
||||
return <SensorWorkspace contributions={sensorContributions} createRerunHost={createIsolatedRerunHost} key={`${vehicleID}:${vehicle.node_id}`} transport={transport} enabled={enabled} readOfflineInventory renderBoard={content=><VehicleEquipment vehicle={vehicle} enabled={enabled} layout={layout} content={content} onAddComputer={onAddComputer} onSettings={onSettings} onMonitor={onMonitor} onUpdated={onUpdated}/>}/>;
|
||||
}
|
||||
|
||||
@@ -1,75 +1,41 @@
|
||||
import {useState} from 'react';
|
||||
import {createPortal} from 'react-dom';
|
||||
import {LoadingRegion,ConfirmationModal,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {WorkspaceBackButton} from '../../components/WorkspaceBackButton';
|
||||
import {fleetRequest,useFleet,type Vehicle} from '../../core/fleet/useFleet';
|
||||
import {BoardObservationCenter} from './observation/BoardObservationCenter';
|
||||
import {BoardMonitorView} from './BoardMonitorView';
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { LoadingRegion, Button, ConfirmationModal, Icon, IconButton, ResourceList, ResourceRow, Select, SettingsCard, StatusBadge, TextAreaField, TextField, Window, WindowFooterActions } from "@nodedc/ui-react";
|
||||
import { fleetRequest, useFleet, type FleetPreview, type Vehicle } from "../../core/fleet/useFleet";
|
||||
import "./fleet.css";
|
||||
import { VehicleSensors } from "./VehicleSensors";
|
||||
import {VehicleSensors} from './VehicleSensors';
|
||||
import {BoardComputerSettings,boardStatus} from './BoardComputer';
|
||||
import {NodeInvitationForm,platformLabel} from './NodeInvitationForm';
|
||||
import './fleet.css';
|
||||
|
||||
const platforms = [{ value: "ugv", label: "Наземный (UGV)" }, { value: "uav", label: "Воздушный (UAV)" }, { value: "stationary", label: "Стационарный" }, { value: "other", label: "Другой" }];
|
||||
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, headerToolsHost }: { createRequest?: number; headerToolsHost?: HTMLElement|null }) {
|
||||
const fleet = useFleet();
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [code, setCode] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [platform, setPlatform] = useState("ugv");
|
||||
const [preview, setPreview] = useState<FleetPreview | null>(null);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [monitorOpen,setMonitorOpen]=useState(false);
|
||||
const [observationOpen,setObservationOpen]=useState(false);
|
||||
const [revoking, setRevoking] = useState<Vehicle | null>(null);
|
||||
const lastCreateRequest = useRef(createRequest);
|
||||
useEffect(() => { if (createRequest !== lastCreateRequest.current) { lastCreateRequest.current = createRequest; setAdding(true); setError(""); } }, [createRequest]);
|
||||
function close() { if (pending) return; setAdding(false); setPreview(null); setCode(""); setName(""); setError(""); }
|
||||
async function inspect(event: React.FormEvent) {
|
||||
event.preventDefault(); if (pending) return;
|
||||
setPending(true); setError("");
|
||||
try { const value = await fleetRequest<FleetPreview>("/preview", "POST", { code: code.trim() }); setPreview(value); setName(current => current || value.name); setCode(""); }
|
||||
catch (error) { setError(error instanceof Error ? error.message : "Не удалось проверить приглашение."); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function add() {
|
||||
if (!preview || pending) return;
|
||||
setPending(true); setError("");
|
||||
try {
|
||||
const item = await fleetRequest<Vehicle>("", "POST", { preview_id: preview.preview_id, name: name.trim(), platform });
|
||||
await fleet.refresh(); setSelected(item.id); setAdding(false); setPreview(null); setCode(""); setName("");
|
||||
} catch (error) { setError(error instanceof Error ? error.message : "Не удалось добавить аппарат."); void fleet.refresh().catch(() => undefined); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
const detail = fleet.items?.find(item => item.id === selected);
|
||||
if(detail&&observationOpen)return <BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={!fleet.error&&detail.enrollment==="paired"&&detail.connectivity==="online"} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>;
|
||||
if(detail&&monitorOpen)return <BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)}/>;
|
||||
return <div className="fleet-workspace">
|
||||
{fleet.error && <p role="alert">{fleet.error}</p>}
|
||||
{!adding && error && <p role="alert">{error}</p>}
|
||||
{detail ? <>
|
||||
<div><Button onClick={() => {setSelected(null);}}>К списку аппаратов</Button></div>
|
||||
<VehicleSensors vehicleID={detail.id} enabled={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online"} description={detail.name} computer={<SettingsCard title={detail.name} description={`${platformLabel(detail.platform)} · с бортовым компьютером`} actions={<StatusBadge tone={!fleet.error && detail.enrollment === "paired" && detail.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(detail)}</StatusBadge>}>
|
||||
{detail.notice && <p role="status">{detail.notice}</p>}
|
||||
<dl className="fleet-facts"><div><dt>Бортовой компьютер</dt><dd>{detail.node_id}</dd></div>
|
||||
<div><dt>Последняя связь</dt><dd>{detail.last_seen ? new Date(detail.last_seen * 1000).toLocaleString("ru-RU") : "Соединение ещё не получено"}</dd></div>
|
||||
{detail.host && <><div><dt>Имя БК в системе</dt><dd>{detail.host.hostname}</dd></div><div><dt>Операционная система</dt><dd>{detail.host.os}</dd></div><div><dt>Архитектура</dt><dd>{detail.host.architecture}</dd></div><div><dt>Процессоры</dt><dd>{detail.host.cpus}</dd></div><div><dt>Память</dt><dd>{detail.host.memory_kib ? `${(detail.host.memory_kib / 1024 / 1024).toFixed(1)} ГиБ` : "Нет сведений"}</dd></div></>}
|
||||
</dl>
|
||||
<div className="fleet-board-actions"><Button onClick={()=>setMonitorOpen(true)}><Icon name="activity"/>Мониторинг системы БК</Button>
|
||||
<Button onClick={()=>setObservationOpen(true)}><Icon name="eye"/>Центр наблюдения и управления</Button>
|
||||
{detail.enrollment !== "revoked" && <Button onClick={() => setRevoking(detail)}>Отозвать привязку БК</Button>}</div>
|
||||
</SettingsCard>}/>
|
||||
</> : !fleet.items ? <LoadingRegion loading label="Получение аппаратов" /> : fleet.items.length === 0 ? <SettingsCard title="Аппаратов пока нет" description="Добавьте аппарат по приглашению из Mission Core Node на его бортовом компьютере."><Button onClick={() => setAdding(true)}>Добавить аппарат</Button></SettingsCard> : <ResourceList aria-label="Аппараты">{fleet.items.map(item => <li key={item.id}><ResourceRow icon={<Icon name="apps" />} title={item.name} description={`${platformLabel(item.platform)} · бортовой компьютер`} status={<StatusBadge tone={!fleet.error && item.enrollment === "paired" && item.connectivity === "online" ? "success" : "neutral"}>{fleet.error ? "Нет свежих данных" : statusLabel(item)}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={() => setSelected(item.id)}><Icon name="sliders" /></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={() => {setSelected(item.id);setObservationOpen(true);}}><Icon name="eye" /></IconButton></>} /></li>)}</ResourceList>}
|
||||
<Window open={adding} title="Добавить аппарат" subtitle="Подключить бортовой компьютер по приглашению Node" size="md" closeOnBackdrop={false} closeOnEscape={!pending} onClose={close} footer={<WindowFooterActions><Button disabled={pending} onClick={close}>Отмена</Button>{preview ? <Button disabled={pending || !name.trim()} onClick={() => void add()}>{pending ? "Добавление…" : "Добавить аппарат"}</Button> : <Button type="submit" form="fleet-invitation" disabled={pending || !code.trim()}>{pending ? "Проверка БК…" : "Проверить БК"}</Button>}</WindowFooterActions>}>
|
||||
<form id="fleet-invitation" className="fleet-form" onSubmit={inspect} aria-busy={pending}>
|
||||
<Select label="Способ подключения" value="node" options={[{ value: "node", label: "С бортовым компьютером" }]} onChange={() => undefined} disabled={pending} />
|
||||
<Select label="Класс аппарата" value={platform} options={platforms} onChange={setPlatform} disabled={pending} />
|
||||
<TextField label="Название аппарата" value={name} maxLength={80} onChange={event => setName(event.target.value)} disabled={pending} autoComplete="off" />
|
||||
{preview ? <SettingsCard title={preview.name} description="Идентичность БК проверена по приглашению"><dl className="fleet-facts"><div><dt>Идентификатор БК</dt><dd>{preview.node_id}</dd></div><div><dt>Система</dt><dd>{preview.host.os} · {preview.host.architecture}</dd></div><div><dt>Адрес БК</dt><dd>{preview.endpoint}</dd></div></dl><Button disabled={pending} onClick={() => setPreview(null)}>Другое приглашение</Button></SettingsCard> : <TextAreaField label="Код приглашения из Node" value={code} rows={5} maxLength={4096} spellCheck={false} autoComplete="off" disabled={pending} onChange={event => setCode(event.target.value)} />}
|
||||
{error && <p role="alert">{error}</p>}
|
||||
</form>
|
||||
</Window>
|
||||
<ConfirmationModal open={revoking !== null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name ?? ""}» останется в реестре, а его БК потеряет доступ к Core. БК получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={() => setRevoking(null)} onConfirm={async () => { if (!revoking) return; try { await fleetRequest(`/${encodeURIComponent(revoking.id)}`, "DELETE"); await fleet.refresh(); setRevoking(null); } catch (error) { setError(error instanceof Error ? error.message : "Не удалось отозвать привязку."); throw error; } }} />
|
||||
export function VehiclesWorkspace({headerToolsHost,headerTitleHost}:{headerToolsHost?:HTMLElement|null;headerTitleHost?:HTMLElement|null}){
|
||||
const fleet=useFleet();
|
||||
const [selected,setSelected]=useState<string|null>(null);
|
||||
const [adding,setAdding]=useState(false),[monitorOpen,setMonitorOpen]=useState(false),[observationOpen,setObservationOpen]=useState(false),[settingsOpen,setSettingsOpen]=useState(false);
|
||||
const [revoking,setRevoking]=useState<Vehicle|null>(null),[error,setError]=useState('');
|
||||
const detail=fleet.items?.find(item=>item.id===selected);
|
||||
const enabled=!!detail&&!fleet.error&&detail.enrollment==='paired'&&detail.connectivity==='online';
|
||||
const refresh=()=>{void fleet.refresh().catch(()=>setError('Не удалось обновить аппарат. Повторите получение сведений.'));};
|
||||
const complete=(item:Vehicle)=>{setSelected(item.id);setAdding(false);refresh();};
|
||||
const addButton=<IconButton label="Добавить аппарат" onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>;
|
||||
const headerAdd=!detail&&(headerToolsHost?createPortal(addButton,headerToolsHost):addButton);
|
||||
const titleSuffix=detail?` — ${detail.name} / ${platformLabel(detail.platform)}`:'';
|
||||
const observationButton=<IconButton label="Центр наблюдения и управления" onClick={()=>setObservationOpen(true)}><Icon name="eye"/></IconButton>;
|
||||
const headerObservation=detail&&!observationOpen&&!monitorOpen&&(headerToolsHost?createPortal(observationButton,headerToolsHost):observationButton);
|
||||
const content=detail&&observationOpen?<BoardObservationCenter key={detail.id} vehicleID={detail.id} name={detail.name} enabled={enabled} back={()=>{setObservationOpen(false);setSelected(null);}} configure={()=>setObservationOpen(false)} headerToolsHost={headerToolsHost}/>
|
||||
:detail&&monitorOpen?<BoardMonitorView vehicle={detail.id} name={detail.name} back={()=>setMonitorOpen(false)} headerToolsHost={headerToolsHost}/>
|
||||
:<div className="fleet-workspace">
|
||||
{detail?<>
|
||||
<WorkspaceBackButton label="К списку аппаратов" onClick={()=>setSelected(null)} host={headerToolsHost}/>
|
||||
<VehicleSensors onUpdated={fleet.acceptVehicle} vehicle={detail} enabled={enabled} onAddComputer={()=>setAdding(true)} onSettings={()=>setSettingsOpen(true)} onMonitor={()=>setMonitorOpen(true)}/>
|
||||
</>:!fleet.items?<LoadingRegion loading={!fleet.error} label="Получение аппаратов"/>:fleet.items.length===0?<SettingsCard title="Аппаратов пока нет" description="Создайте аппарат и подключите его бортовой компьютер по приглашению Mission Core Node."/>:<ResourceList aria-label="Аппараты">{fleet.items.map(item=><li key={item.id}><ResourceRow icon={<Icon name="apps"/>} title={item.name} description={platformLabel(item.platform)} status={<StatusBadge tone={!fleet.error&&item.enrollment==='paired'&&item.connectivity==='online'?'success':'neutral'}>{fleet.error?'Нет свежих данных':`БК: ${boardStatus(item)}`}</StatusBadge>} actions={<><IconButton label={`Конфигурация: ${item.name}`} onClick={()=>setSelected(item.id)}><Icon name="sliders"/></IconButton><IconButton label={`Центр наблюдения и управления: ${item.name}`} onClick={()=>{setSelected(item.id);setObservationOpen(true);}}><Icon name="eye"/></IconButton></>}/></li>)}</ResourceList>}
|
||||
</div>;
|
||||
return <>{headerTitleHost&&createPortal(titleSuffix,headerTitleHost)}{headerAdd}{headerObservation}{content}
|
||||
{adding&&<NodeInvitationForm key={detail?.id??'new'} vehicle={detail} onClose={()=>setAdding(false)} onComplete={complete} renderWindow={({content,actions,busy})=><Window open title={detail?'Подключить бортовой компьютер':'Добавить аппарат'} subtitle={detail?detail.name:'Создать аппарат и подключить БК по приглашению Node'} size="md" closeOnBackdrop={false} closeOnEscape={!busy} onClose={()=>{if(!busy)setAdding(false);}} footer={<WindowFooterActions>{actions}</WindowFooterActions>}>{content}</Window>}/>}
|
||||
{settingsOpen&&detail&&<BoardComputerSettings vehicle={detail} enabled={enabled} onClose={()=>setSettingsOpen(false)} onRevoke={()=>{setSettingsOpen(false);setRevoking(detail);}}/>}
|
||||
<ConfirmationModal open={revoking!==null} title="Отозвать привязку БК?" description={`Аппарат «${revoking?.name??''}» останется в реестре. БК потеряет доступ к Core и получит отзыв при следующем соединении.`} confirmLabel="Отозвать" cancelLabel="Отмена" danger onClose={()=>setRevoking(null)} onConfirm={async()=>{if(!revoking)return;try{await fleetRequest(`/${encodeURIComponent(revoking.id)}`,'DELETE');await fleet.refresh();setRevoking(null);}catch(e){setError(e instanceof Error?e.message:'Не удалось отозвать привязку.');throw e;}}}/>
|
||||
<ToastStack items={error||fleet.error?[{id:'fleet-error',title:error||fleet.error,tone:'error',durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
.fleet-workspace, .fleet-form { display: flex; flex-direction: column; gap: var(--nodedc-space-4); }
|
||||
.fleet-workspace { padding: var(--nodedc-space-4); }
|
||||
.fleet-header-tools { display:flex; align-items:center; gap:var(--nodedc-space-2); }
|
||||
.nodedc-application-panel__titles h1 > .fleet-header-title { font:inherit; color:inherit; letter-spacing:inherit; text-transform:none; }
|
||||
.fleet-facts { display: grid; gap: var(--nodedc-space-3); }
|
||||
.fleet-facts > div { display: grid; grid-template-columns: minmax(120px, 1fr) minmax(0, 2fr); gap: var(--nodedc-space-3); }
|
||||
.fleet-facts dd { margin: 0; overflow-wrap: anywhere; }
|
||||
@@ -10,4 +12,6 @@
|
||||
|
||||
.fleet-board-actions { display:flex; gap:var(--nodedc-space-3); }
|
||||
.fleet-board-actions > * { flex:1; }
|
||||
.fleet-computer-devices { padding-block-start:var(--nodedc-space-4); padding-inline-start:var(--nodedc-space-4); display:grid; gap:var(--nodedc-space-4); }
|
||||
.fleet-form p { font-size:var(--nodedc-font-size-sm); line-height:1.5; color:var(--nodedc-text-secondary); }
|
||||
@media(max-width:600px) { .fleet-board-actions { flex-wrap:wrap; } }
|
||||
|
||||
@@ -7,6 +7,10 @@ import {decodeObservationLayout,emptyObservationLayout,moveObservation,orderedOb
|
||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||
import {CameraObservation} from '../../../../../../packages/sensor-ui/src/CameraObservation';
|
||||
import {cameraObservationViews,observationKey,type SensorObservationView,type ObservationHeaderTargets} from '../../../../../../packages/sensor-ui/src/observation';
|
||||
import {unboundObservationViews} from '../../../../../../packages/sensor-ui/src/observationCatalog';
|
||||
import {sensorContribution} from '../../../../../../packages/sensor-ui/src/extensions';
|
||||
import {ObservationDeviceSettings} from './ObservationDeviceSettings';
|
||||
import {ObservationPanelHeader} from './ObservationPanelHeader';
|
||||
import {ObservationDeck,ObservationMount} from './ObservationDeck';
|
||||
import {ObservationMap} from './ObservationMap';
|
||||
import {useObservationInventory} from './useObservationInventory';
|
||||
@@ -19,62 +23,66 @@ export function BoardObservationCenter({vehicleID,name,enabled,back,configure,he
|
||||
const {registry}=useDevicePluginHost(),transport=useMemo(()=>createFleetSensorTransport(vehicleID),[vehicleID]);
|
||||
const inventory=useObservationInventory(transport),storageKey=`missioncore.observation.v1:${vehicleID}`;
|
||||
const [layout,setLayout]=useState(()=>{try{return decodeObservationLayout(JSON.parse(localStorage.getItem(storageKey)??'null'));}catch{return emptyObservationLayout();}});
|
||||
const [layersOpen,setLayersOpen]=useState(false),[focused,setFocused]=useState<string|null>(null);
|
||||
const [settingsDevice,setSettingsDevice]=useState<string|null>(null);
|
||||
const [layersOpen,setLayersOpen]=useState(false);
|
||||
const [full,setFull]=useState<string|null>(null),[expanded,setExpanded]=useState(false);
|
||||
const frame=useRef<HTMLDivElement>(null),[portal]=useState(()=>document.createElement('div'));
|
||||
useLayoutEffect(()=>{(expanded?document.body:frame.current!).append(portal);return()=>portal.remove();},[expanded,portal]);
|
||||
const mounts=useRef(new Map<string,HTMLElement>()),media=useRef(new Map<string,HTMLElement>()),headers=useRef(new Map<string,ObservationHeaderTargets>());
|
||||
const groups=(inventory?.items??[]).flatMap(device=>{
|
||||
const contribution=registry.sensorContributions.find(item=>item.kind===device.kind)?.observation;
|
||||
const contribution=sensorContribution(registry.sensorContributions,device)?.observation;
|
||||
const views=contribution?.views(device)??cameraObservationViews(device);
|
||||
return views.length?[{device,views,Session:contribution?.Session??CameraObservation}]:[];
|
||||
});
|
||||
const sources:{key:string;deviceID?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
const sources:{key:string;deviceID?:string;catalog?:string;view:SensorObservationView}[]=groups.flatMap(group=>group.views.map(view=>({key:observationKey(group.device.id,view.id),deviceID:group.device.id,view})));
|
||||
sources.push(...unboundObservationViews(inventory?.items??[],registry.sensorContributions));
|
||||
sources.push({key:'map',view:{id:'map',label:'Карта'}},{key:'rover',view:{id:'rover',label:'3D View аппарата'}},{key:'telemetry',view:{id:'telemetry',label:'Телеметрия'}});
|
||||
for(const source of sources){if(!mounts.current.has(source.key)){mounts.current.set(source.key,document.createElement('div'));media.current.set(source.key,document.createElement('div'));headers.current.set(source.key,{statusTarget:document.createElement('div'),actionsTarget:document.createElement('div')});mounts.current.get(source.key)!.className='observation-panel-mount';media.current.get(source.key)!.className='observation-media-mount';}}
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id));
|
||||
const ids=orderedObservationIDs(layout,sources.map(source=>source.key)),visible=ids.filter(id=>!layout.hidden.includes(id)&&(!sources.find(source=>source.key===id)?.catalog||layout.catalogVisible?.includes(id)));
|
||||
const effectiveFull=full&&visible.includes(full)?full:null;
|
||||
const rover=useRoverControl(vehicleID,enabled&&(visible.includes('rover')||visible.includes('telemetry')));
|
||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen;
|
||||
const drivingVisible=visible.includes('rover')&&(!effectiveFull||effectiveFull==='rover')&&!layersOpen&&!settingsDevice;
|
||||
useEffect(()=>{if(!drivingVisible)rover.stop();},[drivingVisible,rover.stop]);
|
||||
const active=sources.find(source=>source.key===focused);
|
||||
const configuredDevice=inventory?.items.find(device=>device.id===settingsDevice);
|
||||
useEffect(()=>{try{localStorage.setItem(storageKey,JSON.stringify(layout));}catch{/* The workspace remains usable without browser storage. */}},[storageKey,layout]);
|
||||
useEffect(()=>{const escape=(event:KeyboardEvent)=>{if(event.key!=='Escape'||layersOpen||document.querySelector('[role=dialog], [role=listbox]'))return;if(effectiveFull){setFull(null);event.stopImmediatePropagation();}else if(expanded){setExpanded(false);event.stopImmediatePropagation();}};window.addEventListener('keydown',escape,true);return()=>window.removeEventListener('keydown',escape,true);},[effectiveFull,expanded,layersOpen]);
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const setVisible=(id:string,show:boolean)=>setLayout(value=>({...value,catalogVisible:show?[...new Set([...(value.catalogVisible??[]),...(sources.find(source=>source.key===id)?.catalog?[id]:[])])]:(value.catalogVisible??[]).filter(key=>key!==id),hidden:show?value.hidden.filter(key=>key!==id):[...new Set([...value.hidden,id])]}));
|
||||
const layerFor=(key:string,view:SensorObservationView)=>view.layers?.find(layer=>layer.value===layout.layers[key])?.value??view.layers?.[0]?.value??view.id;
|
||||
return <>{headerToolsHost&&createPortal(<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&<IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton>}<Button onClick={configure}><Icon name="sliders"/>Конфигуратор</Button><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
const navigationActions=<><IconButton label="Конфигуратор" onClick={configure}><Icon name="sliders"/></IconButton><IconButton label="К аппаратам" onClick={back}><Icon name="chevron-left"/></IconButton></>;
|
||||
return <>{headerToolsHost&&!expanded&&createPortal(navigationActions,headerToolsHost)}<div ref={frame}/>{createPortal(<section className={`observation-center${expanded?' observation-center--expanded':''}`} aria-label={`Центр наблюдения и управления · ${name}`}>
|
||||
<header className="observation-toolbar"><div><h2>Центр наблюдения и управления</h2><span>{name}</span></div><div className="observation-actions">{(!headerToolsHost||expanded)&&navigationActions}<Button onClick={()=>{setLayersOpen(true);}}><Icon name="grid"/>Доступные слои</Button><IconButton label={expanded?'Восстановить размер центра':'Развернуть центр наблюдения и управления'} onClick={()=>setExpanded(value=>!value)}><Icon name={expanded?'minimize':'expand'}/></IconButton></div></header>
|
||||
<div className="observation-summary"><StatusBadge tone={enabled&&inventory&&inventory.fresh!==false?'success':'neutral'}>{!inventory?'Получение источников':!enabled||inventory.fresh===false?'Нет свежих данных с БК':`Устройства: ${groups.length} · Окна: ${visible.length}`}</StatusBadge>{effectiveFull&&<Button onClick={()=>setFull(null)}>Все окна</Button>}</div>
|
||||
<DragDropRoot onDragEnd={({activeId,overId})=>{if(overId)setLayout(value=>moveObservation(value,ids,activeId,overId));}}>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setFocused(null);setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
<div className="observation-deck">{!inventory?<LoadingRegion loading label="Получение визуальных источников"/>:visible.length?<ObservationDeck ids={effectiveFull?[effectiveFull]:visible} mounts={mounts.current} layout={layout} onSplit={(key,value)=>setLayout(current=>({...current,splits:{...current.splits,[key]:value}}))}/>:<div className="observation-empty"><p>Все окна скрыты.</p><Button onClick={()=>{setLayersOpen(true);}}>Доступные слои</Button></div>}</div>
|
||||
{sources.filter(source=>visible.includes(source.key)).map(source=>createPortal(
|
||||
<DropZone id={source.key} className="observation-panel">
|
||||
<DraggableItem id={source.key} className="observation-panel-drag">{({handle})=>
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface">
|
||||
<header className="observation-panel__header">
|
||||
<GlassSurface tone="soft" radius="panel" padding="none" materialRim={false} className="observation-panel__surface observation-panel__surface--overlay">
|
||||
<ObservationPanelHeader>
|
||||
{handle}
|
||||
<div className="observation-panel__title"><h3 title={source.view.label}>{source.view.label}</h3><ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.statusTarget}/></div>
|
||||
<div className="observation-actions">
|
||||
{source.view.layers&&<Select className="observation-layer-select" label={`Слой · ${source.view.label}`} value={layerFor(source.key,source.view)} options={source.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[source.key]:value}}))} variant="inline"/>}
|
||||
<ObservationMount className="observation-header-slot" element={headers.current.get(source.key)!.actionsTarget}/>
|
||||
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>{setFocused(source.key);setLayersOpen(true);}}><Icon name="settings"/></IconButton>}
|
||||
{source.deviceID&&<IconButton label={`Настройки · ${source.view.label}`} onClick={()=>setSettingsDevice(source.deviceID!)}><Icon name="settings"/></IconButton>}
|
||||
<IconButton label={`${effectiveFull===source.key?'Восстановить':'Развернуть'} · ${source.view.label}`} onClick={()=>setFull(value=>value===source.key?null:source.key)}><Icon name={effectiveFull===source.key?'minimize':'expand'}/></IconButton>
|
||||
<IconButton label={`Скрыть · ${source.view.label}`} onClick={()=>setVisible(source.key,false)}><Icon name="close"/></IconButton>
|
||||
</div>
|
||||
</header>
|
||||
</ObservationPanelHeader>
|
||||
<ObservationMount element={media.current.get(source.key)!}/>
|
||||
</GlassSurface>
|
||||
}</DraggableItem>
|
||||
</DropZone>,mounts.current.get(source.key)!,source.key))}
|
||||
</DragDropRoot>
|
||||
{groups.map(({device,views,Session})=>visible.some(key=>views.some(view=>key===observationKey(device.id,view.id)))&&<Session key={device.id} device={device} transport={transport} enabled={enabled&&inventory?.fresh!==false&&device.online} createRerunHost={createIsolatedRerunHost} views={views.map(view=>({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))!}))}/>)}
|
||||
{sources.filter(source=>source.catalog&&visible.includes(source.key)).map(source=>createPortal(<div className="observation-source"><div className="observation-source__notice"><p>{source.catalog} не подключён к аппарату.</p><Button onClick={configure}>Подключить в конфигураторе</Button></div></div>,media.current.get(source.key)!,source.key))}
|
||||
{configuredDevice&&<ObservationDeviceSettings key={configuredDevice.id} device={configuredDevice} transport={transport} contributions={registry.sensorContributions} enabled={enabled&&inventory?.fresh!==false&&configuredDevice.online} close={()=>setSettingsDevice(null)} configure={configure}/>}
|
||||
{visible.includes('map')&&createPortal(<ObservationMap vehicleID={vehicleID} header={headers.current.get('map')!}/>,media.current.get('map')!,'map-session')}
|
||||
{visible.includes('rover')&&createPortal(<RoverView vehicleID={vehicleID} controller={rover} header={headers.current.get('rover')!} active={drivingVisible}/>,media.current.get('rover')!,'rover-session')}
|
||||
{visible.includes('telemetry')&&createPortal(<RoverTelemetry state={rover.state}/>,media.current.get('telemetry')!,'telemetry-session')}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title={active?active.view.label:'Доступные слои'} size="md"><div className="observation-settings">
|
||||
{!active&&<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>}
|
||||
{ids.filter(id=>!active||id===active.key).map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
{active?.view.layers&&<Select label="Слой источника" value={layerFor(active.key,active.view)} options={active.view.layers} onChange={value=>setLayout(current=>({...current,layers:{...current.layers,[active.key]:value}}))}/>}
|
||||
<Window open={layersOpen} onClose={()=>setLayersOpen(false)} title="Доступные слои" size="md"><div className="observation-settings">
|
||||
<Select label="Расположение окон" value={layout.arrangement} options={[{value:'auto',label:'Автоматически'},{value:'columns',label:'В ряд'},{value:'rows',label:'Друг под другом'}]} onChange={value=>setLayout(current=>({...current,arrangement:value as typeof layout.arrangement}))}/>
|
||||
{ids.map(id=>{const source=sources.find(item=>item.key===id)!;return <div className="observation-layer" key={id}><Switch label={source.catalog?`${source.view.label} · Источник не подключён`:source.view.label} checked={visible.includes(id)} onChange={value=>setVisible(id,value)}/><Select label={`Позиция · ${source.view.label}`} value={String(ids.indexOf(id))} options={ids.map((_,i)=>({value:String(i),label:`Окно ${i+1}`}))} onChange={value=>setLayout(current=>moveObservation(current,ids,id,ids[Number(value)]))}/></div>;})}
|
||||
<Button onClick={()=>{setLayout(emptyObservationLayout());setFull(null);}}>Восстановить расположение</Button>
|
||||
</div></Window>
|
||||
</section>,portal)}</>;
|
||||
|
||||
@@ -14,5 +14,5 @@ export function ObservationDeck({ids,mounts,layout,onSplit,depth=0}:{ids:string[
|
||||
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&&cameraCount<ids.length?cameraCount:layout.arrangement==='auto'&&ids[0]==='map'&&ids.includes('rover')?1:Math.ceil(ids.length/2),key=JSON.stringify(ids),orientation=layout.arrangement==='rows'?'horizontal':layout.arrangement==='columns'?'vertical':depth%2?'horizontal':'vertical';
|
||||
return <SplitPane className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
return <SplitPane separatorAppearance="invisible" className="observation-split" orientation={orientation} primarySize={layout.splits[key]??50} onPrimarySizeChange={value=>onSplit(key,value)} separatorLabel="Изменить размеры окон" primary={<ObservationDeck ids={ids.slice(0,mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>} secondary={<ObservationDeck ids={ids.slice(mid)} mounts={mounts} layout={layout} onSplit={onSplit} depth={depth+1}/>}/>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import {useState} from 'react';
|
||||
import {Button,Window} from '@nodedc/ui-react';
|
||||
import {SensorDetail} from '../../../../../../packages/sensor-ui/src/SensorDetail';
|
||||
import {sensorContribution,type SensorUiContribution} from '../../../../../../packages/sensor-ui/src/extensions';
|
||||
import type {Sensor,SensorTransport} from '../../../../../../packages/sensor-ui/src/contracts';
|
||||
import {createIsolatedRerunHost} from '../../../components/rerun/isolatedRerunHost';
|
||||
|
||||
export function ObservationDeviceSettings({device,transport,contributions,enabled,close,configure}:{device:Sensor;transport:SensorTransport;contributions:readonly SensorUiContribution[];enabled:boolean;close:()=>void;configure:()=>void}) {
|
||||
const [error,setError]=useState('');
|
||||
const Detail=sensorContribution(contributions,device)?.Detail??SensorDetail;
|
||||
return <Window open title={`Настройки · ${device.name}`} size="lg" placement="end" draggable closeOnBackdrop={false} lockBodyScroll={false} trapFocus={false} onClose={close}>
|
||||
{!enabled&&<p>Нет связи с устройством. Настройки оборудования будут доступны после подключения.</p>}
|
||||
{!device.prepared&&<Button onClick={configure}>Подготовить устройство в конфигураторе</Button>}
|
||||
{error&&<p role="alert">{error}</p>}
|
||||
<Detail device={device} transport={transport} enabled={enabled} back={close} refresh={async()=>{await transport.inventory();}} failure={reason=>setError(reason instanceof Error?reason.message:reason?String(reason):'')} createRerunHost={createIsolatedRerunHost}/>
|
||||
</Window>;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {useLayoutEffect,useRef,type ReactNode} from 'react';
|
||||
|
||||
/** Reserve the actual overlay height for text/status content, including wrapped controls. */
|
||||
export function ObservationPanelHeader({children}:{children:ReactNode}) {
|
||||
const ref=useRef<HTMLElement>(null);
|
||||
useLayoutEffect(()=>{
|
||||
const header=ref.current!;
|
||||
const measure=()=>header.parentElement?.style.setProperty('--observation-overlay-header-space',`${header.getBoundingClientRect().height}px`);
|
||||
measure();const observer=new ResizeObserver(measure);observer.observe(header);
|
||||
return()=>observer.disconnect();
|
||||
},[]);
|
||||
return <header ref={ref} className="observation-panel__header">{children}</header>;
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
.observation-mount, .observation-panel-mount, .observation-media-mount, .observation-panel, .observation-split { height:100%; min-height:0; min-width:0; }
|
||||
.observation-panel__surface { display:flex; flex-direction:column; height:100%; overflow:hidden; container-type:inline-size; }
|
||||
.observation-panel__surface > .observation-mount { flex:1; }
|
||||
.observation-panel__surface--overlay { position:relative; --observation-overlay-header-space:62px; }
|
||||
.observation-panel__surface--overlay > .observation-panel__header { position:absolute; inset:0 0 auto; z-index:1; background:transparent; pointer-events:none; }
|
||||
.observation-panel__surface--overlay > .observation-panel__header > * { pointer-events:auto; }
|
||||
.observation-panel__header { display:flex; align-items:center; gap:var(--nodedc-space-2); padding:var(--nodedc-space-2); flex-shrink:0; }
|
||||
.observation-panel__header h3 { min-width:0; font-size:var(--nodedc-font-size-sm); overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.observation-source { position:relative; display:flex; flex-direction:column; height:100%; min-height:0; }
|
||||
@@ -21,7 +24,7 @@
|
||||
.observation-source__media { position:relative; flex:1; min-height:0; overflow:hidden; display:flex; align-items:center; justify-content:center; }
|
||||
.observation-source__media video, .observation-source__media canvas { width:100%; height:100%; object-fit:contain; }
|
||||
.observation-source__media[data-live="false"] > *, .observation-source__media [data-live="false"] { visibility:hidden; }
|
||||
.observation-source__notice { position:absolute; inset:30% var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
||||
.observation-source__notice { position:absolute; inset:max(30%, calc(var(--observation-overlay-header-space, 0px) + var(--nodedc-space-3))) var(--nodedc-space-4) auto; text-align:center; color:var(--nodedc-text-secondary); font-size:calc(var(--nodedc-font-size-lg) * .7); }
|
||||
.observation-rerun, .observation-rerun > div, .observation-map > div { width:100%; height:100%; }
|
||||
.observation-settings { display:flex; flex-direction:column; gap:var(--nodedc-space-4); }
|
||||
.observation-layer { display:grid; grid-template-columns:minmax(0, 1fr) 50%; align-items:center; gap:var(--nodedc-space-3); }
|
||||
@@ -43,3 +46,5 @@
|
||||
.observation-panel__title { min-width:80px; }
|
||||
.observation-panel__header > .observation-actions { max-width:100%; flex-wrap:wrap; justify-content:flex-end; margin-left:auto; }
|
||||
}
|
||||
|
||||
.observation-panel__surface--overlay .rover-telemetry { padding-top:calc(var(--observation-overlay-header-space) + var(--nodedc-space-3)); }
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {after,before,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
let server,layout,source,fanout;
|
||||
let server,layout,source,fanout,catalog;
|
||||
before(async()=>{
|
||||
server=await createServer({appType:'custom',logLevel:'silent',server:{middlewareMode:true}});
|
||||
layout=await server.ssrLoadModule('/src/core/fleet/observationLayout.ts');
|
||||
source=await server.ssrLoadModule('../../packages/sensor-ui/src/observation.ts');
|
||||
catalog=await server.ssrLoadModule('../../packages/sensor-ui/src/observationCatalog.ts');
|
||||
({observationRerun:fanout}=await server.ssrLoadModule('@xgrids-k1/frontend/sensors/observationRerun.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
@@ -41,3 +42,21 @@ test('two Rerun views share bytes, apply independent blueprints once and dispose
|
||||
assert.equal(sent[0].length,2);assert.deepEqual(started,[0,1]);host.dispose();assert.deepEqual(closed,[0,1]);
|
||||
}finally{globalThis.fetch=fetchBefore;}
|
||||
});
|
||||
|
||||
|
||||
test('known offline D455 remains selectable; connected capabilities override the catalog',()=>{
|
||||
const offline={name:'RealSense D455',model:'RealSense D455',online:false,layers:[]};
|
||||
assert.deepEqual(source.cameraObservationViews(offline)[0].layers.map(v=>v.value),['color','depth','infrared1','infrared2','points','motion']);
|
||||
assert.deepEqual(source.cameraObservationViews({...offline,online:true,layers:['depth']})[0].layers.map(v=>v.value),['depth']);
|
||||
assert.deepEqual(source.cameraObservationViews({...offline,model:'unknown'}),[]);
|
||||
});
|
||||
test('unbound catalog views have no device authority; connected instances replace templates without aliasing',()=>{
|
||||
const plugin={kind:'k1',observation:{catalog:{label:'K1',views:[{id:'points',label:'Points'},{id:'camera',label:'Camera'}]}}};
|
||||
const entries=catalog.unboundObservationViews([],[plugin]);
|
||||
assert.equal(entries.length,2);assert.notEqual(entries[0].key,entries[1].key);
|
||||
assert.equal(entries[0].deviceID,undefined);assert.equal(entries[0].device,undefined);
|
||||
assert.deepEqual(catalog.unboundObservationViews([{id:'one',kind:'k1'}],[plugin]),[]);
|
||||
assert.deepEqual(catalog.unboundObservationViews([],[plugin,plugin]),[]);
|
||||
const persisted=layout.decodeObservationLayout({...layout.emptyObservationLayout(),catalogVisible:[entries[0].key,entries[0].key,null]});
|
||||
assert.deepEqual(persisted.catalogVisible,[entries[0].key]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import {before,after,test} from 'node:test';
|
||||
import {createServer} from 'vite';
|
||||
|
||||
let server,readContourSnapshot,contourSummary,workerAvailable;
|
||||
before(async()=>{
|
||||
server=await createServer({server:{middlewareMode:true,hmr:false},optimizeDeps:{noDiscovery:true,include:[]},appType:'custom'});
|
||||
({readContourSnapshot,contourSummary,workerAvailable}=await server.ssrLoadModule('/src/core/system/contourHealth.ts'));
|
||||
});
|
||||
after(async()=>{await server?.close();});
|
||||
|
||||
const core={ok:true,service:'mission-core-control-plane',version:'test',plugin_runtimes:{ready:1,total:1}};
|
||||
const contour={contour_id:'test-compute',display_name:'Test compute',expected_node_id:'test-node'};
|
||||
const worker={schema_version:'missioncore.worker-telemetry/v1',profile:{},connection:{reachable:true,identity_matches:true},node:{node_id:'test-node'},runtimes:[],pipeline:{},network:{},history:[]};
|
||||
const vehicle={id:'test-vehicle',name:'Test rover',enrollment:'paired',connectivity:'offline'};
|
||||
function installFetch(t,{failed=[],data={}}={}){
|
||||
const calls=[];
|
||||
t.mock.method(globalThis,'fetch',async url=>{
|
||||
calls.push(url);
|
||||
if(failed.includes(url))throw new Error('unavailable');
|
||||
const defaults={
|
||||
'/api/health':core,
|
||||
'/api/v1/system/contours':{schema_version:'missioncore.compute-contour-catalog/v1',contours:[contour]},
|
||||
'/api/v1/system/contours/test-compute/telemetry?history=90':worker,
|
||||
'/api/v1/fleet':{items:[vehicle]},
|
||||
};
|
||||
assert.ok(url in defaults,`Unexpected probe: ${url}`);
|
||||
return new Response(JSON.stringify(url in data?data[url]:defaults[url]));
|
||||
});
|
||||
return calls;
|
||||
}
|
||||
test('actual registries determine node count and offline boards prevent all-online status',async t=>{
|
||||
const calls=installFetch(t);
|
||||
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||
assert.equal(snapshot.workers[0].contour.display_name,'Test compute');
|
||||
assert.deepEqual(contourSummary(snapshot),{tone:'warning',label:'Часть узлов не на связи',online:2,total:3});
|
||||
assert.equal(calls.length,4);
|
||||
});
|
||||
test('failed checks discard previous online data and distinguish unknown from empty',async t=>{
|
||||
installFetch(t,{failed:['/api/v1/fleet','/api/v1/system/contours/test-compute/telemetry?history=90']});
|
||||
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||
assert.equal(snapshot.vehicles,null);
|
||||
assert.equal(snapshot.workers[0].telemetry,null);
|
||||
assert.equal(snapshot.errors.length,2);
|
||||
assert.deepEqual(contourSummary(snapshot),{tone:'warning',label:'Проверка неполная',online:1,total:null});
|
||||
});
|
||||
test('invalid health never becomes reachable; identity mismatch and stale worker are not online',async t=>{
|
||||
installFetch(t,{data:{'/api/health':{ok:true}}});
|
||||
const snapshot=await readContourSnapshot(new AbortController().signal);
|
||||
assert.equal(snapshot.core,null);
|
||||
assert.equal(workerAvailable({...worker,connection:{reachable:true,identity_matches:false}}),false);
|
||||
assert.equal(workerAvailable({...worker,connection:{reachable:false,identity_matches:true}}),false);
|
||||
assert.equal(workerAvailable({...worker,node:null}),false);
|
||||
});
|
||||
test('an empty configured contour contains only the operator, with no fabricated worker',async t=>{
|
||||
installFetch(t,{data:{'/api/v1/system/contours':{schema_version:'missioncore.compute-contour-catalog/v1',contours:[]},'/api/v1/fleet':{items:[]}}});
|
||||
assert.deepEqual(contourSummary(await readContourSnapshot(new AbortController().signal)),{tone:'success',label:'Все узлы на связи',online:1,total:1});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import {build} from 'esbuild';
|
||||
const result=await build({entryPoints:[new URL('../src/core/roverScene/sceneDocument.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {defaultScene,environmentFields,changeField,fieldDisabled,parseScene,createMaterial,assignMaterial}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64'));
|
||||
test('all donor camera and environment fields remain exposed, frame styling excluded',t=>{
|
||||
const source=new URL('../../../../NODEDC_ENGINE_INFRA/nodedc-source/src/driveinspector/nodes/ThreeDAsset.definition.ts',import.meta.url);
|
||||
if(!fs.existsSync(source)){t.skip('Donor checkout not installed; pinned catalog remains in repo');return;}
|
||||
const donor=fs.readFileSync(source,'utf8');
|
||||
const keys=[...donor.matchAll(/key: '(postFx\.[^']+|cameraZoomStrength|cameraAutoRotate|cameraResetTs)'/g)].map(m=>m[1]);
|
||||
assert.equal(keys.length,109);
|
||||
for(const key of keys)assert.ok(environmentFields.some(f=>f.key===key),key);
|
||||
assert.ok(!environmentFields.some(f=>f.key==='nodeName'));
|
||||
});
|
||||
test('documents are independent, scene edits preserve material texture references',()=>{
|
||||
const a=defaultScene(),b=defaultScene();a.materials['rover/body'].color='#123456';assert.notEqual(a.materials['rover/body'].color,b.materials['rover/body'].color);
|
||||
const edited=changeField(a,'postFx.lighting.exposure',1.5);assert.equal(edited.postFx.lighting.exposure,1.5);assert.equal(a.postFx.lighting.exposure,defaultScene().postFx.lighting.exposure);assert.equal(edited.materials,a.materials);
|
||||
});
|
||||
test('numeric enums, bounds, invalid color and unknown paths are validated',()=>{
|
||||
const a=defaultScene();assert.equal(changeField(a,'postFx.rendering.toneMapping','3').postFx.rendering.toneMapping,3);
|
||||
assert.equal(changeField(a,'postFx.grid.step',0).postFx.grid.step,.01);
|
||||
for(const [key,value] of [['__proto__.x',1],['postFx.grid.step',NaN],['postFx.grid.colorMain','red'],['postFx.rendering.fog','invalid']])assert.equal(changeField(a,key,value),a);
|
||||
});
|
||||
test('fog interval, atlas/skybox exclusion and field availability stay coherent',()=>{
|
||||
let a=changeField(defaultScene(),'postFx.rendering.fogRange',[18,3]);assert.deepEqual(a.postFx.rendering.fogRange,[3,18]);assert.equal(a.postFx.rendering.fogStart,3);assert.equal(a.postFx.rendering.fogEnd,18);
|
||||
a=changeField(a,'postFx.skybox.enabled',true);assert.equal(a.postFx.envAtlas.enabled,false);
|
||||
assert.equal(fieldDisabled(a,environmentFields.find(f=>f.key==='postFx.envAtlas.enabled')),true);
|
||||
});
|
||||
test('scene JSON round trips materials, texture data and environment without authority',()=>{
|
||||
const a=defaultScene();a.materials['rover/box'].textures.diffuse={name:'test.png',data:'data:image/png;base64,YWJj'};a.materials['rover/box'].roughness=.28;
|
||||
assert.deepEqual(parseScene(JSON.parse(JSON.stringify(a))),a);
|
||||
assert.throws(()=>parseScene({schema:'other'}));
|
||||
assert.throws(()=>parseScene({...a,model:'wrong'}));
|
||||
assert.equal('armed' in a,false);
|
||||
});
|
||||
test('import rejects texture URLs and normalizes out-of-range PBR values',()=>{
|
||||
const a=defaultScene();a.materials['rover/box'].roughness=20;assert.equal(parseScene(a).materials['rover/box'].roughness,1);
|
||||
a.materials['rover/box'].textures.normal={name:'remote',data:'https://unknown/texture'};assert.throws(()=>parseScene(a));
|
||||
});
|
||||
test('editing excludes drive bindings and key controls without changing arm authority',()=>{
|
||||
const view=fs.readFileSync(new URL('../src/components/rover/RoverView.tsx',import.meta.url),'utf8');
|
||||
assert.match(view,/if\(!c.ready\|\|!active\|\|open\|\|editing/);assert.match(view,/c.ready&&!editing/);assert.match(view,/c.pauseInput\(\);setHeld/);
|
||||
const editor=fs.readFileSync(new URL('../src/components/rover/editor/RoverScene.tsx',import.meta.url),'utf8');assert.doesNotMatch(editor,/\.arm\(|setDemand|\/api\/v1\/fleet/);
|
||||
});
|
||||
test('export manifest retains complete geometry and all editable material groups',()=>{
|
||||
const manifest=JSON.parse(fs.readFileSync(new URL('../public/rover-scene/dcd006-v020.json',import.meta.url),'utf8'));
|
||||
assert.equal(manifest.source_object_count,1426);assert.equal(manifest.render_mesh_count,9);assert.equal(manifest.source_saved,false);assert.match(manifest.uv,/cube/);
|
||||
assert.deepEqual(Object.keys(manifest.materials).sort(),Object.keys(defaultScene().materials).map(k=>k.replace('rover/','')).sort());
|
||||
});
|
||||
|
||||
test('named custom materials and assignments survive JSON and reject unknown groups',()=>{
|
||||
let a=createMaterial(defaultScene(),'custom/example');
|
||||
a.materials['custom/example'].name='Мой металл';a=assignMaterial(a,'rover/body','custom/example');
|
||||
assert.deepEqual(parseScene(JSON.parse(JSON.stringify(a))),a);
|
||||
assert.equal(assignMaterial(a,'unknown','custom/example'),a);
|
||||
assert.equal(assignMaterial(a,'rover/body','missing'),a);
|
||||
assert.equal(createMaterial(a,'__proto__'),a);
|
||||
const legacy=defaultScene();delete legacy.assignments;assert.deepEqual(parseScene(legacy).assignments,{});
|
||||
});
|
||||
test('default environment is the original NodeDC preset without neutral overrides',()=>{
|
||||
const a=defaultScene();assert.equal(a.postFx.chromaticAberration.enabled,true);
|
||||
assert.equal(a.postFx.rendering.backgroundColor,'#111113');
|
||||
assert.equal(a.postFx.lighting.ambientIntensity,0);
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {build} from 'esbuild';
|
||||
const bundle=await build({stdin:{contents:"export * from './src/core/roverScene/sceneDocument'; export * from './src/core/roverScene/sceneMigration';",resolveDir:process.cwd()},bundle:true,write:false,platform:'node',format:'esm'});
|
||||
const {defaultScene,loadStoredScene,parseScene}=await import('data:text/javascript;base64,'+Buffer.from(bundle.outputFiles[0].contents).toString('base64'));
|
||||
// Historical settings released by the first editor, independent of migration code.
|
||||
function savedFirstEditor(){
|
||||
const d=defaultScene();delete d.environmentVersion;
|
||||
d.postFx={...d.postFx,
|
||||
lighting:{exposure:1.1,skyBoxIntensity:1,ambientIntensity:.22},
|
||||
envAtlas:{...d.postFx.envAtlas,reflectionIntensity:1,brightness:1.2},
|
||||
rendering:{...d.postFx.rendering,backgroundColor:'#202227',fog:'none',toneMapping:4},
|
||||
grid:{...d.postFx.grid,colorMain:'#747982',colorX:'#93969c',colorZ:'#93969c',alphaMain:.25,alphaX:.35,alphaZ:.35,fadeStart:2,fadeEnd:5,step:.5,diameter:12},
|
||||
chromaticAberration:{...d.postFx.chromaticAberration,enabled:false},vignette:{...d.postFx.vignette,enabled:false},grading:{...d.postFx.grading,enabled:false},bloom:{...d.postFx.bloom,enabled:false},
|
||||
directionalLight:{...d.postFx.directionalLight,intensity:1.8,azimuth:135,elevation:45},shadowCatcher:{...d.postFx.shadowCatcher,size:4,lightIntensity:.5,shadowIntensity:.25}
|
||||
};
|
||||
return d;
|
||||
}
|
||||
test('previous saved neutral preset migrates to NodeDC while retaining material work',()=>{
|
||||
const raw=savedFirstEditor();raw.materials['rover/body'].name='Мой корпус';raw.materials['rover/body'].roughness=.73;raw.materials['rover/body'].textures.diffuse={name:'my.png',data:'data:image/png;base64,YWJj'};
|
||||
raw.assignments['rover/box']='rover/body';raw.cameraZoomStrength=.1;
|
||||
const original=structuredClone(raw),r=loadStoredScene(raw);
|
||||
assert.equal(r.restoredEnvironment,true);assert.equal(r.updated,true);
|
||||
assert.deepEqual(r.document.postFx,defaultScene().postFx);
|
||||
assert.deepEqual(r.document.materials,raw.materials);assert.deepEqual(r.document.assignments,raw.assignments);assert.equal(r.document.cameraZoomStrength,.1);
|
||||
assert.deepEqual(raw,original,'backup must retain the exact input');
|
||||
assert.equal(loadStoredScene(r.document).updated,false,'migration runs once');
|
||||
});
|
||||
test('a single custom environment edit prevents a destructive reset',()=>{
|
||||
const raw=savedFirstEditor();raw.postFx.lighting.exposure=1.3;
|
||||
const r=loadStoredScene(raw);assert.equal(r.restoredEnvironment,false);assert.deepEqual(r.document.postFx,raw.postFx);
|
||||
});
|
||||
test('a deliberate new gray scene and explicit JSON imports retain their appearance',()=>{
|
||||
const raw=savedFirstEditor();raw.environmentVersion=2;
|
||||
assert.equal(loadStoredScene(raw).restoredEnvironment,false);
|
||||
delete raw.environmentVersion;
|
||||
const imported=parseScene(raw);assert.equal(imported.environmentVersion,2);assert.deepEqual(imported.postFx,raw.postFx);
|
||||
assert.equal(loadStoredScene(imported).restoredEnvironment,false);
|
||||
});
|
||||
test('already restored legacy NodeDC scene is only versioned, not altered',()=>{
|
||||
const raw=defaultScene();delete raw.environmentVersion;const r=loadStoredScene(raw);
|
||||
assert.equal(r.updated,true);assert.equal(r.restoredEnvironment,false);assert.deepEqual(r.document.postFx,raw.postFx);
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
# Rover scene material and environment editor
|
||||
|
||||
Owner request: 2026-09-25. The operator adjusts the appearance of the selected
|
||||
vehicle without leaving its observation viewport. The pencil in the existing
|
||||
3D View header enters an editing mode. A bottom material palette and movable,
|
||||
modeless material/environment inspectors apply changes immediately.
|
||||
|
||||
The earlier combined control/design modal was explicitly superseded by the
|
||||
owner. Driving settings retain their existing modal. A separate workspace
|
||||
would hide the live scene and duplicate navigation; the admitted surface is an
|
||||
edit mode of the existing rover view, with no new product root.
|
||||
|
||||
The scene document belongs to vehicle + model revision, persists in IndexedDB
|
||||
on this operator computer, includes imported texture data, and can be exported
|
||||
or imported as JSON. Loading and persistence failures remain visible; closing
|
||||
an inspector never waits for saving. This is appearance only, not a firmware,
|
||||
calibration or hardware profile. Editing pauses local drive input and prevents
|
||||
new drive input until exit; it never arms a vehicle.
|
||||
|
||||
NodeDC ThreeDAsset.definition provides the environment field catalog and
|
||||
PlayCanvas remains the renderer. The NodeDC inspector chrome is not copied.
|
||||
Design Guideline Window (modeless, draggable), Inspector, ControlRow,
|
||||
InspectorSelectField, RangeControl, ColorField, Switch, Button, IconButton and
|
||||
ToastStack own all controls and surfaces. Canonical edit/layers/globe icons
|
||||
identify edit, materials and environment. Material thumbnails are domain PBR
|
||||
previews inside canonical buttons.
|
||||
|
||||
The v020 Blender file is an immutable source. Export retains separate semantic
|
||||
material groups and UVs; clicking a rendered surface selects its shared material.
|
||||
The palette explicitly states that edits affect every surface using that
|
||||
material. Appearance groups include chassis, rubber, track steel, enclosure,
|
||||
MOLLE, motor aluminum, black hardware and accessories.
|
||||
|
||||
Acceptance: donor field coverage, scene validation/persistence isolation,
|
||||
renderer resource cleanup, edit/control exclusion, TypeScript/unit/build and
|
||||
browser QA for picking, live edits, dragging, normal/expanded layout, keyboard,
|
||||
texture loading, persistence and return to observation. No physical motion is
|
||||
part of editor acceptance.
|
||||
|
||||
Owner refinement: the 3D header overlays the viewport with no opaque strip.
|
||||
The original NodeDC environment preset remains the default and is restored
|
||||
through the environment inspector, preserving materials. Every material has an
|
||||
editable display name. A permanent final plus tile creates a new PBR material;
|
||||
explicit assignment applies it to the picked semantic group. IDs remain stable
|
||||
when renamed. Custom materials and assignments persist in the same document.
|
||||
|
||||
Verified on 2026-09-25: TypeScript/production build, all 972 frontend tests,
|
||||
normal and expanded browser layout, transparent header, the NodeDC reset control,
|
||||
GPU picking, material naming/creation/assignment, draggable inspector and Escape.
|
||||
A synthetic checker texture was uploaded, rendered and retained with its custom
|
||||
material, name and assignment after a page reload. The texture was then removed
|
||||
and the enclosure's original material reassigned. Browser error/warning log was
|
||||
empty. No motor commands were sent; the canonical service remains on port 8000.
|
||||
The inherited renderer still reduces MSAA to one sample. Local scene storage is
|
||||
not cross-computer synchronization. Both bounds are recorded in MISSIONCOR-85.
|
||||
|
||||
Follow-up: the owner still saw the withdrawn gray preset after clearing browser
|
||||
cache. Changing defaults and using the reset in one browser did not migrate
|
||||
other saved IndexedDB scene documents. Added environmentVersion 2 and a narrow
|
||||
load migration: only the exact original neutral environment is replaced with
|
||||
NodeDC defaults. Material edits, textures, assignments and camera options survive.
|
||||
An atomic IndexedDB transaction retains the old document under
|
||||
`:before-environment-v2` before replacing it. Individually edited environments
|
||||
and explicit JSON imports are preserved; the version prevents future resets.
|
||||
Regression tests cover the legacy scene, material preservation, idempotence,
|
||||
custom environments, deliberate gray scenes, imports and already restored scenes.
|
||||
@@ -0,0 +1,19 @@
|
||||
# 0049 — Observation overlays, offline source catalog and section navigation
|
||||
|
||||
## Decision
|
||||
|
||||
All apparatus observation panels place titles/actions over their viewport, without a separate header strip. A ResizeObserver reserves the real header height for status and telemetry content. DG SplitPane owns the invisible separator variant: pointer hit area, keyboard resize and keyboard focus remain available. Ratios still use the existing per-vehicle JSON layout.
|
||||
|
||||
The apparatus detail and board monitor return through a shared circular header action. Explicit left-menu navigation remounts the workspace entry view, including repeated selection; persisted configuration and window arrangements are retained. Scene tool windows and recorded-session overview close. System contour selection returns to its default modules view. Leaving rover control invokes its existing cleanup/stop.
|
||||
|
||||
## Sources and authority
|
||||
|
||||
A known D455 with an empty offline capability list remains available as a camera viewport with its supported layer choices. A published nonempty capability list takes precedence. Detailed camera settings reuse SensorDetail; its capabilities and preview are not requested from an offline board.
|
||||
|
||||
Plugins may declare a view catalog independent of physical inventory. K1 declares point cloud (3D), point cloud (top), and camera. These optional windows are listed even before enrollment, start hidden, and display an explicit disconnected-source message when selected. They never fabricate a Sensor, session, device identifier or command authority. The catalog choice is persisted in bounded catalogVisible layout data. Actual devices retain distinct device-ID-based views and replace unbound catalog entries; no ambiguous model-to-instance assignment is inferred. K1 acquisition remains an explicit operator action in its existing configurator.
|
||||
|
||||
Source gear actions open the existing device settings in a draggable window. The layers dialog continues to control visibility and order.
|
||||
|
||||
## Validation scope
|
||||
|
||||
Build and offline UI acceptance cover section reset/back navigation, overlay headers, invisible pointer/keyboard resizing, layer visibility/persistence, disconnected notices and settings access. Unit cases cover D455 capability precedence, ambiguous plugin rejection, template/physical-device separation and bounded catalog persistence. The board was offline during this change: live RealSense/XGRIDS delivery and hardware settings are not claimed as accepted.
|
||||
@@ -0,0 +1,105 @@
|
||||
# 0050 — Apparatus composition and its onboard device branch
|
||||
|
||||
## Operator job and decision
|
||||
|
||||
Owner-approved 25.09.2026: configure the rover as a whole while seeing which
|
||||
computer owns each device. Keep the apparatus identity above its computer.
|
||||
The apparatus has a compact name/class summary, apparatus settings, and an
|
||||
Equipment section. The current single computer is an expandable resource row;
|
||||
its devices appear beneath it. Host facts and unpairing belong to its settings.
|
||||
|
||||
The previously proposed flat peer list of computer and cameras was rejected:
|
||||
it hides the actual USB/runtime dependency. The present implementation supports
|
||||
one onboard computer; multiple computers and flight-controller transports are
|
||||
not claimed. A future controller's placement must follow its actual connection,
|
||||
not an invented independent link. Actuation role is separate from topology.
|
||||
|
||||
## Actions and persistence
|
||||
|
||||
The outer plus is owned by the vehicle list and creates an apparatus. Inside a
|
||||
vehicle it is absent. Equipment has an attach-computer action and inventory
|
||||
refresh; the computer row has wireless-device enrollment, settings and monitor.
|
||||
USB devices are discovered by Node. Wireless enrollment reuses plugin-owned
|
||||
forms, including K1, and cannot run while the owning computer is unavailable.
|
||||
|
||||
ResourceRow, Inspector, IconButton, SettingsCard and Window are existing DG
|
||||
primitives. No new visual primitive or product root is introduced. The stored
|
||||
v1 board-layout ids remain compatible: settings/devices control the two outer
|
||||
sections; computer now controls the nested branch. Collapsing an outer section
|
||||
does not erase the nested preference. Scene and observation layout identities
|
||||
remain keyed by the apparatus.
|
||||
|
||||
Owner refinement later on 25.09: the apparatus name and class are appended to
|
||||
the shared panel title. The separate summary card is removed; observation and
|
||||
configuration are opposite header icon actions. Computer branch collapse is
|
||||
the rightmost row action. When cached devices are shown, their explanation is
|
||||
inline after the computer status rather than a separate offline card.
|
||||
|
||||
Offline Core reads only its cached fleet inventory, never queries the absent
|
||||
hardware or shows an endless discovery spinner. Cached devices stay visible;
|
||||
their current connection is unconfirmed, and mutations stay disabled.
|
||||
|
||||
Apparatus metadata is an exception to the hardware mutation gate: the name is
|
||||
editable in Apparatus settings while the board is offline. PATCH /fleet/{id}
|
||||
accepts only name and expected_revision through the local-operator boundary.
|
||||
The transaction preserves apparatus/node identity, binding, platform, device
|
||||
assignments and history. It checks revisions to prevent a stale editor from
|
||||
overwriting another change, emits the existing fleet snapshot event, and
|
||||
returns public metadata. Repeating the already-saved name is idempotent.
|
||||
The list, panel name/type title, monitor and observation center use this same
|
||||
registry name. No board request or OS configuration is involved.
|
||||
|
||||
The Park navigation now contains only Contour status and Vehicles. Sensor and
|
||||
composition catalog placeholders are removed. Contour status reads local Core
|
||||
health, the configured compute-contour catalog and its telemetry, plus the
|
||||
fleet registry. Simulation gateway fallback identifiers are not inventory.
|
||||
Node counts are derived from these registries; unavailable/invalid checks are
|
||||
explicitly unknown, not empty or online. Worker availability requires both
|
||||
reachability and identity match, with backend telemetry freshness checks;
|
||||
board status uses the authenticated fleet heartbeat expiry. Polling is bounded,
|
||||
sequential and stopped when the workspace unmounts.
|
||||
|
||||
## Computer replacement boundary
|
||||
|
||||
POST /fleet/{vehicle}/computer consumes a verified Node invitation and an
|
||||
expected vehicle revision. The vehicle id/name/class/creation time stay intact.
|
||||
The prior private binding row is archived transactionally in board_history;
|
||||
monitor/configuration archives remain keyed by their original node identity.
|
||||
The new computer does not inherit commands, sensor sessions, calibration or
|
||||
claims of physical wiring from the previous computer. A new verified inventory
|
||||
is required. Existing devices bound to another apparatus are not transferred.
|
||||
|
||||
Expired previews, stale revisions, pending pairing, active rover control and
|
||||
unexpired device operations block replacement. Verification/listener failures
|
||||
before commit leave the original row intact. At explicit replacement commit,
|
||||
the old binding loses authority; handshake completion of the new binding is
|
||||
shown separately. Failure does not silently restore or authorize the old one.
|
||||
Each stream frame rechecks its binding. Command admission resolves the node
|
||||
under the same fleet lock as replacement. Preview retry is idempotent only for
|
||||
the exact resulting binding. This is not live hot-swapping acceptance.
|
||||
|
||||
## Validation
|
||||
|
||||
Synthetic pairing tests cover preserved vehicle identity/layout, archived old
|
||||
binding, old-certificate rejection, duplicate node rejection, stale/expired
|
||||
previews, idempotency, active operation rejection and transactional rollback.
|
||||
Frontend checks and offline browser QA cover both plus scopes, nested folding,
|
||||
offline inventory, K1 enrollment state, host settings and back navigation.
|
||||
Acceptance on 25.09.2026: all 69 fleet tests, 978 Control Station tests and
|
||||
the Node UI contract test passed. Both production builds passed TypeScript.
|
||||
Browser QA on canonical port 8000 verified normal/expanded presentation,
|
||||
Escape dismissal, list/computer/device action scopes, cached devices, and
|
||||
folding persistence across navigation and a full page reload. The existing
|
||||
LaunchAgent restarted the sole Core process; exact health recovered.
|
||||
The real board is powered off: new pairing/replacement and live device operation
|
||||
require subsequent hardware acceptance; no such action is performed here.
|
||||
|
||||
Later name/status acceptance on the same date: 44 focused identity, pairing
|
||||
and replacement tests passed, as did all 982 Control Station unit tests,
|
||||
TypeScript and the production build. Final presentation refinements passed
|
||||
the architecture/status checks again (8 tests), typecheck and build. Browser
|
||||
QA covered normal/expanded views, empty-name rejection, cancellation, a
|
||||
temporary rename visible in the list/header/observation center, persistence
|
||||
after reload and restoration of the original name. The live status view
|
||||
showed Core reachable, a configured worker without fresh telemetry and the
|
||||
powered-off board without a link. No actuator or device commands were sent.
|
||||
@@ -3,11 +3,11 @@ import {LoadingRegion,Icon,IconButton,SettingsCard,StatusBadge} from '@nodedc/ui
|
||||
import {perform,type Sensor,type SensorTransport} from './contracts';
|
||||
import {PointViewport} from './PointViewport';
|
||||
interface Telemetry {points?:number[];motion?:Record<string,{x:number;y:number;z:number}>;acquisition?:string;frame?:{observed_at:string}}
|
||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage}:{device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||
export function LiveViewport({device,layer,transport,failure,title,note,inactiveMessage,enabled=true}:{enabled?:boolean;device:Sensor;layer:string;transport:SensorTransport;failure:(e:unknown)=>void;title?:string;note?:string;inactiveMessage?:string}){
|
||||
const video=useRef<HTMLVideoElement>(null);const frame=useRef<HTMLDivElement>(null);const [expanded,setExpanded]=useState(false);
|
||||
const [state,setState]=useState('Подключение просмотра');const [telemetry,setTelemetry]=useState<Telemetry>({});
|
||||
useEffect(()=>{
|
||||
if(device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
if(!enabled||device.snapshot.acquisition!=='streaming'){setState('Захват остановлен');return;}
|
||||
let cancelled=false;let terminal=false;let peerID='';let timer:ReturnType<typeof setInterval>|undefined;let last=Date.now();let frameHandle:number|undefined;let decoded=0;
|
||||
const live=()=>{if(cancelled||terminal)return;last=Date.now();setState(device.playback_id?'Исходная запись':'Прямой эфир');};
|
||||
const frameSeen=()=>{live();if(!cancelled)frameHandle=video.current?.requestVideoFrameCallback(frameSeen);};
|
||||
@@ -28,9 +28,9 @@ export function LiveViewport({device,layer,transport,failure,title,note,inactive
|
||||
await pc.setRemoteDescription({type:'answer',sdp:answer.sdp});
|
||||
}catch(e){if(!cancelled){terminal=true;setState('Просмотр недоступен');failure(e);}}}
|
||||
void connect();return()=>{cancelled=true;if(timer)clearInterval(timer);if(frameHandle!==undefined)video.current?.cancelVideoFrameCallback(frameHandle);pc.close();if(peerID)void perform(transport,device,'close-peer',{peer_id:peerID}).catch(()=>undefined);};
|
||||
},[device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||
},[enabled,device.id,device.snapshot.context.session_id,device.snapshot.acquisition,device.playback_id,layer,transport]);
|
||||
useEffect(()=>{const escape=(e:KeyboardEvent)=>{if(e.key==='Escape')setExpanded(false);};document.addEventListener('keydown',escape);return()=>document.removeEventListener('keydown',escape);},[]);
|
||||
const active=device.snapshot.acquisition==='streaming';
|
||||
const active=enabled&&device.snapshot.acquisition==='streaming';
|
||||
return <div ref={frame} className={expanded?'sensor-viewer sensor-viewer-expanded':'sensor-viewer'}><SettingsCard title={title??(layer==='points'?'Облако точек':layer==='motion'?'Движение':({color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2'}[layer]??layer))} actions={<><StatusBadge tone={active&&(state==='Прямой эфир'||state==='Исходная запись')?'success':'neutral'}>{state}</StatusBadge><IconButton label={expanded?'Восстановить размер просмотра':'Развернуть просмотр'} onClick={()=>setExpanded(!expanded)}><Icon name={expanded?'minimize':'expand'}/></IconButton></>}>
|
||||
{!active?<p>{inactiveMessage??'Нажмите «Начать просмотр» или «Начать запись» для запуска камеры.'}</p>:<>
|
||||
<LoadingRegion loading={state==='Подключение просмотра'||state==='Ожидание изображения'} label={state}>
|
||||
|
||||
@@ -5,7 +5,7 @@ import {LiveViewport} from './LiveViewport';
|
||||
export function SensorDetail({device,transport,back,refresh,failure,enabled=true}:{enabled?:boolean;device:Sensor;transport:SensorTransport;back:()=>void;refresh:()=>Promise<void>;failure:(e:unknown)=>void}){
|
||||
const [detail,setDetail]=useState<Sensor|null>(null);const [selected,setSelected]=useState<Record<string,string>>({});const [layer,setLayer]=useState('color');const [pendingAction,setPendingAction]=useState<string|null>(null);const pending=pendingAction!==null;const [option,setOption]=useState('');const [optionValue,setOptionValue]=useState('');
|
||||
async function load(){const value=await perform<Sensor>(transport,device,'details');setDetail(value);return value;}
|
||||
useEffect(()=>{let live=true;if(device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
useEffect(()=>{let live=true;if(enabled&&device.online&&device.prepared)void load().then(value=>{if(live){const selection:Record<string,string>={};for(const p of value.profiles??[])if(value.defaults?.includes(p.id))selection[p.stream+':'+p.index]=p.id;setSelected(selection);}}).catch(failure);return()=>{live=false;};},[enabled,device.online,device.id,device.prepared,device.snapshot.context.session_id]);
|
||||
async function act(action:string,parameters:Record<string,unknown>={}){if(pending||!enabled)return;failure(null);setPendingAction(action==='start'?(parameters.record?'record.start':'preview.start'):action==='replay'?'replay:'+String(parameters.recording_id):action);try{await perform(transport,device,action,parameters);await refresh();await load();}catch(e){failure(e);}finally{setPendingAction(null);}}
|
||||
const supported=(detail?.profiles??[]).filter(p=>['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)&&(p.stream!=='infrared'||[1,2].includes(p.index)));
|
||||
const groups=[...new Set(supported.map(p=>p.stream+':'+p.index))];
|
||||
@@ -21,10 +21,10 @@ export function SensorDetail({device,transport,back,refresh,failure,enabled=true
|
||||
{device.snapshot.acquisition==='stopping'&&<p>Сохранение исходной записи и проверка целостности.</p>}
|
||||
{device.recording&&device.snapshot.acquisition!=='stopping'&&<p>Идёт исходная запись на БК. Она продолжится после закрытия окна.</p>}
|
||||
</SettingsCard>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
{detail&&<><Select label="Слой камеры" value={layer} options={available.map(value=>({value,label:videoNames[value]??value}))} onChange={setLayer}/><LiveViewport enabled={enabled&&device.online} device={device} layer={layer} transport={transport} failure={failure}/>
|
||||
<SettingsCard title="Профили потоков" description="Применяются при следующем запуске захвата."><div className="sensor-fields">{groups.map(group=><Select key={group} label={group} value={selected[group]??''} options={[{value:'',label:'Выключен'},...(detail.profiles??[]).filter(p=>p.stream+':'+p.index===group&&['rgb8','bgr8','z16','y8','motion_xyz32f'].includes(p.format)).map(p=>({value:p.id,label:`${p.width?p.width+' × '+p.height+' · ':''}${p.fps} Гц · ${p.format}`}))]} onChange={value=>setSelected(v=>({...v,[group]:value}))} disabled={!enabled||pending||active}/>)}</div></SettingsCard>
|
||||
<SettingsCard title="Параметры камеры"><div className="sensor-fields"><Select searchable label="Параметр" value={option} options={(detail.options??[]).map(v=>({value:v.id,label:v.label,description:v.sensor}))} onChange={id=>{setOption(id);setOptionValue(String(detail.options?.find(v=>v.id===id)?.value??''));}}/>{currentOption&&<><TextField type="number" label={`${currentOption.min} … ${currentOption.max}${currentOption.read_only?' · только чтение':''}`} min={currentOption.min} max={currentOption.max} step={currentOption.step||'any'} value={optionValue} onChange={e=>setOptionValue(e.target.value)} disabled={!enabled||pending||currentOption.read_only||!!device.playback_id}/><Button disabled={!enabled||pending||currentOption.read_only||!!device.playback_id||optionValue===''} loading={pendingAction==='option'} onClick={()=>void act('option',{id:option,value:Number(optionValue)})}>Применить параметр</Button></>}</div></SettingsCard>
|
||||
<SettingsCard title="Исходные записи">{detail.recordings?.length?detail.recordings.map(item=><div className="sensor-record" key={item.id}><span>{new Date(item.started_at).toLocaleString('ru-RU')}</span><span>{item.state==='complete'?'Запись завершена':item.state==='recording'?'Идёт запись':item.state==='finalizing'?'Сохранение записи':item.state==='interrupted'?'Запись прервана':'Запись не завершена'}</span><span>{item.bytes?`${(item.bytes/1048576).toFixed(1)} МиБ`:''}</span><IconButton label={`Открыть запись: ${new Date(item.started_at).toLocaleString('ru-RU')}`} disabled={!enabled||pending||active||item.state!=='complete'} loading={pendingAction==='replay:'+item.id} onClick={()=>void act('replay',{recording_id:item.id})}><Icon name="eye"/></IconButton></div>):<p>Записей пока нет.</p>}</SettingsCard></>}
|
||||
{!detail&&<p>{device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
{!detail&&<p>{!enabled||!device.online?'Возможности камеры будут прочитаны после подключения.':device.prepared?'Получение возможностей камеры…':'Подготовьте устройство в списке.'}</p>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {useCallback,useEffect,useRef,useState} from 'react';
|
||||
import {useCallback,useEffect,useRef,useState,type ReactNode} from 'react';
|
||||
import {LoadingRegion,Button,Icon,IconButton,ResourceList,ResourceRow,SettingsCard,StatusBadge,TextField,ToastStack,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {perform,enrolledDevice,type Sensor,type SensorInventory,type SensorTransport} from './contracts';
|
||||
import {SensorDetail} from './SensorDetail';
|
||||
@@ -11,20 +11,21 @@ import type {RerunHostFactory} from './rerunHost';
|
||||
import './sensors.css';
|
||||
import {BoardSections,type BoardSectionsProps} from './BoardSections';
|
||||
import {WirelessEnrollmentWindow} from './WirelessEnrollmentWindow';
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps}){
|
||||
export interface SensorBoardContent {settings:ReactNode;devices:ReactNode;refresh:()=>Promise<void>;addDevice:()=>void;canAddDevice:boolean;showingSavedDevices:boolean}
|
||||
export function SensorWorkspace({transport,enabled=true,onDetailChange,createRerunHost,contributions=[],board,renderBoard,readOfflineInventory=false}:{transport:SensorTransport;enabled?:boolean;onDetailChange?:(open:boolean)=>void;createRerunHost?:RerunHostFactory;contributions?:readonly SensorUiContribution[];board?:BoardSectionsProps;renderBoard?:(view:SensorBoardContent)=>ReactNode;readOfflineInventory?:boolean}){
|
||||
const [adding,setAdding]=useState(false);
|
||||
const [inventory,setInventory]=useState<SensorInventory|null>(null);const [selected,setSelected]=useState<string|null>(null);const [editing,setEditing]=useState<Sensor|null>(null);const [name,setName]=useState('');const [localBusy,setBusy]=useState<ReadonlyMap<string,string>>(()=>new Map());const activeActions=useRef(new Map<string,string>());const [error,setError]=useState('');const [fresh,setFresh]=useState(false);
|
||||
const failure=useCallback((e:unknown)=>{setError(e===null?'':e instanceof Error?e.message:'Не удалось выполнить действие устройства.');},[]);
|
||||
const refresh=useCallback(async()=>{if(!enabled){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,failure]);
|
||||
const refresh=useCallback(async()=>{if(!enabled&&!readOfflineInventory){setFresh(false);return;}try{const value=await transport.inventory();setInventory(value);setFresh(enabled&&value.fresh!==false);}catch(e){setFresh(false);failure(e);}},[transport,enabled,readOfflineInventory,failure]);
|
||||
useEffect(()=>{
|
||||
if(!enabled){setFresh(false);return;}
|
||||
if(!enabled&&!readOfflineInventory){setFresh(false);return;}
|
||||
let active=true;let fallback:ReturnType<typeof setInterval>|undefined;
|
||||
const unavailable=()=>{if(!active)return;setFresh(false);if(!fallback)fallback=setInterval(()=>void refresh(),3000);};
|
||||
void refresh();
|
||||
const close=transport.subscribe?.(value=>{if(!active)return;if(fallback){clearInterval(fallback);fallback=undefined;}setInventory(value);setFresh(value.fresh!==false);},unavailable);
|
||||
const close=transport.subscribe?.(value=>{if(!active)return;if(fallback){clearInterval(fallback);fallback=undefined;}setInventory(value);setFresh(enabled&&value.fresh!==false);},unavailable);
|
||||
if(!close)unavailable();
|
||||
return()=>{active=false;close?.();if(fallback)clearInterval(fallback);};
|
||||
},[transport,enabled,refresh]);
|
||||
},[transport,enabled,readOfflineInventory,refresh]);
|
||||
async function action(device:Sensor,action:string,parameters:Record<string,unknown>={}){
|
||||
if(activeActions.current.has(device.id))return;
|
||||
activeActions.current.set(device.id,action);setBusy(new Map(activeActions.current));setError('');
|
||||
@@ -41,25 +42,27 @@ export function SensorWorkspace({transport,enabled=true,onDetailChange,createRer
|
||||
setInventory(value);setFresh(true);setSelected(target.id);setAdding(false);
|
||||
}
|
||||
const device=inventory?.items.find(v=>v.id===selected);
|
||||
const connected=inventory?.items.filter(v=>v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
||||
const connected=inventory?.items.filter(v=>readOfflineInventory||v.online||sensorContribution(contributions,v)?.retainOffline)??[];
|
||||
const editingCurrent=inventory?.items.find(v=>v.id===editing?.id);
|
||||
useEffect(()=>{onDetailChange?.(!!device);},[!!device,onDetailChange]);
|
||||
const Detail=device?(sensorContribution(contributions,device)?.Detail??(device.kind?null:SensorDetail)):null;
|
||||
const inventoryView=<>
|
||||
<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>
|
||||
{!inventory?<LoadingRegion loading label="Получение устройств БК"/>:connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=inventory.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state));const busy=!!operation||localBusy.has(item.id);
|
||||
{!renderBoard&&<div className="sensor-actions sensor-inventory-toolbar"><StatusBadge tone={fresh&&enabled?'success':'neutral'}>{!enabled?'БК недоступен':fresh?'Сведения с БК':'Нет свежих сведений'}</StatusBadge><div className="sensor-actions">{transport.enrollment&&wirelessContributions(contributions).length>0&&<IconButton label="Подключить беспроводное устройство к БК" disabled={!enabled} onClick={()=>setAdding(true)}><Icon name="plus"/></IconButton>}<IconButton label="Обновить устройства" disabled={!enabled&&!readOfflineInventory} onClick={()=>{void refresh();}}><Icon name="refresh"/></IconButton></div></div>}
|
||||
<ResourceList aria-label="Устройства БК">{connected.map(item=>{
|
||||
const operation=enabled&&fresh?inventory?.operations?.find(v=>v.device_id===item.id&&['queued','running'].includes(v.state)):undefined;const busy=!!operation||localBusy.has(item.id);
|
||||
const pending=localBusy.get(item.id)??operation?.action_id;
|
||||
const configured=item.configured??item.snapshot.enrollment==='enrolled';
|
||||
const prep=devicePreparation(inventory,item);
|
||||
const prep=devicePreparation(inventory!,item);
|
||||
const status=(sensorContribution(contributions,item)?.status??sensorStatus)(item,enabled&&fresh);const label=busy?(operation?.action_id==='prepare'?'Подготовка устройства':'Выполняется команда'):status.label;
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>}
|
||||
<SensorPreparations inventory={inventory}/>
|
||||
return <li key={item.id}><ResourceRow icon={<Icon name={sensorContribution(contributions,item)?.icon??'camera'}/>} title={item.name} description={item.model} metadata={<span>{item.connection_label||`USB ${item.usb}`}</span>} statusPlacement={configured?'leading':'trailing'} aria-busy={busy} progress={busy&&pending==='prepare'?preparationProgress(prep,label):undefined} status={<StatusBadge variant={configured?'indicator':'badge'} tone={status.tone} aria-label={label} title={label}>{configured?null:label}</StatusBadge>} actions={<><SensorRowActions device={item} actions={sensorContribution(contributions,item)?.rowActions?.(item)} enabled={enabled&&fresh} busy={busy} pending={pending} perform={(name,parameters)=>void action(item,name,parameters)}/>{!configured&&sensorContribution(contributions,item)?.supportsPreparation!==false&&<IconButton label={`Подготовить и проверить: ${item.name}`} disabled={!enabled||!fresh||busy||item.initializable===false||item.preparation_safe===false||!item.online||item.snapshot.acquisition==='streaming'||item.snapshot.acquisition==='stopping'} onClick={()=>void action(item,'prepare')}><Icon name="download"/></IconButton>}{sensorContribution(contributions,item)?.supportsRenaming!==false&&<IconButton label={`Настройки: ${item.name}`} disabled={!enabled||!fresh||busy} onClick={()=>{setEditing(item);setName(item.name);}}><Icon name="settings"/></IconButton>}{sensorContribution(contributions,item)?.detailLabel?<Button disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}>{sensorContribution(contributions,item)?.detailLabel}</Button>:<IconButton label={`Просмотр: ${item.name}`} disabled={!item.prepared||busy} onClick={()=>setSelected(item.id)}><Icon name="eye"/></IconButton>}</>}/></li>;})}</ResourceList>
|
||||
{!inventory&&enabled&&!error?<LoadingRegion loading label="Получение устройств БК"/>:!enabled?(!renderBoard?<SettingsCard title="БК недоступен" description="Показаны последние сохранённые устройства. Их состояние обновится после восстановления связи."/>:null):inventory&&connected.length===0?<SettingsCard title="Поддерживаемые устройства не обнаружены" description="Подключите устройство кабелем или добавьте беспроводное устройство через плюс."/>:null}
|
||||
<SensorPreparations inventory={enabled&&fresh?inventory:null}/>
|
||||
</>;
|
||||
const boardSettings=contributions.filter(value=>value.BoardSettings&&contributions.filter(other=>other.kind===value.kind).length===1);
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(board?<BoardSections {...board} settings={<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>} devices={inventoryView}/>:inventoryView)}
|
||||
const settingsView=<div className="sensor-content">{boardSettings.map(contribution=>{const View=contribution.BoardSettings!;return <View key={contribution.kind} inventory={inventory} transport={transport} enabled={enabled&&fresh} refresh={refresh} failure={failure} openDevice={setSelected}/>;})}{!boardSettings.length&&<p>Для подключённых устройств общие настройки пока недоступны.</p>}</div>;
|
||||
return <div className="sensor-workspace">{device?Detail?<Detail key={device.snapshot.context.session_id} enabled={enabled&&fresh} device={device} transport={transport} back={()=>setSelected(null)} refresh={refresh} failure={failure} createRerunHost={createRerunHost} reconnect={transport.enrollment?()=>setAdding(true):undefined}/>:<div className="sensor-content"><Button onClick={()=>setSelected(null)}>К устройствам</Button><SettingsCard title="Просмотр устройства недоступен" description="Интеграция этого устройства не установлена."/></div>:(renderBoard?renderBoard({settings:settingsView,devices:inventoryView,showingSavedDevices:connected.length>0&&(!enabled||!fresh),refresh,addDevice:()=>setAdding(true),canAddDevice:!!transport.enrollment&&wirelessContributions(contributions).length>0}):board?<BoardSections {...board} settings={settingsView} devices={inventoryView}/>:inventoryView)}
|
||||
<Window open={editing!==null} title="Настройки устройства" onClose={()=>setEditing(null)} footer={<WindowFooterActions><Button disabled={localBusy.has(editing?.id??'')} onClick={()=>setEditing(null)}>Отмена</Button><Button loading={localBusy.get(editing?.id??'')==='rename'} disabled={localBusy.has(editing?.id??'')||!name.trim()||!enabled||!fresh} onClick={()=>{if(editing)void action(editing,'rename',{name});}}>Сохранить</Button></WindowFooterActions>}><div className="sensor-content"><TextField label="Название устройства" value={name} maxLength={80} onChange={e=>setName(e.target.value)} disabled={localBusy.has(editing?.id??'')}/>{editing&&sensorContribution(contributions,editing)?.supportsPreparation!==false&&(editing.configured??editing.snapshot.enrollment==='enrolled')&&<ResourceRow title="Конфигурация на БК" description="Повторно развернуть и проверить встроенный драйвер устройства." actions={<Button disabled={localBusy.has(editing?.id??'')||!enabled||!fresh||!editingCurrent?.online||editingCurrent?.initializable===false||editingCurrent?.preparation_safe===false||['streaming','starting','stopping'].includes(editingCurrent?.snapshot.acquisition??'offline')} onClick={()=>{const target=editing;setEditing(null);void action(target,'prepare');}}>Обновить</Button>}/>}</div></Window>
|
||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||
{adding&&transport.enrollment&&wirelessContributions(contributions).length>0&&<WirelessEnrollmentWindow enabled={enabled} contributions={contributions} transport={transport.enrollment} onClose={()=>setAdding(false)} onChange={()=>{void refresh();}} onComplete={completeEnrollment}/>}
|
||||
<ToastStack items={error?[{id:'sensor-error',tone:'error',title:error,durationMs:null}]:[]} onDismiss={()=>setError('')}/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import {useState} from 'react';
|
||||
import {Select,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {Select,SettingsCard,Window,WindowFooterActions} from '@nodedc/ui-react';
|
||||
import {wirelessContributions,type SensorEnrollmentProps,type SensorUiContribution} from './extensions';
|
||||
|
||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete}:{
|
||||
export function WirelessEnrollmentWindow({contributions,transport,onClose,onChange,onComplete,enabled=true}:{
|
||||
contributions:readonly SensorUiContribution[];
|
||||
transport:SensorEnrollmentProps['transport'];onClose:()=>void;onChange:()=>void;
|
||||
onComplete:SensorEnrollmentProps['onComplete'];
|
||||
enabled?:boolean;
|
||||
}) {
|
||||
const [selected,setSelected]=useState('');
|
||||
const supported=wirelessContributions(contributions);
|
||||
const Enrollment=supported.find(value=>value.kind===selected)?.wirelessEnrollment?.View;
|
||||
const renderWindow:SensorEnrollmentProps['renderWindow']=({content,actions,busy=false})=>(
|
||||
<Window open title="Подключение беспроводных устройств к БК" onClose={onClose}
|
||||
<Window open title="Подключение беспроводных устройств к БК" closeOnBackdrop={false} closeOnEscape={!busy} onClose={()=>{if(!busy)onClose();}}
|
||||
footer={actions?<WindowFooterActions style={{width:'100%'}}>{actions}</WindowFooterActions>:undefined}>
|
||||
<div className="sensor-content">
|
||||
<Select label="Выбор поддерживаемого устройства" value={selected} disabled={busy}
|
||||
<Select label="Тип устройства" value={selected} disabled={busy}
|
||||
options={[{value:'',label:'Выберите поддерживаемое устройство',disabled:true},
|
||||
...supported.map(value=>({value:value.kind,label:value.wirelessEnrollment!.label}))]}
|
||||
onChange={setSelected}/>
|
||||
@@ -22,5 +23,6 @@ export function WirelessEnrollmentWindow({contributions,transport,onClose,onChan
|
||||
</div>
|
||||
</Window>
|
||||
);
|
||||
if(Enrollment&&!enabled)return renderWindow({content:<SettingsCard title="Бортовой компьютер не в сети" description="Для подключения беспроводного устройства включите привязанный БК и дождитесь связи с Mission Core."/>});
|
||||
return Enrollment?<Enrollment key={selected} transport={transport} onClose={onClose} onChange={onChange} onComplete={onComplete} renderWindow={renderWindow}/>:renderWindow({content:null});
|
||||
}
|
||||
|
||||
@@ -12,10 +12,12 @@ export interface SensorObservationProps {
|
||||
export interface SensorObservationContribution {
|
||||
views:(device:Sensor)=>SensorObservationView[];
|
||||
Session:ComponentType<SensorObservationProps>;
|
||||
catalog?:{label:string;views:SensorObservationView[]};
|
||||
}
|
||||
export const sensorLayerLabels:Record<string,string>={color:'RGB',depth:'Глубина',infrared1:'ИК · 1',infrared2:'ИК · 2',points:'Облако точек',motion:'Движение'};
|
||||
export function cameraObservationViews(device:Sensor):SensorObservationView[] {
|
||||
const layers=device.layers.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
|
||||
const published=device.layers.length?device.layers:/^RealSense D455$/i.test(device.model??'')?['color','depth','infrared1','infrared2','points','motion']:[];
|
||||
const layers=published.filter(value=>Object.hasOwn(sensorLayerLabels,value)).map(value=>({value,label:sensorLayerLabels[value]}));
|
||||
layers.sort((a,b)=>Number(b.value==='color')-Number(a.value==='color'));
|
||||
return layers.length?[{id:'camera',label:device.name,layers}]:[];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type {Sensor} from './contracts';
|
||||
import type {SensorUiContribution} from './extensions';
|
||||
|
||||
/** Supported view slots are not physical devices and carry no command authority. */
|
||||
export function unboundObservationViews(devices:Sensor[],contributions:readonly SensorUiContribution[]) {
|
||||
return contributions.flatMap(contribution=>{
|
||||
const catalog=contribution.observation?.catalog;
|
||||
if(!catalog||devices.some(device=>device.kind===contribution.kind)||contributions.filter(other=>other.kind===contribution.kind).length!==1)return [];
|
||||
return catalog.views.map(view=>({key:JSON.stringify(['catalog',contribution.kind,view.id]),catalog:catalog.label,view}));
|
||||
});
|
||||
}
|
||||
@@ -8,5 +8,5 @@ export const xgridsK1SensorUi:SensorUiContribution={
|
||||
kind:'k1', Detail:K1Detail, icon:'network', retainOffline:true, supportsPreparation:false,
|
||||
supportsRenaming:false,status:k1Status,
|
||||
wirelessEnrollment:{label:'XGRIDS LixelKity K1',View:DeviceEnrollmentWindow},
|
||||
observation:{Session:K1Observation,views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
||||
observation:{Session:K1Observation,catalog:{label:'XGRIDS LixelKity K1',views:[{id:'points',label:'XGRIDS K1 · Облако точек · 3D'},{id:'plan',label:'XGRIDS K1 · Облако точек · Сверху'},{id:'camera',label:'XGRIDS K1 · Камера'}]},views:device=>[{id:'points',label:`${device.name} Point Cloud · 3D`},{id:'plan',label:`${device.name} Point Cloud · Сверху`},{id:'camera',label:`${device.name} Cam Right`}]},
|
||||
};
|
||||
|
||||
@@ -52,6 +52,10 @@ class FleetRegistry:
|
||||
"CREATE TABLE IF NOT EXISTS vehicles (id TEXT PRIMARY KEY, "
|
||||
"node_id TEXT UNIQUE NOT NULL, body TEXT NOT NULL)"
|
||||
)
|
||||
self.db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS board_history (vehicle_id TEXT NOT NULL, "
|
||||
"revision INTEGER NOT NULL, body TEXT NOT NULL, PRIMARY KEY(vehicle_id, revision))"
|
||||
)
|
||||
self.db.commit()
|
||||
from k1link.device_plugins.vesc.archive import Archive
|
||||
|
||||
@@ -74,11 +78,14 @@ class FleetRegistry:
|
||||
json.loads(row[0]) for row in self.db.execute("SELECT body FROM vehicles ORDER BY id")
|
||||
]
|
||||
|
||||
def save(self, row):
|
||||
def save(self, row, *, previous=None):
|
||||
with self.db:
|
||||
if previous is not None:
|
||||
self.db.execute("INSERT INTO board_history VALUES(?,?,?)",
|
||||
(previous["id"], previous["revision"], json.dumps(previous)))
|
||||
self.db.execute(
|
||||
"INSERT INTO vehicles VALUES(?,?,?) "
|
||||
"ON CONFLICT(id) DO UPDATE SET body=excluded.body",
|
||||
"ON CONFLICT(id) DO UPDATE SET node_id=excluded.node_id, body=excluded.body",
|
||||
(row["id"], row["node_id"], json.dumps(row)),
|
||||
)
|
||||
self.events.notify()
|
||||
@@ -164,17 +171,31 @@ class FleetRegistry:
|
||||
"endpoint": invitation["endpoint"],
|
||||
}
|
||||
|
||||
def add(self, preview_id: str, name: str, platform: str) -> dict:
|
||||
def add(self, preview_id: str, name: str, platform: str, *,
|
||||
vehicle_id: str | None = None, expected_revision: int | None = None) -> dict:
|
||||
with self.lock:
|
||||
preview = self.previews.get(preview_id)
|
||||
if preview is None or preview["expires"] <= time.time():
|
||||
raise PairingError("Проверка приглашения истекла. Вставьте код снова.")
|
||||
if preview.get("created_id"):
|
||||
return self.public(self.find(preview["created_id"]))
|
||||
current = self.find(preview["created_id"])
|
||||
if (preview.get("target_id") != vehicle_id
|
||||
or current["binding"]["binding_id"] != preview.get("created_binding")):
|
||||
raise PairingError("Приглашение уже использовано. Проверьте БК заново.")
|
||||
return self.public(current)
|
||||
target = self.find(vehicle_id) if vehicle_id is not None else None
|
||||
if target is not None:
|
||||
if expected_revision != target["revision"]:
|
||||
raise PairingError("Привязка БК изменилась. Обновите аппарат и повторите проверку.")
|
||||
from .replacement import check_replacement
|
||||
check_replacement(self, target)
|
||||
name, platform = target["name"], target["platform"]
|
||||
invitation = preview["invitation"]
|
||||
existing = next(
|
||||
(row for row in self.rows() if row["node_id"] == invitation["node_id"]), None
|
||||
)
|
||||
if target is not None and existing is not None and existing["id"] != target["id"]:
|
||||
raise PairingError("Этот БК относится к другому аппарату. Выберите другое приглашение.")
|
||||
if existing and existing["enrollment"] in ("pending", "paired"):
|
||||
if existing.get("invitation_id") == invitation["id"]:
|
||||
return self.public(existing)
|
||||
@@ -201,13 +222,14 @@ class FleetRegistry:
|
||||
"ca_pem": pem(self.trust.ca),
|
||||
"client_pem": self.trust.leaf(invitation["node_id"], preview["public_key"]),
|
||||
}
|
||||
previous = target or existing
|
||||
row = {
|
||||
"id": existing["id"] if existing else secrets.token_urlsafe(16),
|
||||
"id": previous["id"] if previous else secrets.token_urlsafe(16),
|
||||
"node_id": invitation["node_id"],
|
||||
"name": name.strip(),
|
||||
"platform": platform,
|
||||
"enrollment": "pending",
|
||||
"revision": (existing["revision"] + 1) if existing else 1,
|
||||
"revision": (previous["revision"] + 1) if previous else 1,
|
||||
"binding": binding,
|
||||
"invitation_id": invitation["id"],
|
||||
"invitation": invitation,
|
||||
@@ -218,10 +240,11 @@ class FleetRegistry:
|
||||
"runtime": None,
|
||||
"certificate_previous": None,
|
||||
"notice": "Подтверждаем привязку с БК",
|
||||
"created_at": time.time(),
|
||||
"created_at": previous["created_at"] if previous else time.time(),
|
||||
}
|
||||
self.save(row)
|
||||
self.previews[preview_id] = {"created_id": row["id"], "expires": preview["expires"]}
|
||||
self.save(row, previous=previous)
|
||||
self.previews[preview_id] = {"created_id": row["id"], "created_binding": binding["binding_id"],
|
||||
"target_id": vehicle_id, "expires": preview["expires"]}
|
||||
return self.public(row)
|
||||
|
||||
def public(self, row):
|
||||
@@ -250,6 +273,21 @@ class FleetRegistry:
|
||||
"core_endpoint": row["binding"]["endpoint"],
|
||||
}
|
||||
|
||||
def rename(self, identifier: str, name: str, expected_revision: int) -> dict:
|
||||
"""Update operator metadata without contacting or re-enrolling the board."""
|
||||
if (not name.strip() or len(name.strip()) > 80
|
||||
or any(ord(c) < 32 or 127 <= ord(c) < 160 for c in name)):
|
||||
raise PairingError("Введите название аппарата от 1 до 80 символов без управляющих знаков.")
|
||||
with self.lock:
|
||||
row = self.find(identifier)
|
||||
if row["name"] == name.strip():
|
||||
return self.public(row) # Safe retry after a lost response.
|
||||
if row["revision"] != expected_revision:
|
||||
raise PairingError("Аппарат изменился. Отмените правку и повторите её по свежим данным.")
|
||||
row.update(name=name.strip(), revision=row["revision"] + 1)
|
||||
self.save(row)
|
||||
return self.public(row)
|
||||
|
||||
def listing(self):
|
||||
with self.lock:
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Admission for replacing the computer of an existing vehicle, never its identity."""
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from .trust import PairingError
|
||||
|
||||
|
||||
def check_replacement(fleet, row):
|
||||
if row["enrollment"] == "pending":
|
||||
raise PairingError("Дождитесь завершения текущей привязки БК или отзовите её.")
|
||||
control = fleet.rover_control.view(row["node_id"])
|
||||
if control["controlling"] or (control["fresh"] and control["snapshot"].get("state") in
|
||||
("preparing", "ready", "driving", "stopping")):
|
||||
raise PairingError("Сначала завершите управление аппаратом и дождитесь остановки.")
|
||||
fleet.device_enrollment.prune()
|
||||
if any(entry["binding"] == row["binding"]["binding_id"]
|
||||
and entry["public"]["state"] in ("queued", "running")
|
||||
for (vehicle_id, _), entry in fleet.device_enrollment.pending.items()
|
||||
if vehicle_id == row["id"]):
|
||||
raise PairingError("Дождитесь завершения подключения беспроводного устройства.")
|
||||
for receipt in row.get("sensor_commands", {}).values():
|
||||
if receipt.get("state") not in ("queued", "running"):
|
||||
continue
|
||||
try:
|
||||
deadline = datetime.fromisoformat(receipt["command"]["deadline_at"].replace("Z", "+00:00")).timestamp()
|
||||
except (KeyError, ValueError, TypeError):
|
||||
deadline = float("inf")
|
||||
if deadline > time.time():
|
||||
raise PairingError("Дождитесь завершения операции с устройствами БК.")
|
||||
@@ -48,6 +48,18 @@ class AddRequest(BaseModel):
|
||||
platform: str = Field(pattern="^(ugv|uav|stationary|other)$")
|
||||
|
||||
|
||||
class AttachBoardRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
preview_id: str = Field(min_length=43, max_length=43)
|
||||
expected_revision: int = Field(ge=1, strict=True)
|
||||
|
||||
|
||||
class RenameRequest(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
name: str = Field(min_length=1, max_length=80)
|
||||
expected_revision: int = Field(ge=1, strict=True)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/v1/fleet", tags=["fleet"])
|
||||
|
||||
|
||||
@@ -157,6 +169,16 @@ def fleet_add(
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
|
||||
@router.patch("/{vehicle_id}")
|
||||
def fleet_rename(vehicle_id: str, body: RenameRequest, response: Response,
|
||||
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return fleet.rename(vehicle_id, body.name, body.expected_revision)
|
||||
except PairingError as error:
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
|
||||
@router.delete("/{vehicle_id}")
|
||||
def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
try:
|
||||
@@ -165,6 +187,17 @@ def fleet_revoke(vehicle_id: str, fleet: Annotated[FleetRegistry, Depends(local_
|
||||
raise HTTPException(404, str(error)) from None
|
||||
|
||||
|
||||
@router.post("/{vehicle_id}/computer")
|
||||
def fleet_attach_board(vehicle_id: str, body: AttachBoardRequest, response: Response,
|
||||
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
try:
|
||||
return fleet.add(body.preview_id, "", "", vehicle_id=vehicle_id,
|
||||
expected_revision=body.expected_revision)
|
||||
except PairingError as error:
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
|
||||
@router.post("/{vehicle_id}/devices/operations")
|
||||
def sensor_command(
|
||||
vehicle_id: str, body: dict, fleet: Annotated[FleetRegistry, Depends(local_operator)]
|
||||
|
||||
@@ -27,7 +27,9 @@ def rover_state(vehicle_id: str, response: Response,
|
||||
def rover_arm(vehicle_id: str, body: dict,
|
||||
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
try:
|
||||
return fleet.rover_control.arm(node(fleet, vehicle_id), body)
|
||||
# Admission and node resolution must not race a computer replacement.
|
||||
with fleet.lock:
|
||||
return fleet.rover_control.arm(node(fleet, vehicle_id), body)
|
||||
except (ValueError, PairingError) as error:
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
@@ -35,6 +37,7 @@ def rover_arm(vehicle_id: str, body: dict,
|
||||
def rover_command(vehicle_id: str, body: dict,
|
||||
fleet: Annotated[FleetRegistry, Depends(local_operator)]):
|
||||
try:
|
||||
return fleet.rover_control.command(node(fleet, vehicle_id), body)
|
||||
with fleet.lock:
|
||||
return fleet.rover_control.command(node(fleet, vehicle_id), body)
|
||||
except (ValueError, PairingError) as error:
|
||||
raise HTTPException(409, str(error)) from None
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Apparatus metadata is independent of its powered/offline board."""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_pairing import setup, create, cert, heartbeat
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
from k1link.fleet.trust import PairingError
|
||||
from k1link.web.fleet_api import router
|
||||
|
||||
|
||||
def test_offline_rename_persists_without_touching_board_or_authority(setup):
|
||||
fleet, _, _, calls = setup
|
||||
public, _ = create(setup)
|
||||
fleet.advance(public['id'])
|
||||
before = fleet.find(public['id'])
|
||||
network_calls = len(calls)
|
||||
result = fleet.rename(before['id'], ' New rover ', before['revision'])
|
||||
expected = {**before, 'name': 'New rover', 'revision': before['revision']+1}
|
||||
assert fleet.find(before['id']) == expected
|
||||
assert result['connectivity'] == 'offline'
|
||||
assert len(calls) == network_calls
|
||||
assert fleet.rename(before['id'], 'New rover', before['revision']) == result
|
||||
reopened = FleetRegistry(fleet.root)
|
||||
try:
|
||||
assert reopened.find(before['id']) == expected
|
||||
finally:
|
||||
reopened.close()
|
||||
# The existing board certificate and heartbeat continue to work unchanged.
|
||||
assert fleet.receive(cert(before), '/v1/node/heartbeat', heartbeat(before))[0] == 200
|
||||
assert fleet.find(before['id'])['name'] == 'New rover'
|
||||
|
||||
|
||||
def test_stale_editor_cannot_overwrite_newer_name(setup):
|
||||
fleet = setup[0]
|
||||
row, _ = create(setup)
|
||||
fleet.rename(row['id'], 'Other editor', row['revision'])
|
||||
with pytest.raises(PairingError, match='изменился'):
|
||||
fleet.rename(row['id'], 'Stale edit', row['revision'])
|
||||
assert fleet.find(row['id'])['name'] == 'Other editor'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('name', ['', ' ', 'a'*81, 'bad\nname', 'bad\x7fname'])
|
||||
def test_invalid_name_does_not_change_saved_apparatus(setup, name):
|
||||
fleet = setup[0]
|
||||
row, _ = create(setup)
|
||||
before = fleet.find(row['id'])
|
||||
with pytest.raises(PairingError):
|
||||
fleet.rename(row['id'], name, row['revision'])
|
||||
assert fleet.find(row['id']) == before
|
||||
|
||||
|
||||
def test_database_failure_keeps_previous_name(setup):
|
||||
fleet = setup[0]
|
||||
row, _ = create(setup)
|
||||
fleet.db.execute("CREATE TRIGGER fail_rename BEFORE UPDATE ON vehicles BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
fleet.rename(row['id'], 'Unsaved', row['revision'])
|
||||
assert fleet.find(row['id'])['name'] == row['name']
|
||||
|
||||
|
||||
def test_rename_endpoint_is_local_scoped_and_returns_public_metadata(setup):
|
||||
fleet = setup[0]
|
||||
row, _ = create(setup)
|
||||
app = FastAPI(); app.state.fleet = fleet; app.include_router(router)
|
||||
path = '/api/v1/fleet/'+row['id']
|
||||
body = {'name':'Updated rover','expected_revision':row['revision']}
|
||||
with TestClient(app, base_url='http://127.0.0.1:8000', client=('127.0.0.1',55555)) as client:
|
||||
assert client.patch(path, json=body, headers={'Origin':'https://attacker.test'}).status_code == 403
|
||||
assert client.patch(path, json={**body,'node_id':'injected'}).status_code == 422
|
||||
assert client.patch(path, json={**body,'expected_revision':True}).status_code == 422
|
||||
response = client.patch(path, json=body)
|
||||
assert response.status_code == 200
|
||||
assert response.headers['cache-control'] == 'no-store'
|
||||
assert response.json()['name'] == 'Updated rover'
|
||||
assert fleet.find(row['id'])['binding']['binding_id'] not in response.text
|
||||
assert client.patch(path, json={**body,'name':'Stale'}).status_code == 409
|
||||
@@ -0,0 +1,162 @@
|
||||
"""Replacement uses synthetic identities; no device or network access."""
|
||||
import json
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from test_pairing import setup, create, code, cert, heartbeat
|
||||
from k1link.fleet import board_layout
|
||||
from k1link.fleet.registry import FleetRegistry
|
||||
from k1link.fleet.trust import SCHEMA, PairingError, public_id
|
||||
from k1link.web.fleet_api import router
|
||||
|
||||
|
||||
def candidate(fleet, monkeypatch):
|
||||
key = Ed25519PrivateKey.generate()
|
||||
invite = dict(schema=SCHEMA, node_id=public_id('node_', key.public_key()),
|
||||
id=secrets.token_urlsafe(32), secret=secrets.token_urlsafe(32),
|
||||
expires_at=int(time.time())+600, endpoint='https://192.168.20.8:8781')
|
||||
def request(invitation, path, payload):
|
||||
return dict(schema=SCHEMA, node_id=invite['node_id'], name='Replacement board',
|
||||
host={'os':'Linux'}, receipt=secrets.token_urlsafe(32)), key.public_key(), '192.168.20.5'
|
||||
monkeypatch.setattr('k1link.fleet.registry.node_request', request)
|
||||
return fleet.preview(code(invite))
|
||||
|
||||
|
||||
def current(setup):
|
||||
fleet = setup[0]
|
||||
public, _ = create(setup)
|
||||
fleet.advance(public['id'])
|
||||
return fleet.find(public['id'])
|
||||
|
||||
|
||||
def attach(fleet, old, preview, **kwargs):
|
||||
return fleet.add(preview['preview_id'], '', '', vehicle_id=old['id'],
|
||||
expected_revision=kwargs.get('revision', old['revision']))
|
||||
|
||||
|
||||
def test_replacement_keeps_vehicle_and_layout_but_not_old_authority(setup, monkeypatch):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
board_layout.update(fleet.root, old['id'], 'settings', False)
|
||||
before_layout = board_layout.read(fleet.root, old['id'])
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
new = attach(fleet, old, preview)
|
||||
assert new['id'] == old['id'] and new['name'] == old['name'] and new['platform'] == old['platform']
|
||||
assert new['node_id'] == preview['node_id'] != old['node_id']
|
||||
assert new['revision'] == old['revision']+1 and new['enrollment'] == 'pending'
|
||||
assert new['host'] is None and new['sensor_state']['items'] == []
|
||||
assert board_layout.read(fleet.root, old['id']) == before_layout
|
||||
assert fleet.find(old['id'])['created_at'] == old['created_at']
|
||||
archived = fleet.db.execute('SELECT body FROM board_history WHERE vehicle_id=?', (old['id'],)).fetchone()[0]
|
||||
assert json.loads(archived) == old
|
||||
assert fleet.db.execute('SELECT node_id FROM vehicles').fetchone()[0] == preview['node_id']
|
||||
assert fleet.receive(cert(old), '/v1/node/heartbeat', heartbeat(old))[0] == 410
|
||||
assert attach(fleet, old, preview) == new # A lost operator response is retried safely.
|
||||
fresh = fleet.find(old['id'])
|
||||
assert fleet.receive(cert(fresh), '/v1/node/heartbeat', heartbeat(fresh))[0] == 200
|
||||
assert fleet.public(fleet.find(old['id']))['connectivity'] == 'online'
|
||||
reopened = FleetRegistry(fleet.root)
|
||||
try:
|
||||
assert reopened.find(old['id'])['node_id'] == preview['node_id']
|
||||
assert reopened.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 1
|
||||
finally:
|
||||
reopened.close()
|
||||
|
||||
|
||||
def test_stale_and_expired_preview_do_not_touch_existing_binding(setup, monkeypatch):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
with pytest.raises(PairingError, match='изменилась'):
|
||||
attach(fleet, old, preview, revision=old['revision']+1)
|
||||
fleet.previews[preview['preview_id']]['expires'] = 0
|
||||
with pytest.raises(PairingError, match='истекла'):
|
||||
attach(fleet, old, preview)
|
||||
assert fleet.find(old['id']) == old
|
||||
assert fleet.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_other_apparatus_computer_is_never_silently_transferred(setup, monkeypatch):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
verified = dict(fleet.previews[preview['preview_id']])
|
||||
other = fleet.add(preview['preview_id'], 'Other apparatus', 'ugv')
|
||||
with pytest.raises(PairingError):
|
||||
attach(fleet, old, preview)
|
||||
# Another verified preview of the same computer cannot transfer its owner.
|
||||
preview['preview_id'] = secrets.token_urlsafe(32)
|
||||
fleet.previews[preview['preview_id']] = verified
|
||||
with pytest.raises(PairingError, match='другому аппарату'):
|
||||
attach(fleet, old, preview)
|
||||
assert fleet.find(old['id']) == old
|
||||
assert len(fleet.rows()) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('activity', ['pairing', 'control', 'device', 'wireless'])
|
||||
def test_replacement_rejects_active_work(setup, monkeypatch, activity):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
if activity == 'pairing':
|
||||
old['enrollment'] = 'pending'
|
||||
elif activity == 'control':
|
||||
fleet.rover_control.exchange(old['node_id'], {'relay_id':'a'*32, 'rover':{'instance':'test','supported':True,'state':'idle'}})
|
||||
fleet.rover_control.arm(old['node_id'], {'standstill_confirmed':True,'current_a':5,'max_erpm':1000})
|
||||
elif activity == 'device':
|
||||
old['sensor_commands'] = {'synthetic':{'state':'running','command':{'deadline_at':(datetime.now(UTC)+timedelta(seconds=60)).isoformat()}}}
|
||||
else:
|
||||
fleet.device_enrollment.pending[(old['id'],'synthetic')] = dict(binding=old['binding']['binding_id'], created=time.time(),deadline=time.time()+60,payload={},public={'state':'running'})
|
||||
fleet.save(old)
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
with pytest.raises(PairingError):
|
||||
attach(fleet, old, preview)
|
||||
assert fleet.find(old['id']) == old
|
||||
|
||||
|
||||
def test_failed_transaction_preserves_old_binding_and_history(setup, monkeypatch):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
fleet.db.execute("CREATE TRIGGER fail_replace BEFORE UPDATE ON vehicles BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END")
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
attach(fleet, old, preview)
|
||||
assert fleet.find(old['id']) == old
|
||||
assert fleet.db.execute('SELECT count(*) FROM board_history').fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_listener_failure_keeps_original_and_preview_usable(setup, monkeypatch):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
def fail(_):
|
||||
raise OSError('synthetic')
|
||||
monkeypatch.setattr(fleet, 'listen', fail)
|
||||
with pytest.raises(PairingError, match='частный канал'):
|
||||
attach(fleet, old, preview)
|
||||
assert fleet.find(old['id']) == old
|
||||
monkeypatch.setattr(fleet, 'listen', lambda _:None)
|
||||
assert attach(fleet, old, preview)['id'] == old['id']
|
||||
|
||||
|
||||
def test_attach_endpoint_scoped_local_revision_and_payload(setup, monkeypatch):
|
||||
fleet = setup[0]
|
||||
old = current(setup)
|
||||
preview = candidate(fleet, monkeypatch)
|
||||
app = FastAPI(); app.state.fleet = fleet; app.include_router(router)
|
||||
body = {'preview_id':preview['preview_id'],'expected_revision':old['revision']}
|
||||
with TestClient(app, base_url='http://127.0.0.1:8000', client=('127.0.0.1',55555)) as client:
|
||||
path = '/api/v1/fleet/'+old['id']+'/computer'
|
||||
assert client.post(path, json=body, headers={'Origin':'https://attacker.test'}).status_code == 403
|
||||
assert client.post(path, json={**body,'name':'injected'}).status_code == 422
|
||||
assert client.post(path, json={**body,'expected_revision':0}).status_code == 422
|
||||
response = client.post(path, json=body)
|
||||
assert response.status_code == 200
|
||||
assert response.json()['id'] == old['id']
|
||||
assert old['binding']['binding_id'] not in response.text
|
||||
@@ -1,49 +1,70 @@
|
||||
"""Immutable-source evaluated geometry export, owner-selected complete v020."""
|
||||
import bpy,json,hashlib,struct
|
||||
"""Evaluated v020 geometry, semantic PBR groups and UVs; never saves the source."""
|
||||
import bpy, json, hashlib
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from mathutils import Vector,Matrix
|
||||
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('<II',f.read(8));donor_json=json.loads(f.read(n))
|
||||
# Same physical PBR parameters as the drone's untextured metal/rubber/steel.
|
||||
material_rules={'metal':1,'rubber':2,'steel':6,'paint':3}
|
||||
# Linear colors. Painted black is a dielectric coating, not bare silver.
|
||||
specs={
|
||||
'body':('Корпус и подвеска',(.025,.028,.031),.12,.53),
|
||||
'rubber':('Резина гусениц и катков',(.014,.017,.019),0,.72),
|
||||
'track-steel':('Нержавеющие проставки',(.55,.58,.61),1,.17),
|
||||
'box':('Бортовой ящик',(.034,.038,.042),.08,.40),
|
||||
'molle':('MOLLE панель',(.085,.094,.105),.30,.44),
|
||||
'motor':('Алюминий моторов',(.30,.32,.33),.68,.38),
|
||||
'hardware':('Чёрный крепёж',(.018,.021,.024),.45,.38),
|
||||
'polymer':('Пластик и кожухи',(.018,.022,.026),0,.44),
|
||||
'drive':('Ведущие звёзды',(.22,.13,.038),0,.46),
|
||||
}
|
||||
materials={}
|
||||
for kind,index in material_rules.items():
|
||||
spec=donor_json['materials'][index]['pbrMetallicRoughness'];m=bpy.data.materials.new('NodeDC '+kind);m.use_nodes=True
|
||||
bs=m.node_tree.nodes.get('Principled BSDF');bs.inputs['Base Color'].default_value=spec.get('baseColorFactor',[1,1,1,1]);bs.inputs['Metallic'].default_value=spec.get('metallicFactor',1);bs.inputs['Roughness'].default_value=spec.get('roughnessFactor',1);materials[kind]=m
|
||||
source_scene=bpy.context.scene;source_scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get()
|
||||
instances=[]
|
||||
for key,(label,color,metal,rough) in specs.items():
|
||||
m=bpy.data.materials.new('rover/'+key);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
|
||||
bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;materials[key]=m
|
||||
|
||||
def classify(name,obj):
|
||||
n=name.lower();o=obj.lower()
|
||||
if 'stamped clips' in n or 'inter-window clips' in o:return 'track-steel'
|
||||
if 'molle' in n:return 'molle'
|
||||
if 'motor | cast' in n:return 'motor'
|
||||
if 'polyurethane' in n:return 'drive'
|
||||
if any(k in n for k in ['rubber','gasket','elastomer','jacket','coated roller']):return 'rubber'
|
||||
if any(k in n for k in ['case | textured','case | dark raised']):return 'box'
|
||||
if any(k in n+' '+o for k in ['bolt','screw','washer','nut','lock steel','zinc','stainless','connector brass','dark steel','black oxide']):return 'hardware'
|
||||
if any(k in n for k in ['polymer','asa','recess','cavities']):return 'polymer'
|
||||
return 'body'
|
||||
|
||||
bpy.context.scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get();instances=[];assignment=defaultdict(set)
|
||||
for inst in dg.object_instances:
|
||||
o=inst.object;src=o.original;cols=[c.name for c in src.users_collection]
|
||||
if o.type not in {'MESH','CURVE'} or src.hide_render:continue
|
||||
if any(c.hide_render for c in src.users_collection) or any(('STUDIO' in c or 'BOOLEAN' in c or 'DATUM' in c) for c in cols):continue
|
||||
# Exclude standalone instancing source collections; their placed instances survive.
|
||||
if any(c.hide_render for c in src.users_collection) or any(any(t in c for t in ['STUDIO','BOOLEAN','DATUM']) for c in cols):continue
|
||||
if not inst.is_instance and not src.visible_get():continue
|
||||
mesh=bpy.data.meshes.new_from_object(o,preserve_all_data_layers=True,depsgraph=dg)
|
||||
names=[slot.material.name.lower() if slot.material else '' for slot in o.material_slots]
|
||||
for idx in range(len(mesh.materials)):
|
||||
name=names[idx] if idx<len(names) else ''
|
||||
kind='rubber' if any(k in name for k in ['rubber','gasket','polymer','elastomer','jacket','asa']) else 'steel' if any(k in name for k in ['zinc','steel','brass','spring']) else 'paint' if any(k in name for k in ['paint','coated','black','charcoal']) else 'metal'
|
||||
mesh.materials[idx]=materials[kind]
|
||||
if not mesh.materials:mesh.materials.append(materials['metal'])
|
||||
instances.append((src.name,mesh,inst.matrix_world.copy(),inst.is_instance))
|
||||
scene=bpy.data.scenes.new('Mission Core rover export');bpy.context.window.scene=scene
|
||||
objects=[]
|
||||
for name,mesh,matrix,_ in instances:
|
||||
name=mesh.materials[idx].name if mesh.materials[idx] else '';key=classify(name,src.name)
|
||||
mesh.materials[idx]=materials[key];assignment[key].add(src.name)
|
||||
if not mesh.materials:mesh.materials.append(materials['body']);assignment['body'].add(src.name)
|
||||
instances.append((src.name,mesh,inst.matrix_world.copy()))
|
||||
scene=bpy.data.scenes.new('Mission Core rover export');bpy.context.window.scene=scene;objects=[]
|
||||
for name,mesh,matrix in instances:
|
||||
o=bpy.data.objects.new(name,mesh);scene.collection.objects.link(o);o.matrix_world=matrix;objects.append(o)
|
||||
bpy.context.view_layer.update()
|
||||
lo=Vector((min((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
|
||||
hi=Vector((max((o.matrix_world@Vector(v))[i] for o in objects for v in o.bound_box) for i in range(3)))
|
||||
shift=Vector((-(lo.x+hi.x)/2,-(lo.y+hi.y)/2,-lo.z))
|
||||
for o in objects:o.matrix_world=Matrix.Translation(shift)@o.matrix_world
|
||||
source_object_count=len(objects)
|
||||
count=len(objects)
|
||||
bpy.ops.object.select_all(action='SELECT');bpy.context.view_layer.objects.active=objects[0];bpy.ops.object.join()
|
||||
# Separate by material keeps picking exact while avoiding 1,426 draw calls.
|
||||
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.mesh.separate(type='MATERIAL');bpy.ops.object.mode_set(mode='OBJECT')
|
||||
for o in list(scene.objects):
|
||||
bpy.ops.object.select_all(action='DESELECT');o.select_set(True);bpy.context.view_layer.objects.active=o
|
||||
key=o.data.materials[0].name.removeprefix('rover/');o.name=specs[key][0]
|
||||
for uv in list(o.data.uv_layers):o.data.uv_layers.remove(uv)
|
||||
bpy.ops.object.mode_set(mode='EDIT');bpy.ops.mesh.select_all(action='SELECT');bpy.ops.uv.cube_project(cube_size=.2);bpy.ops.object.mode_set(mode='OBJECT')
|
||||
bpy.ops.object.select_all(action='SELECT')
|
||||
bpy.context.view_layer.objects.active=objects[0]
|
||||
bpy.ops.object.join()
|
||||
joined=bpy.context.object
|
||||
for uv in list(joined.data.uv_layers):joined.data.uv_layers.remove(uv)
|
||||
out=root/'apps/control-station/public/rover-scene/dcd006-v020.glb'
|
||||
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_cameras=False,export_lights=False,export_extras=False,export_yup=True,export_apply=False,export_texcoords=False,export_attributes=False,export_vertex_color='NONE')
|
||||
manifest={'source':source.name,'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model':'DCD-006 v020 complete rover','source_object_count':source_object_count,'render_mesh_count':1,'original_bounds':[list(lo),list(hi)],'dimensions_m':list(hi-lo),'translation_m':list(shift),'glb_bytes':out.stat().st_size,'glb_sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'material_source_sha256':hashlib.sha256(donor.read_bytes()).hexdigest(),'material_rules':material_rules,'source_saved':False}
|
||||
(out.parent/'dcd006-v020.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n');print(json.dumps(manifest))
|
||||
bpy.ops.export_scene.gltf(filepath=str(out),export_format='GLB',use_selection=True,use_active_scene=True,export_animations=False,export_cameras=False,export_lights=False,export_extras=False,export_yup=True,export_apply=False,export_texcoords=True,export_attributes=False,export_vertex_color='NONE')
|
||||
manifest={'source':source.name,'source_sha256':hashlib.sha256(source.read_bytes()).hexdigest(),'model':'DCD-006 v020 complete rover','revision':'materials-v2','source_object_count':count,'render_mesh_count':len(scene.objects),'original_bounds':[list(lo),list(hi)],'dimensions_m':list(hi-lo),'translation_m':list(shift),'glb_bytes':out.stat().st_size,'glb_sha256':hashlib.sha256(out.read_bytes()).hexdigest(),'source_saved':False,'uv':'cube projection, 0.2 m','materials':{k:{'name':v[0],'baseColorLinear':v[1],'metalness':v[2],'roughness':v[3],'sourceObjects':sorted(assignment[k])} for k,v in specs.items()}}
|
||||
(out.parent/'dcd006-v020.json').write_text(json.dumps(manifest,ensure_ascii=False,indent=2)+'\n')
|
||||
print('ROVER EXPORT',out.stat().st_size,'bytes;',len(scene.objects),'material groups')
|
||||
|
||||
Reference in New Issue
Block a user