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 {RoverController} from '../../core/fleet/useRoverControl';
import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation'; import type {ObservationHeaderTargets} from '../../../../../packages/sensor-ui/src/observation';
import './rover.css'; 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:'Управление остановлено'}; 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}){ export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:string;controller:RoverController;header:ObservationHeaderTargets;active:boolean}){
const storage=`missioncore.rover-view.v1:${vehicleID}`; 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 [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 [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 [modelState,setModelState]=useState<'loading'|'ready'|'error'>('loading');
const stage=useRef<HTMLDivElement>(null),input=useRef<ReturnType<typeof bindRoverHoldInput>|null>(null); 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]); 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(()=>{try{localStorage.setItem(storage,JSON.stringify(settings));}catch{}},[storage,settings]);
useEffect(()=>{if(!active)stop();},[active,stop]); useEffect(()=>{if(!active)stop();},[active,stop]);
useEffect(()=>{ 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}); const binding=bindRoverHoldInput(stage.current,settings.mode,{held:setHeld,demand:c.setDemand,pause:c.pauseInput,stop});
input.current=binding;c.setInputGuard(binding.valid); input.current=binding;c.setInputGuard(binding.valid);
return()=>{c.setInputGuard(null);binding.dispose();if(input.current===binding)input.current=null;}; 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]); },[c.ready,active,open,editing,settings.mode,c.setDemand,c.setInputGuard,c.pauseInput,stop]);
const showSettings=()=>{stop();setOpen(true);}; const showSettings=()=>{setEditing(false);stop();setOpen(true);};
const preparing=c.pending||(c.armed&&!c.ready); 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 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 tone=preparing||availability?'warning':c.ready?'success':c.state.snapshot.state==='fault'?'warning':'neutral';
const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение'); const status=preparing?'Подготовка управления':availability??(labels[c.state.snapshot.state??'']??'Наблюдение');
return <> 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)} {createPortal(<StatusBadge variant="indicator" tone={tone} aria-label={status} title={status}/>,header.statusTarget)}
<div className="rover-view" ref={stage} tabIndex={0} aria-label="3D-вид и управление ровером" <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();}}> 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="Загрузка модели ровера"> {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>} {modelState==='error'&&<p className="rover-view__empty">Не удалось загрузить модель ровера.</p>}
</LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>} </LoadingRegion>:<div className="rover-view__empty"><p>Выберите модель аппарата.</p><Button onClick={showSettings}>Настройки вида</Button></div>}
<div className="rover-view__status"><StatusBadge tone={tone}>{status}</StatusBadge></div> <div className="rover-view__status"><StatusBadge tone={editing?'neutral':tone}>{editing?'Редактирование сцены':status}</StatusBadge></div>
{c.ready&&<div className="rover-view__controls"> {c.ready&&!editing&&<div className="rover-view__controls">
<div className={`rover-keys rover-keys--${settings.mode}`} aria-label={settings.mode==='arcade'?'Аркадное управление':'Танковое управление'}> <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)} {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)} disabled={!c.ready||!active} pressed={held.has(code)}
@@ -50,9 +51,9 @@ export function RoverView({vehicleID,controller:c,header,active}:{vehicleID:stri
</div> </div>
<span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span> <span>{settings.mode==='arcade'?'Аркадный':'Танковый'} · пробел — стоп</span>
</div>} </div>}
<div className="rover-view__authority"> {!editing&&<div className="rover-view__authority">
{c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>} {c.armed||c.pending?<Button onClick={stop}>Остановить управление</Button>:<Button onClick={showSettings}>Управлять</Button>}
</div> </div>}
</div> </div>
<Window open={open} onClose={()=>setOpen(false)} title="Управление ровером" size="md"><div className="rover-settings"> <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}))}/> <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", "No fallback to a different vehicle",
"Removed unused node-editor type helper; rendering settings unchanged", "Removed unused node-editor type helper; rendering settings unchanged",
"Observe the host pane on resize; preserve responsive canvas CSS sizing", "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 { buildSceneTree } from './sceneTree'
import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx' import { mergePostFxDefaults, type PostFxSettings } from './playcanvasPostFx'
import {useLutTexture} from './useLutTexture'
import {useSceneMaterials,type SceneMaterialsProps} from './useSceneMaterials'
const DEFAULT_MODEL_URL = '' const DEFAULT_MODEL_URL = ''
const PIXELFORMAT_RGBA8 = (pc as any).PIXELFORMAT_RGBA8 ?? 7 const PIXELFORMAT_RGBA8 = (pc as any).PIXELFORMAT_RGBA8 ?? 7
const PIXELFORMAT_111110F = (pc as any).PIXELFORMAT_111110F ?? 18 const PIXELFORMAT_111110F = (pc as any).PIXELFORMAT_111110F ?? 18
const PIXELFORMAT_RGBA16F = (pc as any).PIXELFORMAT_RGBA16F ?? 12 const PIXELFORMAT_RGBA16F = (pc as any).PIXELFORMAT_RGBA16F ?? 12
const PIXELFORMAT_RGBA32F = (pc as any).PIXELFORMAT_RGBA32F ?? 14 const PIXELFORMAT_RGBA32F = (pc as any).PIXELFORMAT_RGBA32F ?? 14
export type PlayCanvasViewerProps = { export type PlayCanvasViewerProps = SceneMaterialsProps & {
modelUrl: string | null modelUrl: string | null
postFx?: Partial<PostFxSettings> | null postFx?: Partial<PostFxSettings> | null
viewportSize?: { width: number; height: number } 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) { function setWireframeForScene(app: pc.Application, enabled: boolean) {
const renders = app.root.findComponents('render') as any[] const root = app.root.findByName('ModelRoot') as pc.Entity | null
for (const r of renders) { for (const mi of collectMeshInstances(root)) {
const meshInstances = r?.meshInstances ?? [] mi.renderStyle = enabled ? pc.RENDERSTYLE_WIREFRAME : pc.RENDERSTYLE_SOLID
for (const mi of meshInstances) {
const mat = mi.material as any
if (mat && 'wireframe' in mat) {
mat.wireframe = enabled
mat.update?.()
}
}
} }
} }
@@ -805,7 +801,7 @@ function applyPostFx(
} }
frame.rendering.renderTargetScale = fx.rendering.renderTargetScale frame.rendering.renderTargetScale = fx.rendering.renderTargetScale
frame.rendering.samples = Number.isFinite(overrides?.samples) ? Number(overrides?.samples) : fx.rendering.samples 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.sceneDepthMap = !!fx.rendering.sceneDepthMap
frame.rendering.toneMapping = fx.rendering.toneMapping frame.rendering.toneMapping = fx.rendering.toneMapping
frame.rendering.sharpness = fx.rendering.sharpness frame.rendering.sharpness = fx.rendering.sharpness
@@ -882,6 +878,7 @@ function applyPostFx(
export default function PlayCanvasViewer({ export default function PlayCanvasViewer({
modelUrl, modelUrl,
materials, assignments, editing, onMaterialPick, onTextureState,
postFx, postFx,
viewportSize, viewportSize,
onSceneReady, onSceneReady,
@@ -928,7 +925,11 @@ export default function PlayCanvasViewer({
const [skyboxVersion, setSkyboxVersion] = useState(0) const [skyboxVersion, setSkyboxVersion] = useState(0)
const [modelVersion, setModelVersion] = useState(0) const [modelVersion, setModelVersion] = useState(0)
useSceneMaterials(appRef,cameraEntityRef,canvasRef,modelVersion,{materials,assignments,editing,onMaterialPick,onTextureState})
const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx]) const fx = useMemo(() => mergePostFxDefaults(postFx), [postFx])
const lutError = useCallback(()=>onTextureState?.('error'),[onTextureState])
useLutTexture(appRef,cameraFrameRef,lutTextureRef,fx.lut.textureUrl,appReady,lutError)
useEffect(() => { useEffect(() => {
cameraStateRef.current = cameraState ?? null cameraStateRef.current = cameraState ?? null
@@ -962,7 +963,7 @@ export default function PlayCanvasViewer({
if (bounds && orbit) { if (bounds && orbit) {
const vertical = (cameraEntityRef.current?.camera?.fov ?? 28) * Math.PI / 360 const vertical = (cameraEntityRef.current?.camera?.fov ?? 28) * Math.PI / 360
const horizontal = Math.atan(Math.tan(vertical) * w / h) 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: ` fragmentGLSL: `
uniform vec2 uHalfExtents; uniform vec2 uHalfExtents;
uniform float uGridStep;
uniform float uGridRadius;
uniform vec3 uColorX; uniform vec3 uColorX;
uniform vec3 uColorZ; uniform vec3 uColorZ;
uniform vec3 uColorMain; uniform vec3 uColorMain;
@@ -1182,7 +1185,9 @@ export default function PlayCanvasViewer({
void main(void) { void main(void) {
vec2 uv = uv0; 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 ddx = dFdx(pos);
vec2 ddy = dFdy(pos); vec2 ddy = dFdy(pos);
@@ -1262,7 +1267,7 @@ export default function PlayCanvasViewer({
float fadeEnd = max(0.0, uFadeEnd); float fadeEnd = max(0.0, uFadeEnd);
float fade = 1.0; float fade = 1.0;
if (fadeEnd > fadeStart && fadeEnd > 0.0) { if (fadeEnd > fadeStart && fadeEnd > 0.0) {
float d = length(pos); float d = length(worldPos);
fade = 1.0 - smoothstep(fadeStart, fadeEnd, d); fade = 1.0 - smoothstep(fadeStart, fadeEnd, d);
} }
outAlpha *= fade; outAlpha *= fade;
@@ -1312,6 +1317,8 @@ export default function PlayCanvasViewer({
gridMaterial.setParameter('uFadeStart', 0) gridMaterial.setParameter('uFadeStart', 0)
gridMaterial.setParameter('uFadeEnd', 0) gridMaterial.setParameter('uFadeEnd', 0)
gridMaterial.setParameter('uResolution', 2) gridMaterial.setParameter('uResolution', 2)
gridMaterial.setParameter('uGridStep', fx.grid.step)
gridMaterial.setParameter('uGridRadius', fx.grid.diameter / 2)
const gridHalfExtents = new pc.Vec2(500, 500) const gridHalfExtents = new pc.Vec2(500, 500)
gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y]) gridMaterial.setParameter('uHalfExtents', [gridHalfExtents.x, gridHalfExtents.y])
gridMaterial.update() gridMaterial.update()
@@ -1528,7 +1535,7 @@ export default function PlayCanvasViewer({
const cam = cameraEntityRef.current?.camera const cam = cameraEntityRef.current?.camera
const vertical = (cam?.fov ?? 28) * Math.PI / 360 const vertical = (cam?.fov ?? 28) * Math.PI / 360
const horizontal = Math.atan(Math.tan(vertical) * (cam?.aspectRatio ?? 1)) 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) { if (exposeSceneGraph || onSceneGraph) {
@@ -1617,7 +1624,7 @@ export default function PlayCanvasViewer({
if (!aabb) return if (!aabb) return
orbit.setState({ orbit.setState({
pivot: { x: aabb.center.x, y: aabb.center.y, z: aabb.center.z }, 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, azimuthDeg: 45,
elevationDeg: 10, elevationDeg: 10,
}) })
@@ -1638,6 +1645,9 @@ export default function PlayCanvasViewer({
const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled const gridVisible = !!fx.grid.enabled || !!fx.grid.dotsEnabled || !!fx.grid.crossEnabled
grid.enabled = gridVisible 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 cX = toColor(fx.grid.colorX)
const cZ = toColor(fx.grid.colorZ) const cZ = toColor(fx.grid.colorZ)
const cMain = toColor(fx.grid.colorMain) 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.setParameter('uFadeEnd', Math.max(0, Number(fx.grid.fadeEnd) || 0))
mat.update() mat.update()
}, [ }, [
fx.grid.step,fx.grid.diameter,
fx.grid.enabled, fx.grid.enabled,
fx.grid.colorX, fx.grid.colorX,
fx.grid.colorZ, fx.grid.colorZ,
@@ -1690,7 +1701,7 @@ export default function PlayCanvasViewer({
const app = appRef.current const app = appRef.current
if (!app) return if (!app) return
setWireframeForScene(app, fx.rendering.wireframe) setWireframeForScene(app, fx.rendering.wireframe)
}, [fx.rendering.wireframe]) }, [fx.rendering.wireframe,modelVersion])
useEffect(() => { useEffect(() => {
const frame: any = cameraFrameRef.current const frame: any = cameraFrameRef.current
@@ -1703,9 +1714,10 @@ export default function PlayCanvasViewer({
const app = appRef.current const app = appRef.current
if (!app) return if (!app) return
if (fx.lighting) { if (fx.lighting) {
app.scene.ambientLight = new pc.Color(fx.lighting.ambientIntensity,fx.lighting.ambientIntensity,fx.lighting.ambientIntensity)
app.scene.exposure = fx.lighting.exposure app.scene.exposure = fx.lighting.exposure
} }
}, [fx.lighting?.exposure]) }, [fx.lighting?.exposure,fx.lighting.ambientIntensity])
useEffect(() => { useEffect(() => {
const app = appRef.current const app = appRef.current
@@ -1924,6 +1936,8 @@ export default function PlayCanvasViewer({
envAtlasVersion, envAtlasVersion,
skyboxVersion, skyboxVersion,
modelVersion, modelVersion,
materials,
assignments,
fx.envAtlas.enabled, fx.envAtlas.enabled,
fx.envAtlas.background, fx.envAtlas.background,
fx.envAtlas.reflection, fx.envAtlas.reflection,
@@ -1,393 +1,2 @@
// Shared serializable contract; donor defaults remain available.
export type PostFxSettings = { export * from '../../../core/roverScene/postFx';
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
}
@@ -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:focus-visible { outline:2px solid var(--nodedc-text-secondary); outline-offset:-2px; }
.rover-view .rover-view__scene { position:absolute; inset:0; } .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__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 { 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-view__controls > span { font-size:var(--nodedc-font-size-xs); color:var(--nodedc-text-secondary); }
.rover-keys { display:grid; gap:var(--nodedc-space-1); grid-template-columns:repeat(3,46px); } .rover-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-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); } .rover-view__authority { position:absolute; left:var(--nodedc-space-3); bottom:var(--nodedc-space-3); }
@container (max-width: 380px) { @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 { display:flex; flex-direction:column; gap:var(--nodedc-space-3); }
.rover-settings p { font-size:var(--nodedc-font-size-sm); color:var(--nodedc-text-secondary); } .rover-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);
});
+66
View File
@@ -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.
+53 -32
View File
@@ -1,49 +1,70 @@
"""Immutable-source evaluated geometry export, owner-selected complete v020.""" """Evaluated v020 geometry, semantic PBR groups and UVs; never saves the source."""
import bpy,json,hashlib,struct import bpy, json, hashlib
from collections import defaultdict
from pathlib import Path from pathlib import Path
from mathutils import Vector,Matrix from mathutils import Vector, Matrix
root=Path.cwd();source=Path(bpy.data.filepath) root=Path.cwd();source=Path(bpy.data.filepath)
donor=root.parent/'NODEDC_ENGINE_INFRA/nodedc-source/public/3dassetnode/model.glb' # Linear colors. Painted black is a dielectric coating, not bare silver.
with donor.open('rb') as f: specs={
f.read(12);n,t=struct.unpack('<II',f.read(8));donor_json=json.loads(f.read(n)) 'body':('Корпус и подвеска',(.025,.028,.031),.12,.53),
# Same physical PBR parameters as the drone's untextured metal/rubber/steel. 'rubber':('Резина гусениц и катков',(.014,.017,.019),0,.72),
material_rules={'metal':1,'rubber':2,'steel':6,'paint':3} '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={} materials={}
for kind,index in material_rules.items(): for key,(label,color,metal,rough) in specs.items():
spec=donor_json['materials'][index]['pbrMetallicRoughness'];m=bpy.data.materials.new('NodeDC '+kind);m.use_nodes=True m=bpy.data.materials.new('rover/'+key);m.use_nodes=True;bs=m.node_tree.nodes.get('Principled BSDF')
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 bs.inputs['Base Color'].default_value=(*color,1);bs.inputs['Metallic'].default_value=metal;bs.inputs['Roughness'].default_value=rough;materials[key]=m
source_scene=bpy.context.scene;source_scene.frame_set(1);dg=bpy.context.evaluated_depsgraph_get()
instances=[] 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: for inst in dg.object_instances:
o=inst.object;src=o.original;cols=[c.name for c in src.users_collection] 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 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 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
# Exclude standalone instancing source collections; their placed instances survive.
if not inst.is_instance and not src.visible_get():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) 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)): for idx in range(len(mesh.materials)):
name=names[idx] if idx<len(names) else '' name=mesh.materials[idx].name if mesh.materials[idx] else '';key=classify(name,src.name)
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[key];assignment[key].add(src.name)
mesh.materials[idx]=materials[kind] if not mesh.materials:mesh.materials.append(materials['body']);assignment['body'].add(src.name)
if not mesh.materials:mesh.materials.append(materials['metal']) instances.append((src.name,mesh,inst.matrix_world.copy()))
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=[]
scene=bpy.data.scenes.new('Mission Core rover export');bpy.context.window.scene=scene for name,mesh,matrix in instances:
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) o=bpy.data.objects.new(name,mesh);scene.collection.objects.link(o);o.matrix_world=matrix;objects.append(o)
bpy.context.view_layer.update() 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))) 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))) 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)) 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 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.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' 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') 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','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} 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(json.dumps(manifest)) (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')