Fix editor regressions and add Blender scene and animated material import

This commit is contained in:
emil28092005
2026-09-12 00:53:20 +03:00
parent 1a0bf725fa
commit 6f497d1977
34 changed files with 1241 additions and 213 deletions
+48
View File
@@ -0,0 +1,48 @@
"""Synthetic integration fixture; does not use any user's project assets."""
import bpy
import sys
bpy.ops.object.select_all(action='SELECT')
bpy.ops.object.delete(use_global=False)
scene = bpy.context.scene
scene.name = 'ExportFixture'
scene.frame_start, scene.frame_end, scene.render.fps = 1, 3, 24
bpy.ops.mesh.primitive_cube_add()
obj = bpy.context.object
obj.name = 'AnimatedCube'
material = bpy.data.materials.new('AnimatedMaterial')
material.use_nodes = True
obj.data.materials.append(material)
tree = material.node_tree
principled = tree.nodes.get('Principled BSDF')
noise = tree.nodes.new('ShaderNodeTexNoise')
noise.noise_dimensions = '4D'
noise.inputs['W'].driver_add('default_value').driver.expression = 'frame * 0.7'
tree.links.new(noise.outputs['Color'], principled.inputs['Base Color'])
for frame, value in [(1, .2), (3, .8)]:
principled.inputs['Roughness'].default_value = value
principled.inputs['Roughness'].keyframe_insert('default_value', frame=frame)
obj.shape_key_add(name='Basis')
key = obj.shape_key_add(name='Stretch')
key.data[0].co.z += 1
for frame, value in [(1, 0), (3, 1)]:
key.value = value
key.keyframe_insert('value', frame=frame)
bpy.ops.object.camera_add(location=(0, 0, 5))
scene.camera = bpy.context.object
scene.camera.name = 'ActiveCameraObject'
scene.camera.data.name = 'ActiveCameraData'
bpy.ops.object.light_add(type='POINT', location=(0, -2, 3))
bpy.context.object.data.energy = 100
curve = bpy.data.curves.new('CurveGeometry', 'CURVE')
curve.dimensions = '3D'
curve.bevel_depth = .1
spline = curve.splines.new('POLY')
spline.points.add(1)
spline.points[0].co, spline.points[1].co = (0, 0, 0, 1), (1, 1, 1, 1)
curve_obj = bpy.data.objects.new('ConvertedCurve', curve)
scene.collection.objects.link(curve_obj)
# A second scene must not leak into the chosen scene's export.
other = bpy.data.scenes.new('ExcludedScene')
other.collection.objects.link(bpy.data.objects.new('UnrelatedObject', bpy.data.meshes.new('Unused')))
scene.frame_set(1)
bpy.ops.wm.save_as_mainfile(filepath=sys.argv[sys.argv.index('--') + 1])
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {promisify} from 'node:util';
import {execFile} from 'node:child_process';
import {mkdtemp,readFile,rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {inspectModel,modelDocument} from '../engine/model.ts';
const exec=promisify(execFile), binary=process.env.FORMA_BLENDER;
test('Blender exports shape keys, curves, cameras, lights and driver-baked animated textures', {skip:!binary,timeout:180000}, async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-blender-'));
try{
const input=path.join(dir,'input.blend'),output=path.join(dir,'output.glb');
await exec(binary!,['--background','--factory-startup','--disable-autoexec','--python-exit-code','1','--python',path.resolve('tests/blender-fixture.py'),'--',input],{maxBuffer:4*1024*1024});
const original=await readFile(input);
await exec(process.execPath,['scripts/export-blender.mjs',input,'--output',output,'--bake-materials','--resolution','16','--samples','1'],{maxBuffer:4*1024*1024});
assert.deepEqual(await readFile(input),original,'the source .blend is never saved or modified');
const bytes=new Uint8Array(await readFile(output)),doc=modelDocument(bytes,'output.glb'),meta=inspectModel(bytes,'output.glb');
assert.equal(doc.scenes.length,1);assert.equal(meta.cameras.length,1);assert.equal(meta.lights,1);assert.equal(meta.morphTargets,1);
assert.equal(meta.activeCamera,'ActiveCameraData');
assert.ok(Math.abs(doc.extensions.KHR_lights_punctual.lights[0].intensity-100/(4*Math.PI))<.001,'Forma-compatible light units avoid overexposure');
assert.ok(doc.nodes.some((n:any)=>n.name==='ConvertedCurve'&&n.mesh!==undefined));
assert.ok(!doc.nodes.some((n:any)=>n.name==='UnrelatedObject'));
assert.ok(meta.animatedProperties.some(p=>p.endsWith('KHR_texture_transform/offset')));
const report=JSON.parse(await readFile(output.replace('.glb','.report.json'),'utf8'));
assert.equal(report.bakedMaterials[0].frames,3);
const material=doc.materials.find((m:any)=>m.name.startsWith('FormaBake_'));
assert.ok(material.pbrMetallicRoughness.baseColorTexture.extensions.KHR_texture_transform);
}finally{await rm(dir,{recursive:true,force:true});}
});
+36 -1
View File
@@ -39,7 +39,7 @@ export function findNode(p: any, id: string) {
}
/** A generated triangle, optionally skinned and animated; no game assets. */
export function triangleGlb(animated = false): Buffer {
export function triangleGlb(animated = false, configure?: (document: any, add: (data: Float32Array | Uint16Array, type: string, count: number, bounds?: Record<string, any>) => number) => void): Buffer {
const chunks: Buffer[] = [];
const views: any[] = [];
const accessors: any[] = [];
@@ -115,6 +115,7 @@ export function triangleGlb(animated = false): Buffer {
},
];
}
configure?.(document, add);
Object.assign(document, {
buffers: [{ byteLength: length }],
bufferViews: views,
@@ -153,3 +154,37 @@ export const triangleAsset = (animated = false) => ({
uri:
"data:model/gltf-binary;base64," + triangleGlb(animated).toString("base64"),
});
/** Material, camera, light and morph animation in one real glTF clip. */
export function sceneAnimationGlb() {
return triangleGlb(false, (doc, add) => {
doc.materials = [{ name: 'AnimatedPBR', pbrMetallicRoughness: { baseColorFactor: [1, 0, 0, 1], metallicFactor: .1, roughnessFactor: .2 } }];
doc.meshes[0].primitives[0].material = 0;
doc.meshes[0].primitives[0].targets = [{ POSITION: add(new Float32Array([0,0,0, 0,0,0, 0,1,0]), 'VEC3', 3) }];
doc.meshes[0].weights = [0];
doc.cameras = [{ name: 'ImportedCamera', type: 'perspective', perspective: { yfov: .7, znear: .1 } }];
doc.nodes.push({ name: 'ImportedCamera', camera: 0, translation: [0, 0, 5] }, { name: 'ImportedLight', extensions: { KHR_lights_punctual: { light: 0 } } });
doc.scenes[0].nodes.push(1, 2);
doc.cameras.push({ name: 'Ortho', type: 'orthographic', orthographic: { xmag: 2, ymag: 1, znear: .1, zfar: 100 } });
doc.nodes.push({ name: 'Ortho', camera: 1 });doc.scenes[0].nodes.push(3);
doc.extensions = { KHR_lights_punctual: { lights: [{ name: 'ImportedLight', type: 'point', intensity: 10 }] } };
doc.extensionsUsed = ['KHR_lights_punctual', 'KHR_animation_pointer'];
const input = add(new Float32Array([0, 1]), 'SCALAR', 2, { min: [0], max: [1] });
const animation: any = { name: 'Scene', channels: [], samplers: [] };
for (const [pointer, type, values] of [
['/materials/0/pbrMetallicRoughness/baseColorFactor', 'VEC4', [1,0,0,1, 0,0,1,.5]],
['/materials/0/pbrMetallicRoughness/metallicFactor', 'SCALAR', [.1,.9]],
['/materials/0/pbrMetallicRoughness/roughnessFactor', 'SCALAR', [.2,.8]],
['/cameras/0/perspective/yfov', 'SCALAR', [.7,1.1]],
['/cameras/1/orthographic/xmag', 'SCALAR', [2,4]],
['/cameras/1/orthographic/ymag', 'SCALAR', [1,3]],
['/extensions/KHR_lights_punctual/lights/0/intensity', 'SCALAR', [10,20]],
] as const) {
animation.channels.push({ sampler: animation.samplers.length, target: { path: 'pointer', extensions: { KHR_animation_pointer: { pointer } } } });
animation.samplers.push({ input, output: add(new Float32Array(values), type, 2), interpolation: 'LINEAR' });
}
animation.channels.push({ sampler: animation.samplers.length, target: { node: 0, path: 'weights' } });
animation.samplers.push({ input, output: add(new Float32Array([0,1]), 'SCALAR', 2), interpolation: 'LINEAR' });
doc.animations = [animation];
});
}
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {promisify} from 'node:util';
import {execFile} from 'node:child_process';
import {mkdtemp,writeFile,readFile,access,rm} from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {gameArchive,engineResource} from '../engine/archive.ts';
import {buildKit} from '../engine/build-kit.ts';
import {defaultProject} from '../engine/templates.ts';
test('hosted editor HTML and downloadable builds resolve inside a nested publication',async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-hosted-'));
const previous=Object.getOwnPropertyDescriptor(globalThis,'document');
try{
const project=defaultProject(true),source=path.join(dir,'source.forma.json'),out=path.join(dir,'demo');
await writeFile(source,JSON.stringify(project));
await promisify(execFile)(process.execPath,['--import','tsx','scripts/export-editor.ts',source,out]);
const html=await readFile(path.join(out,'index.html'),'utf8');
for(const match of html.matchAll(/(?:src|href)="([^"]+)"/g)){
assert.ok(match[1].startsWith('./'),match[1]);await access(path.join(out,match[1]));
}
Object.defineProperty(globalThis,'document',{value:{baseURI:'https://example.test/demo/index.html'},configurable:true});
const requested:string[]=[];
const read=async(uri:string)=>{requested.push(uri);assert.ok(uri.startsWith('https://example.test/demo/'),uri);return new Uint8Array(await readFile(path.join(out,new URL(uri).pathname.slice('/demo/'.length))));};
await gameArchive(project,read);await buildKit(project,{target:'linux',name:'Fixture'},read);
assert.ok(requested.includes('https://example.test/demo/engine/player.js'));
assert.ok(requested.includes('https://example.test/demo/build-targets/manifest.json'));
assert.equal(engineResource('/engine/player.js','https://example.test/demo/'),'https://example.test/demo/engine/player.js');
}finally{
if(previous)Object.defineProperty(globalThis,'document',previous);else delete (globalThis as any).document;
await rm(dir,{recursive:true,force:true});
}
});
+34
View File
@@ -0,0 +1,34 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { zipSync, strToU8 } from 'fflate';
import { portableModel, modelComponents, modelResourcePath } from '../engine/model-import.ts';
import { inspectModel, modelDocument } from '../engine/model.ts';
import { sceneAnimationGlb } from './fixtures.ts';
test('model metadata exposes scene contents and material animation',()=>{
const m=inspectModel(sceneAnimationGlb(),'scene.glb');
assert.deepEqual(m.clips,['Scene']);assert.equal(m.lights,1);assert.equal(m.morphTargets,1);assert.equal(m.cameras.length,2);
assert.ok(m.animatedProperties.includes('/materials/0/pbrMetallicRoughness/roughnessFactor'));
assert.equal(modelComponents('a',m).animator!.autoplay,'Scene');
});
test('zipped glTF bundles embed relative buffers and textures for offline reload',async()=>{
const gltf={asset:{version:'2.0'},buffers:[{uri:'../geometry.bin',byteLength:4}],images:[{uri:'textures/color%20map.png'}]};
const zip=zipSync({'scene/model.gltf':strToU8(JSON.stringify(gltf)),'geometry.bin':new Uint8Array([1,2,3,4]),'scene/textures/color map.png':new Uint8Array([9,8,7])});
const model=await portableModel(zip,'scene.zip'),doc=modelDocument(model.bytes,model.name);
assert.equal(model.name,'model.gltf');assert.match(doc.buffers[0].uri,/^data:application\/octet-stream;base64,AQIDBA==$/);
assert.match(doc.images[0].uri,/^data:image\/png;base64,CQgH$/);
});
test('model import rejects missing resources, remote URLs, traversal and ambiguous bundles',async()=>{
for(const uri of ['../../outside.bin','https://example.test/a.bin','%2fetc/passwd','C:/secret','textures\\a.png']) assert.throws(()=>modelResourcePath('scene/a.gltf',uri));
await assert.rejects(()=>portableModel(strToU8(JSON.stringify({asset:{version:'2.0'},buffers:[{uri:'a.bin'}]})),'a.gltf'),/связанные/);
await assert.rejects(()=>portableModel(zipSync({'a.glb':sceneAnimationGlb(),'b.glb':sceneAnimationGlb()}),'model.zip'),/ровно одну/);
});
import {modelProject} from '../engine/model-import.ts';
import {validateProject,activeScene} from '../engine/schema.ts';
test('opening an imported scene uses its camera, timeline and lighting',async()=>{
const p=modelProject(await portableModel(sceneAnimationGlb(),'scene.glb'));
validateProject(p);const n=activeScene(p).entities[0];
assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
assert.equal(p.settings.rendering!.defaultLights,false);assert.equal(p.assets.length,1);
});
+16
View File
@@ -184,3 +184,19 @@ test("real MCP HTTP + stdio share revisions, models, scripts, resources and expo
await rm(dir, { recursive: true, force: true });
}
});
test('MCP scene ZIP import replaces the scene atomically and Undo restores the prior project',async()=>{
const dir=await mkdtemp(path.join(os.tmpdir(),'forma-scene-'));
const s=await createService({projectDir:dir,port:0,blank:true});const client=new Client({name:'scene-test',version:'1'});
try{
await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${s.port}/mcp`),{requestInit:{headers:{authorization:'Bearer '+s.token}}}));
const original=structuredClone(s.service.store.project);
const {zipSync}=await import('fflate');const {sceneAnimationGlb}=await import('./fixtures.ts');
const archive=zipSync({'scene.glb':sceneAnimationGlb()});
const result=await client.callTool({name:'asset_import_glb',arguments:{expectedRevision:0,name:'scene.zip',base64:Buffer.from(archive).toString('base64'),asScene:true}});
assert.ok(!result.isError,JSON.stringify(result.content));
const p=s.service.store.project,n=p.scenes[0].entities[0];assert.equal(p.revision,1);assert.equal(n.components.camera.mode,'imported');assert.equal(n.components.animator.autoplay,'Scene');
const undo=await client.callTool({name:'history_undo',arguments:{expectedRevision:1}});assert.ok(!undo.isError,JSON.stringify(undo.content));
assert.equal(s.service.store.project.id,original.id);assert.equal(s.service.store.project.assets.length,0);
}finally{await client.close();await s.close();await rm(dir,{recursive:true,force:true});}
});
+44
View File
@@ -312,3 +312,47 @@ test(
}
},
);
test('changing a parent material or transform does not repaint child entities', async () => {
const r = runtime(), p = defaultProject(true);
const parent = entity('Parent', {mesh:{type:'box'}, material:{color:'#ff0000',alpha:.5}}, [0,0,0], 'parent');
const child = entity('Child', {mesh:{type:'box'}, material:{color:'#00ff00',alpha:1}}, [2,0,0], 'child');
child.parentId = parent.id; activeScene(p).entities = [parent,child];
try {
await r.load(p);
parent.transform.position = [3,0,0]; parent.components.material.roughness = .1;
await r.load(p);
const mat:any = r.nodes.get('child')!.getChildMeshes()[0].material;
assert.deepEqual(mat.albedoColor.asArray(), [0,1,0]); assert.equal(mat.alpha,1);
assert.equal((r.nodes.get('parent')!.getChildMeshes().find(m=>m.metadata.entityId==='parent')!.material as any).roughness,.1);
} finally { r.dispose(); }
});
import { sceneAnimationGlb } from './fixtures.ts';
test('imported material, morph, camera and light animation targets are independent per entity', async () => {
const r=runtime(), p=defaultProject(true), bytes=sceneAnimationGlb();
p.assets=[{id:'scene',name:'scene.glb',kind:'model',uri:'data:model/gltf-binary;base64,'+bytes.toString('base64')}];
activeScene(p).entities=['a','b'].map(id=>entity(id,{mesh:{type:'model',assetId:'scene'},...(id==='a'?{camera:{mode:'imported'},animator:{autoplay:'Scene'}}:{})},[0,0,0],id));
try {
await r.play(p);
assert.ok(!r.logs.some(l=>l.level==='error'),JSON.stringify(r.logs));
const a=r.containers.get('a')!, b=r.containers.get('b')!;
assert.equal(r.scene.activeCamera,a.cameras[0]);
const group=r.animations.get('a')![0];
assert.ok(group.isStarted,'autoplay starts the imported timeline');
group.pause(); group.goToFrame(group.to);
const am:any=a.materials[0], bm:any=b.materials[0];
const close=(value:number,expected:number)=>assert.ok(Math.abs(value-expected)<.0001,`${value} != ${expected}`);
close(am.metallic,.9);close(am.roughness,.8);close(am.albedoColor.b,1);close(am.alpha,.5);
close(bm.metallic,.1);close(bm.roughness,.2);close(bm.albedoColor.r,1);
close(a.cameras[0].fov,1.1);close(b.cameras[0].fov,.7);
close(a.cameras[1].orthoLeft!,-4);close(a.cameras[1].orthoRight!,4);
close(a.cameras[1].orthoBottom!,-3);close(a.cameras[1].orthoTop!,3);
close(a.lights[0].intensity,20);close(b.lights[0].intensity,10);
close(a.morphTargetManagers[0].getTarget(0).influence,1);
close(b.morphTargetManagers[0].getTarget(0).influence,0);
activeScene(p).entities=activeScene(p).entities.filter(n=>n.id==='b');
await r.stop(p);await r.load(p);
assert.equal(r.containers.has('a'),false);assert.equal(r.containers.get('b')!.meshes[0].isDisposed(),false);
} finally {r.dispose();}
});
+13
View File
@@ -27,3 +27,16 @@ test('local editor still gives the connected Forma project priority over browser
const result=await loadEditorProject({}, {request:async(url)=>Response.json(url==='/api/status'?status:{project}),draft:async()=>{throw Error('Must not read draft');}});
assert.equal(result.project.name,project.name);assert.deepEqual(result.status,status);
});
import {editorDraftKey} from '../editor/startup.ts';
test('hosted drafts are isolated by publication path and bundled project ID',async()=>{
const a=defaultProject(true),b=defaultProject(true);a.id='a';b.id='b';
const opts={browserOnly:true,initialProject:a,draftScope:'/demo-a/'};
const key=editorDraftKey(opts);const saved=structuredClone(a);saved.name='Edited A';
const drafts=new Map([[key,saved]]);const draft=async(key:string)=>drafts.get(key)||null;
assert.equal((await loadEditorProject(opts,{draft,request:neverRequest})).project.name,'Edited A');
assert.equal((await loadEditorProject({...opts,initialProject:b},{draft,request:neverRequest})).project.name,b.name);
assert.equal((await loadEditorProject({...opts,draftScope:'/demo-b/'},{draft,request:neverRequest})).restored,false);
assert.notEqual(editorDraftKey({browserOnly:true,initialProject:a},'https://host.test/one/'),editorDraftKey({browserOnly:true,initialProject:a},'https://host.test/two/'));
assert.equal(editorDraftKey({}),'active');
});
+16
View File
@@ -0,0 +1,16 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {bindViewInput} from '../engine/view-input.ts';
test('shared player/editor controls request mouse lock and route touch look only while active',()=>{
const canvas=new EventTarget() as HTMLCanvasElement;let locks=0;const moves:number[][]=[];
canvas.requestPointerLock=async()=>{locks++;};canvas.setPointerCapture=()=>{};
const runtime={playing:true,paused:false,firstPerson:()=>true,lookBy:(x:number,y:number)=>moves.push([x,y])};
const cleanup=bindViewInput(canvas,runtime);
const send=(type:string,props:any)=>canvas.dispatchEvent(Object.assign(new Event(type),props));
send('pointerdown',{pointerType:'mouse',button:0});assert.equal(locks,1);
send('pointerdown',{pointerType:'touch',pointerId:3,clientX:10,clientY:10});
send('pointermove',{pointerId:3,clientX:20,clientY:5});assert.deepEqual(moves,[[.04,.02]]);
runtime.paused=true;send('pointerdown',{pointerType:'mouse',button:0});send('pointermove',{pointerId:3,clientX:30,clientY:0});
assert.equal(locks,1);assert.equal(moves.length,1);
runtime.paused=false;cleanup();send('pointerdown',{pointerType:'mouse',button:0});assert.equal(locks,1);
});