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

This commit is contained in:
DCCONSTRUCTIONS
2026-09-25 19:13:52 +03:00
parent 9d3329f608
commit a993eda6b8
24 changed files with 3640 additions and 471 deletions
@@ -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);
});