feat(rover): add live scene and material editing with environment migration

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 19:13:52 +03:00
parent 9d3329f608
commit a993eda6b8
24 changed files with 3640 additions and 471 deletions
Binary file not shown.
File diff suppressed because it is too large Load Diff
@@ -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); }
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,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);
});