Accumulated uncommitted work: GHOST Web backend, WebGL/GPU web layer, web workflow engine and protocol, tooling, docs and task records. Large generated evidence, user-testing archives, build snapshots and imported test assets remain untracked. Co-Authored-By: Claude Code <noreply@anthropic.com>
99 lines
22 KiB
TypeScript
99 lines
22 KiB
TypeScript
import { expect, test } from '@playwright/test';
|
|
import fs from 'node:fs'; import path from 'node:path';
|
|
import { observeNative, performNativeAction, captureNativeWorkflowState, exposeWebTools, resolveProjectReplacement } from '../fixtures/native-ui-workflow';
|
|
import { queryNativeJSON, readNativeBlend, readNativeHistory, waitNativeModal, transformNative, focusNativeViewport, nativeKeyFrame, nativeRequest } from '../fixtures/native-modeling-workflow';
|
|
import { openFromUI, showNativeObjectProperties, expectUILocation, runWasmPython, switchNativeWorkspace } from '../fixtures/wasm-console-workflow';
|
|
import { unzipSync } from 'fflate';
|
|
const root=path.resolve(import.meta.dirname,'../../..'),fixture=path.resolve(root,process.env.WBV2_CASE_FIXTURE!),meta=JSON.parse(fs.readFileSync(fixture.replace(/\.blend$/,'.json'),'utf8'));
|
|
const tracePrefix=process.env.WBV2_TRACE_MAIN_BYTES==='1'?"import os; os.environ['WEB_ENGINE_TRACE_MAIN_BYTES']='1'; ":'';
|
|
test.afterEach(async({page},info)=>{
|
|
fs.writeFileSync(info.outputPath('b6-last-state.json'),JSON.stringify(await captureNativeWorkflowState(page).catch(error=>({error:String(error)})),null,2));fs.writeFileSync(info.outputPath('b6-last-history.json'),JSON.stringify(await readNativeHistory(page).catch(error=>({error:String(error)})),null,2));
|
|
if(info.status!==info.expectedStatus){const bytes=await readNativeBlend(page).catch(()=>null);if(bytes)fs.writeFileSync(info.outputPath('b6-failure.blend'),bytes);}
|
|
if(info.status!==info.expectedStatus&&tracePrefix){
|
|
for(const name of ['ui-before','ui-after','readback-before','readback-after']){
|
|
const result=await nativeRequest(page,{type:'editorResourceDownload',path:`/browser/b6-diagnostics/${name}.blend`}).catch(error=>({error:String(error)}));
|
|
if(result.fileBytes)fs.writeFileSync(info.outputPath(`native-${name}.blend`),Buffer.from(result.fileBytes));else fs.writeFileSync(info.outputPath(`native-${name}-error.json`),JSON.stringify(result));
|
|
}
|
|
}
|
|
});
|
|
test('B6 production composite keeps hierarchy materials camera animation and file recovery in Main',async({page},info)=>{
|
|
const evidence:Record<string,unknown>={},record=(key:string,value:unknown)=>{evidence[key]=value;fs.writeFileSync(info.outputPath('b6-composite.json'),JSON.stringify(evidence,null,2));};
|
|
const serverCalls:string[]=[];page.on('request',request=>{if(/^\/api\/(blender-session|blender-workflow|server-operator-job)$/.test(new URL(request.url()).pathname))serverCalls.push(request.url());});
|
|
const byteTrace:string[]=[];page.on('console',message=>{const text=message.text();if(text.startsWith('WBV2_MAIN_BYTES ')){byteTrace.push(text);if(byteTrace.length>32)byteTrace.shift();fs.writeFileSync(info.outputPath('b6-main-byte-trace.json'),JSON.stringify(byteTrace));}});
|
|
await observeNative(page); await page.goto('/'); await expect.poll(()=>page.evaluate(()=>Array.isArray((window as any).__nativeWorkflowMain?.nodes)),{timeout:60000}).toBe(true);
|
|
await openFromUI(page,()=>page.getByTestId('blend-file-input').setInputFiles(fixture)); await waitNativeModal(page,false);
|
|
await expect(page.locator('canvas[aria-label="Blender desktop"]')).toHaveAttribute('data-native-input-ready','true',{timeout:60000});
|
|
const initialFrame=await page.evaluate(()=>(window as any).__nativeWorkflowGPU?.requestId); await exposeWebTools(page); await waitNativeModal(page,false,initialFrame);
|
|
const oracle=[
|
|
'import bpy,json,hashlib',
|
|
`s=bpy.context.scene; target=bpy.data.objects[${JSON.stringify(meta.target)}]; a=target.animation_data.action; objects=sorted(s.objects,key=lambda o:o.name)`,
|
|
"mesh=lambda o:{'vertices':len(o.data.vertices),'polygons':len(o.data.polygons),'sha256':hashlib.sha256(json.dumps([[list(v.co) for v in o.data.vertices],[list(p.vertices) for p in o.data.polygons]],separators=(',',':')).encode()).hexdigest()} if o.type=='MESH' else None",
|
|
"records=[{'name':o.name,'type':o.type,'parent':o.parent.name if o.parent else None,'location':list(o.location),'rotation':list(o.rotation_euler),'scale':list(o.scale),'world':[list(row) for row in o.evaluated_get(bpy.context.evaluated_depsgraph_get()).matrix_world],'mesh':mesh(o),'materials':[m.name for m in getattr(o.data,'materials',[]) if m]} for o in objects]",
|
|
"materials=sorted({m for o in objects for m in getattr(o.data,'materials',[]) if m},key=lambda m:m.name)",
|
|
"curves=[{'path':f.data_path,'index':f.array_index,'keys':[[k.co[0],k.co[1],k.interpolation] for k in f.keyframe_points]} for l in a.layers for st in l.strips for bag in getattr(st,'channelbags',[]) for f in bag.fcurves]",
|
|
"print(json.dumps({'frame':s.frame_current,'active':bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None,'selected':sorted(o.name for o in bpy.context.view_layer.objects if o.select_get()),'objects':records,'materials':[{'name':m.name,'color':list(m.diffuse_color)} for m in materials],'camera':{'name':s.camera.name,'lens':s.camera.data.lens,'clipStart':s.camera.data.clip_start,'clipEnd':s.camera.data.clip_end},'render':{'resolution':[s.render.resolution_x,s.render.resolution_y,s.render.resolution_percentage],'format':s.render.image_settings.file_format,'world':s.world.name if s.world else None},'animation':{'name':a.name,'fakeUser':a.use_fake_user,'curves':curves}}))"
|
|
].join('\n');
|
|
const read=async()=>{const gpu=await page.evaluate(()=>(window as any).__nativeWorkflowGPU);if(gpu?.attached)await waitNativeModal(page,false);return queryNativeJSON(page,oracle);};
|
|
const before=await read(); record('opened',before);
|
|
const identity=(items:any[])=>items.map(o=>({name:o.name,type:o.type,parent:o.parent,mesh:o.mesh?{vertices:o.mesh.vertices,polygons:o.mesh.polygons}:null,materials:o.materials}));
|
|
expect(identity(before.value.objects)).toEqual(identity(meta.objects)); expect(before.value.materials).toEqual(meta.materials); expect(before.value.camera).toEqual(meta.camera); expect(before.value.render).toEqual(meta.render); expect(before.value.animation.name).toBe(meta.action);
|
|
const select=async(name:string)=>{const ancestors:string[]=[];for(let parent=meta.objects.find((o:any)=>o.name===name)?.parent;parent;parent=meta.objects.find((o:any)=>o.name===parent)?.parent)ancestors.unshift(parent);
|
|
for(const ancestor of ['Scene Collection',...ancestors]){const expand=page.getByRole('button',{name:`展开 ${ancestor}`,exact:true});if(await expand.isVisible()){await performNativeAction(page,{command:'editEditorContext',editAction:'OUTLINER_TOGGLE_EXPAND'},()=>expand.click());await expect(page.getByRole('treeitem',{name:ancestor,exact:true})).toHaveAttribute('aria-expanded','true');}}
|
|
const row=page.getByRole('treeitem',{name,exact:true}).first();await expect(row).toBeVisible();await performNativeAction(page,{command:'setObjectSelection'},()=>row.click());await expect.poll(async()=>(await queryNativeJSON(page,"import bpy,json; print(json.dumps(bpy.context.view_layer.objects.active.name if bpy.context.view_layer.objects.active else None))")).value).toBe(name);};
|
|
await select(meta.target); await showNativeObjectProperties(page); await expectUILocation(page,meta.target,before.value.objects.find((o:any)=>o.name===meta.target).location); record('hierarchyUI',{root:meta.root,target:meta.target,objects:before.value.objects.length});
|
|
const history=async(command:'undo'|'redo')=>performNativeAction(page,{type:'applyCommand',command},()=>page.getByRole('button',{name:command==='undo'?'撤销':'重做',exact:true}).click());
|
|
await focusNativeViewport(page); const cancelBefore=await read(),cancelHistory=await readNativeHistory(page),cancelBytes=await readNativeBlend(page);
|
|
await page.keyboard.press('g');const cancelling=await waitNativeModal(page,true);await page.keyboard.press('x');await page.keyboard.type('3');await page.keyboard.press('Escape');await waitNativeModal(page,false,cancelling.requestId);
|
|
const cancelled=await read(),cancelHistoryAfter=await readNativeHistory(page),cancelBytesAfter=await readNativeBlend(page);record('transformCancelled',{before:cancelBefore,after:cancelled,historyBefore:cancelHistory,historyAfter:cancelHistoryAfter});fs.writeFileSync(info.outputPath('cancel-before.blend'),cancelBytes);fs.writeFileSync(info.outputPath('cancel-after.blend'),cancelBytesAfter);
|
|
expect(cancelled.value).toEqual(cancelBefore.value);expect({...cancelHistoryAfter,revision:cancelHistory.revision}).toEqual(cancelHistory);expect(cancelBytesAfter.equals(cancelBytes)).toBe(true);
|
|
const transformReceipt=await transformNative(page,'g','x','0.02');record('transformReceipt',transformReceipt);const moved=await read();expect(moved.value.objects.find((o:any)=>o.name===meta.target).location[0]).toBeCloseTo(cancelBefore.value.objects.find((o:any)=>o.name===meta.target).location[0]+0.02,5);record('moved',moved);
|
|
const movedFrame=await page.evaluate(()=>(window as any).__nativeWorkflowGPU?.requestId);await exposeWebTools(page);await waitNativeModal(page,false,movedFrame);
|
|
await history('undo');const transformUndone=await read();expect(transformUndone.value).toEqual(cancelBefore.value);await history('redo');const transformRedone=await read();expect(transformRedone.value).toEqual(moved.value);record('transformHistory',{undone:transformUndone,redone:transformRedone});
|
|
await history('undo');expect((await read()).value).toEqual(cancelBefore.value);
|
|
const slider=page.getByLabel('当前帧',{exact:true}),middle=Math.floor((meta.frameStart+meta.frameEnd)/2),seek=async(frame:number)=>{await slider.focus();if(Number(await slider.inputValue())!==meta.frameStart)await performNativeAction(page,{command:'setFrame'},()=>slider.press('Home'));for(let f=meta.frameStart;f<frame;f++)await performNativeAction(page,{command:'setFrame'},()=>slider.press('ArrowRight'));await expect(slider).toHaveValue(String(frame));};
|
|
await seek(middle);const linear=await read(),targetBefore=before.value.objects.find((o:any)=>o.name===meta.target),targetLinear=linear.value.objects.find((o:any)=>o.name===meta.target),delta=meta.motionDelta*(middle-meta.frameStart)/(meta.frameEnd-meta.frameStart);expect(targetLinear.location[0]).toBeCloseTo(targetBefore.location[0]+delta,5);expect(targetLinear.world[0][3]).toBeCloseTo(targetBefore.world[0][3]+delta,5);expect(linear.value.objects.map((o:any)=>o.mesh)).toEqual(before.value.objects.map((o:any)=>o.mesh));record('animatedLinear',linear);
|
|
await performNativeAction(page,{command:'setFCurveInterpolation'},()=>page.getByLabel('FCurve 插值',{exact:true}).selectOption('CONSTANT'));const constant=await read();expect(constant.value.objects.find((o:any)=>o.name===meta.target).location[0]).toBeCloseTo(targetBefore.location[0],5);record('animatedConstant',constant);
|
|
await history('undo');expect((await read()).value).toEqual(linear.value);await history('redo');expect((await read()).value).toEqual(constant.value);record('curveHistory',{undone:linear.value,redone:constant.value});
|
|
await performNativeAction(page,{command:'setFCurveInterpolation'},()=>page.getByLabel('FCurve 插值',{exact:true}).selectOption('LINEAR'));const playBefore=await read(),playHistory=await readNativeHistory(page),playBytes=await readNativeBlend(page);
|
|
await performNativeAction(page,{command:'editEditorContext',editAction:'TIMELINE_EDIT',editOperation:'PLAY_FORWARD'},()=>page.getByRole('button',{name:'播放',exact:true}).click());const cancelPlay=page.getByRole('button',{name:'取消播放并恢复起始帧',exact:true});await expect(cancelPlay).toBeEnabled();await expect.poll(()=>slider.inputValue()).not.toBe(String(middle));
|
|
record('playbackProgress',{frame:await slider.inputValue(),history:await readNativeHistory(page),native:await captureNativeWorkflowState(page)});
|
|
await performNativeAction(page,{command:'editEditorContext',editAction:'TIMELINE_EDIT',editOperation:'CANCEL_PLAYBACK'},()=>cancelPlay.click());await expect(cancelPlay).toBeDisabled();const playAfter=await read(),playHistoryAfter=await readNativeHistory(page),playBytesAfter=await readNativeBlend(page);
|
|
record('playbackCancelled',{before:playBefore,after:playAfter,historyBefore:playHistory,historyAfter:playHistoryAfter});fs.writeFileSync(info.outputPath('play-before.blend'),playBytes);fs.writeFileSync(info.outputPath('play-after.blend'),playBytesAfter);
|
|
if(playHistoryAfter.cursor!==playHistory.cursor){await history('undo');fs.writeFileSync(info.outputPath('unexpected-history-before.blend'),await readNativeBlend(page));record('unexpectedHistoryBefore',await read());await history('redo');record('unexpectedHistoryRestored',await read());}
|
|
expect(playAfter.value).toEqual(playBefore.value);expect({...playHistoryAfter,revision:playHistory.revision}).toEqual(playHistory);expect(playBytesAfter.equals(playBytes)).toBe(true);
|
|
await switchNativeWorkspace(page,'Scripting');
|
|
const cameraBefore=await read(),newLens=cameraBefore.value.camera.lens+2,cameraHistoryBefore=await readNativeHistory(page);record('cameraHistoryBefore',cameraHistoryBefore);
|
|
const cameraReply=await runWasmPython(page,`${tracePrefix}import bpy; bpy.data.objects[${JSON.stringify(meta.camera.name)}].data.lens=${newLens}; print('B6_CAMERA',bpy.data.objects[${JSON.stringify(meta.camera.name)}].data.lens)`);expect(cameraReply.changed).toBe(true);const cameraAfter=await read();expect(cameraAfter.value.camera.lens).toBe(newLens);expect({...cameraAfter.value,camera:cameraBefore.value.camera}).toEqual(cameraBefore.value);
|
|
await expect(page.getByRole('region',{name:'Python Console',exact:true}).locator('pre')).toContainText(`B6_CAMERA ${newLens}`);record('cameraEdited',{route:'WASM_CONSOLE_NATIVE_RNA',before:cameraBefore,after:cameraAfter,reply:cameraReply});
|
|
record('cameraHistoryAfterEdit',await readNativeHistory(page));await history('undo');const cameraUndone=await read();record('cameraUndoAttempt',{main:cameraUndone,history:await readNativeHistory(page)});expect(cameraUndone.value).toEqual(cameraBefore.value);await history('redo');expect((await read()).value).toEqual(cameraAfter.value);record('cameraHistory',{undone:cameraBefore.value,redone:cameraAfter.value});
|
|
for(const workspace of ['Animation','Shading','Layout']){await switchNativeWorkspace(page,workspace);const current=await read();expect(current.value).toEqual(cameraAfter.value);record(`workspace${workspace}`,current);}
|
|
await select(meta.target);await showNativeObjectProperties(page);await expectUILocation(page,meta.target,cameraAfter.value.objects.find((o:any)=>o.name===meta.target).location);
|
|
const chooseCommand=async(label:string)=>{await exposeWebTools(page);await page.getByRole('button',{name:'操作搜索',exact:true}).click();const dialog=page.getByRole('dialog',{name:'操作搜索',exact:true});await dialog.getByRole('textbox',{name:'搜索 Blender 操作'}).fill(label);await dialog.getByRole('button',{name:label,exact:true}).click();};
|
|
const browserOpen=()=>page.evaluate(()=>(window as any).__nativeWorkflowEditor?.context?.activeEditor==='FILE_BROWSER');
|
|
const focusBrowser=async()=>{const bounds=await page.locator('canvas[aria-label="Blender desktop"]').boundingBox();expect(bounds).not.toBeNull();await performNativeAction(page,{type:'gpuUI',gpuOperation:'frame',mouseReleased:true},()=>page.mouse.click(bounds!.x+bounds!.width/2,bounds!.y+bounds!.height/2));};
|
|
const prepareImport=async()=>{await chooseCommand('Import OBJ');const dialog=page.getByRole('dialog',{name:'导入 OBJ 与材质',exact:true});await dialog.getByLabel('选择文件夹',{exact:true}).setInputFiles(path.join(root,'tests/files/web/b3_obj_bundle_v1'));await dialog.getByRole('button',{name:'打开导入选项',exact:true}).click();await expect(dialog).toBeHidden();await waitNativeModal(page,true);await page.locator('canvas[aria-label="Blender desktop"]').focus();};
|
|
const importBefore=await read(),importHistory=await readNativeHistory(page);record('beforeImport',importBefore);
|
|
await prepareImport();await performNativeAction(page,{type:'gpuUI',gpuOperation:'frame',keyPressed:27},()=>page.keyboard.press('Escape'));await expect.poll(browserOpen).toBe(false);await waitNativeModal(page,false);const importCancelled=await read(),importCancelHistory=await readNativeHistory(page);record('importCancelled',{before:importBefore,after:importCancelled,historyBefore:importHistory,historyAfter:importCancelHistory});
|
|
expect(importCancelled.value).toEqual(importBefore.value);expect(importCancelHistory.cursor).toBe(importHistory.cursor);expect(importCancelHistory.entries).toEqual(importHistory.entries);
|
|
await prepareImport();await performNativeAction(page,{type:'gpuUI',gpuOperation:'frame',keyPressed:13},()=>page.keyboard.press('Enter'));await expect.poll(browserOpen).toBe(false);await waitNativeModal(page,false);
|
|
const imported=await read(),originalNames=new Set(importBefore.value.objects.map((o:any)=>o.name)),added=imported.value.objects.filter((o:any)=>!originalNames.has(o.name));expect(added).toHaveLength(1);expect(added[0].mesh.vertices).toBe(3);expect(added[0].mesh.polygons).toBe(1);expect(added[0].materials).toContain('Painted');
|
|
expect(imported.value.objects.filter((o:any)=>originalNames.has(o.name))).toEqual(importBefore.value.objects);expect(imported.value.animation).toEqual(importBefore.value.animation);expect(imported.value.camera).toEqual(importBefore.value.camera);record('imported',imported);fs.writeFileSync(info.outputPath('imported-main.blend'),await readNativeBlend(page));
|
|
await exposeWebTools(page);await history('undo');const importUndone=await read();expect(importUndone.value).toEqual(importBefore.value);await history('redo');const importRedone=await read();expect(importRedone.value).toEqual(imported.value);record('importHistory',{undone:importUndone,redone:importRedone});
|
|
await chooseCommand('Export OBJ');await expect.poll(browserOpen,{timeout:30000}).toBe(true);await focusBrowser();await performNativeAction(page,{type:'gpuUI',gpuOperation:'frame',keyPressed:27},()=>page.keyboard.press('Escape'));await expect.poll(browserOpen).toBe(false);await waitNativeModal(page,false);expect((await read()).value).toEqual(imported.value);record('exportCancelled',await read());
|
|
await chooseCommand('Export OBJ');await expect.poll(browserOpen,{timeout:30000}).toBe(true);await focusBrowser();const exporting=page.waitForEvent('download',{timeout:60000});await performNativeAction(page,{type:'gpuUI',gpuOperation:'frame',keyPressed:13},()=>page.keyboard.press('Enter'));
|
|
const exported=await exporting,zipPath=info.outputPath('composite-export.zip');expect(exported.suggestedFilename()).toMatch(/\.zip$/i);await exported.saveAs(zipPath);const files=unzipSync(fs.readFileSync(zipPath)),names=Object.keys(files),objName=names.find(name=>/\.obj$/i.test(name));expect(objName).toBeDefined();expect(names.some(name=>/\.mtl$/i.test(name))).toBe(true);
|
|
const objText=Buffer.from(files[objName!]).toString('utf8'),vertices=objText.split('\n').filter(line=>line.startsWith('v ')).length,faces=objText.split('\n').filter(line=>line.startsWith('f ')).length;expect(vertices).toBeGreaterThanOrEqual(imported.value.objects.reduce((n:number,o:any)=>n+(o.mesh?.vertices??0),0));expect(faces).toBeGreaterThanOrEqual(imported.value.objects.reduce((n:number,o:any)=>n+(o.mesh?.polygons??0),0));
|
|
expect(Object.values(files).some(bytes=>Buffer.from(bytes).equals(fs.readFileSync(path.join(root,'tests/files/web/b3_obj_bundle_v1/textures/albedo.png'))))).toBe(true);await expect.poll(browserOpen).toBe(false);await waitNativeModal(page,false);expect((await read()).value).toEqual(imported.value);record('exported',{zipPath,names,vertices,faces});
|
|
await exposeWebTools(page);const downloading=page.waitForEvent('download',{timeout:60000});await page.getByRole('button',{name:'保存项目',exact:true}).click();const saved=info.outputPath('composite-roundtrip.blend');await(await downloading).saveAs(saved);await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-save-status','SUCCEEDED');record('saved',{path:saved,bytes:fs.statSync(saved).size});
|
|
const failureBefore=await read(),failureHistory=await readNativeHistory(page),failureBytes=await readNativeBlend(page),owner=await page.locator('main.blender-app').getAttribute('data-project-id');
|
|
await page.getByTestId('blend-file-input').setInputFiles({name:'b6-invalid.blend',mimeType:'application/x-blender',buffer:Buffer.from('B6_INVALID_BLEND')});const replacement=page.getByRole('dialog',{name:'保存当前工程的修改?',exact:true});
|
|
await expect.poll(async()=>await replacement.isVisible()?'PROMPT':String(await page.locator('main.blender-app').getAttribute('data-user-action-open-status')),{timeout:30000}).toMatch(/^(PROMPT|FAILED)$/);if(await replacement.isVisible())await resolveProjectReplacement(page,'discard');
|
|
await expect(page.locator('main.blender-app')).toHaveAttribute('data-user-action-open-status','FAILED');await expect(page.locator('main.blender-app')).toHaveAttribute('aria-busy','false');const failureAfter=await read(),failureHistoryAfter=await readNativeHistory(page),failureBytesAfter=await readNativeBlend(page);
|
|
record('failedOpenRetained',{before:failureBefore,after:failureAfter,historyBefore:failureHistory,historyAfter:failureHistoryAfter,message:await page.getByTestId('engine-status').innerText()});fs.writeFileSync(info.outputPath('failed-open-before.blend'),failureBytes);fs.writeFileSync(info.outputPath('failed-open-after.blend'),failureBytesAfter);
|
|
expect(failureAfter.value).toEqual(failureBefore.value);expect(failureHistoryAfter).toEqual(failureHistory);expect(failureBytesAfter.equals(failureBytes)).toBe(true);expect(await page.locator('main.blender-app').getAttribute('data-project-id')).toBe(owner);
|
|
await openFromUI(page,()=>page.getByTestId('blend-file-input').setInputFiles(saved));const reopened=await read();expect(reopened.value).toEqual(imported.value);record('reopened',reopened);
|
|
await select(meta.target);await showNativeObjectProperties(page);await expectUILocation(page,meta.target,reopened.value.objects.find((o:any)=>o.name===meta.target).location);
|
|
await focusNativeViewport(page);const viewFrame=await nativeKeyFrame(page,'Home',36);const framed=await read();expect({...framed.value,active:reopened.value.active,selected:reopened.value.selected}).toEqual(reopened.value);await page.screenshot({path:info.outputPath('composite-native-viewport.png')});record('nativeViewport',{frame:viewFrame,state:framed});
|
|
expect(serverCalls).toEqual([]);record('result',{status:'PASS',diagnosticTrace:Boolean(tracePrefix),kind:meta.kind,fixture,candidate:process.env.WEB_DESKTOP_CANDIDATE_PUBLIC,fullProductionAssembly:meta.assembly.completeURDFVisuals,objectsBefore:meta.objects.length,objectsAfter:imported.value.objects.length,saved,exported:zipPath,saveReopenRoute:'BROWSER_EQUIVALENT',cameraEditRoute:'WASM_CONSOLE_NATIVE_RNA',serverCalls,scope:'Whole hierarchy/mesh/material integrity, native transforms and cancellation, animation, camera, workspace continuity, OBJ import/export, error recovery, UndoRedo and actual SaveReopen'});
|
|
});
|