feat(rover): add live scene and material editing with environment migration
This commit is contained in:
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);
|
||||||
|
});
|
||||||
@@ -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.
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user