171 lines
17 KiB
JavaScript
171 lines
17 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { createHash } from 'node:crypto';
|
|
import { pathToFileURL } from 'node:url';
|
|
import { loadWasmProbe } from './wasm-probe-runtime.mjs';
|
|
import { playwright } from './part-primitive-business-cases.mjs';
|
|
|
|
export const meshConversionId = 'Part:desktop:Part_ShapeFromMesh';
|
|
export async function runMeshConversionCases(ids) {
|
|
if (!ids.includes(meshConversionId)) return [];
|
|
const temporary = fs.mkdtempSync(path.join(os.tmpdir(), 'part-mesh-conversion-'));
|
|
const directory = path.resolve('docs/current/part-mesh-conversion-business', new Date().toISOString().replace(/[:.]/g, '-'));
|
|
fs.mkdirSync(directory, { recursive: true });
|
|
const result = { capabilityId: meshConversionId, status: 'failed', cases: [], evidenceFiles: {} };
|
|
const record = (input, actual, name) => result.cases.push({ input, actual, assertions: [{ name, passed: true }] });
|
|
const near = (a,b) => assert.ok(Number.isFinite(a) && Math.abs(a-b) < 1e-6, `${a} != ${b}`);
|
|
const stage = name => process.stderr.write(`Part mesh conversion: ${name}\n`);
|
|
let kernel, browser;
|
|
try {
|
|
execFileSync('web/node_modules/.bin/tsc', ['--target','ES2022','--module','ESNext','--moduleResolution','Bundler','--strict','--skipLibCheck',
|
|
'--outDir',temporary,'web/node_modules/vite/client.d.ts','web/src/part-desktop-adapter.ts'], { stdio: 'pipe' });
|
|
const facade = await import(pathToFileURL(path.join(temporary,'part-document-facade.js')));
|
|
const adapter = await import(pathToFileURL(path.join(temporary,'part-desktop-adapter.js')));
|
|
const { FacadeSerialQueue } = await import(pathToFileURL(path.join(temporary,'facade-envelope.js')));
|
|
stage('load shared mesh and Part document kernel');
|
|
({ kernel } = await loadWasmProbe('web/public/kernel','FreeCADWebDocumentProbe'));
|
|
const document = 'MeshConversionBusiness', queue = new FacadeSerialQueue(); let sequence = 0;
|
|
const router = { request: (_module,command,payload) => {
|
|
assert.equal(command,facade.PART_DOCUMENT_COMMAND);
|
|
return facade.dispatchPartDocument(queue,facade.createPartDocumentRequest(`mesh-conversion-${++sequence}`,payload),kernel);
|
|
} };
|
|
const request = (action,args={},name=document) => router.request('Part',facade.PART_DOCUMENT_COMMAND,{
|
|
schema:facade.PART_DOCUMENT_SCHEMA,module:'Part',document:name,action,builderInfo:true,includeMesh:true,...args });
|
|
const call = async (action,args={},name=document) => {
|
|
const reply=await request(action,args,name);assert.equal(reply.kind,'result',JSON.stringify(reply));return reply.result;
|
|
};
|
|
const python = source => {
|
|
const reply=JSON.parse(kernel.ccall('fc_web_dispatch_json','string',['string'],[JSON.stringify({cmd:'part.documentViewCommand',args:{
|
|
owner:'DocumentProbe',document,command:'Std_CommandLine',parameters:{operation:'submit',source:`exec(${JSON.stringify(source)})`}}})]));
|
|
assert.equal(reply.ok,true,JSON.stringify(reply));assert.equal(reply.result.stderr,'',JSON.stringify(reply));
|
|
return reply.result.stdout.trim()?JSON.parse(reply.result.stdout.trim()):null;
|
|
};
|
|
const ref = object => ({documentId:document,object,subelements:[]});
|
|
const execute = args => adapter.executePartFixedBoxDesktop(router,'Part_ShapeFromMesh',args,document);
|
|
const accepted = async args => {const reply=await execute(args);assert.equal(reply.kind,'result',JSON.stringify(reply));return reply.result;};
|
|
const item = (state,name) => state.objects.find(o=>o.name===name);
|
|
const signature = state => ({objects:state.objects,undoCount:state.undoCount,redoCount:state.redoCount});
|
|
await call('create');
|
|
python(`import FreeCAD as App, Part, Mesh, json
|
|
d=App.getDocument('${document}')
|
|
def add(name,mesh):
|
|
obj=d.addObject('Mesh::Feature',name); obj.Mesh=mesh; return obj
|
|
box=add('BoxMesh',Mesh.Mesh(Part.makeBox(2,3,4).tessellate(0.1))); box.Placement.Base=App.Vector(5,6,7)
|
|
plane=add('PlaneMesh',Mesh.Mesh([[App.Vector(0,0,0),App.Vector(2,0,0),App.Vector(2,2,0)],[App.Vector(0,0,0),App.Vector(2,2,0),App.Vector(0,2,0)]])); plane.Placement.Base=App.Vector(12,0,0)
|
|
empty=add('EmptyMesh',Mesh.Mesh())
|
|
bad=add('DegenerateMesh',Mesh.Mesh([[App.Vector(0,0,0),App.Vector(1,0,0),App.Vector(2,0,0)]]))
|
|
solid=d.addObject('Part::Box','Solid'); solid.Length=1; solid.Width=1; solid.Height=1
|
|
solid.addProperty('App::PropertyBool','WebDisplayVisibility','Web'); solid.WebDisplayVisibility=False
|
|
d.addObject('App::FeaturePython','NoShape');d.recompute()
|
|
def stats(shape):
|
|
b=shape.BoundBox
|
|
return dict(faces=len(shape.Faces),shells=len(shape.Shells),solids=len(shape.Solids),area=shape.Area,volume=shape.Volume,valid=shape.isValid(),bounds=[b.XMin,b.YMin,b.ZMin,b.XMax,b.YMax,b.ZMax])
|
|
def desktop(mesh,tolerance,sew):
|
|
shape=Part.Shape();shape.makeShapeFromMesh(mesh.Mesh.Topology,tolerance,sew);return stats(shape)`);
|
|
const fixture = await call('save',{path:'/tmp/MeshConversionBusiness.FCStd'});
|
|
await call('create',{},'MeshConversionOther');const other=await call('readCleanup',{},'MeshConversionOther');
|
|
const initial=await call('readCleanup');assert.equal(item(initial,'BoxMesh').meshSource.facets,12);assert.equal(item(initial,'BoxMesh').meshSource.points,8);
|
|
assert.deepEqual(item(initial,'BoxMesh').mesh.bounds,[5,6,7,7,9,11]);
|
|
const minimum=initial.meshConversionLimits.minimum;assert.ok(minimum>=1e-6 && minimum<=0.1);
|
|
const names=[];
|
|
stage('native conversion, sewing and history');
|
|
for(const [source,sew,tolerance,area] of [['BoxMesh',false,0.1,52],['BoxMesh',true,0.1,52],['PlaneMesh',false,minimum,4],['PlaneMesh',true,0.01,4]]){
|
|
const before=await call('readCleanup');const state=await accepted({selection:[ref(source),ref(source)],sew,tolerance});
|
|
assert.equal(state.meshConversionResults.length,1);const converted=item(state,state.createdObject);names.push(converted.name);
|
|
const expected=python(`print(json.dumps(desktop(d.${source},${tolerance},${sew?'True':'False'})))`);
|
|
assert.equal(converted.type,'Part::Feature');assert.deepEqual(converted.dependencies,[]);assert.equal(converted.topology.valid,true);
|
|
assert.equal(converted.topology.Face,expected.faces);assert.equal(converted.topology.Shell,expected.shells);assert.equal(converted.topology.Solid,expected.solids);
|
|
near(converted.topology.area,area);near(converted.volume,expected.volume);converted.mesh.bounds.forEach((n,i)=>near(n,expected.bounds[i]));
|
|
assert.equal(converted.topology.Face,source==='BoxMesh'?12:2);
|
|
if(sew)assert.equal(converted.topology.Shell,1);
|
|
assert.deepEqual(converted.meshConversion,{source,sew,tolerance});assert.equal(state.undoCount,before.undoCount+1);
|
|
for(const sourceObject of before.objects)assert.deepEqual(item(state,sourceObject.name),sourceObject);
|
|
assert.deepEqual((await call('undo')).objects,before.objects);assert.deepEqual((await call('redo')).objects,state.objects);
|
|
assert.deepEqual(await call('readCleanup',{},'MeshConversionOther'),other);
|
|
record({source,sew,tolerance},{object:converted.name,topology:converted.topology,bounds:converted.mesh.bounds,desktop:expected},
|
|
'Document conversion matches desktop makeShapeFromMesh with analytic area, native placement, sewing topology and one reversible transaction');
|
|
}
|
|
const beforeMulti=await call('readCleanup');const multi=await accepted({selection:[ref('BoxMesh'),ref('PlaneMesh')],sew:true,tolerance:0.05});
|
|
assert.deepEqual(multi.meshConversionResults.map(r=>r.source),['BoxMesh','PlaneMesh']);assert.equal(multi.undoCount,beforeMulti.undoCount+1);
|
|
assert.equal(new Set(multi.meshConversionResults.map(r=>r.object)).size,2);
|
|
record({sources:['BoxMesh','PlaneMesh'],sew:true},multi.meshConversionResults,'Multiple selected meshes create distinct persistent shapes in a single transaction');
|
|
const stable=signature(await call('readCleanup'));
|
|
for(const args of [{selection:[]},{selection:[ref('Missing')]},{selection:[ref('EmptyMesh')]},{selection:[ref('Solid')]},{selection:[ref('NoShape')]},
|
|
{selection:[ref('BoxMesh'),ref('EmptyMesh')]},{selection:[ref('DegenerateMesh')]},
|
|
{selection:[{documentId:'MeshConversionOther',object:'BoxMesh'}]},{selection:[{...ref('BoxMesh'),subelements:['Facet1']}]},
|
|
{tolerance:0},{tolerance:minimum/2},{tolerance:10.01},{tolerance:'0.1'},{tolerance:Infinity},{sew:'yes'},
|
|
{document:'MeshConversionOther'},{shapes:['BREP']},{object:'Replacement'},{activeTask:true}]){
|
|
const reply=await execute({selection:[ref('BoxMesh')],...args});assert.equal(reply.kind,'error',JSON.stringify(reply));
|
|
assert.deepEqual(signature(await call('readCleanup')),stable);assert.deepEqual(await call('readCleanup',{},'MeshConversionOther'),other);
|
|
record(args,reply.error,'Invalid sources, degenerate geometry and conversion options fail without partial shapes or history changes');
|
|
}
|
|
const native=await request('createShapeFromMesh',{selection:[ref('BoxMesh'),ref('EmptyMesh')],tolerance:0.1,sew:true});
|
|
assert.equal(native.kind,'error');assert.deepEqual(signature(await call('readCleanup')),stable);
|
|
python('d.Solid.Height=2');
|
|
const dirty=python('print(json.dumps(dict(state=d.Solid.State,volume=d.Solid.Shape.Volume,undo=d.UndoCount,redo=d.RedoCount)))');
|
|
const read=await accepted({action:'read'}),cancel=await accepted({action:'cancel'});assert.equal(read.documentChanged,false);assert.equal(cancel.cancelled,true);
|
|
assert.deepEqual(python('print(json.dumps(dict(state=d.Solid.State,volume=d.Solid.Shape.Volume,undo=d.UndoCount,redo=d.RedoCount)))'),dirty);
|
|
python('d.recompute()');
|
|
record({operations:['direct-invalid','read-dirty','cancel-dirty']},{preserved:true},'Native validation cannot be bypassed; task read and cancel preserve unrelated pending edits');
|
|
const copies=(await call('readCleanup')).objects.filter(o=>o.meshConversion);
|
|
python('d.BoxMesh.Placement.Base=App.Vector(30,0,0); d.BoxMesh.Mesh=Mesh.Mesh(Part.makeBox(6,6,6).tessellate(0.1)); d.recompute()');
|
|
const edited=await call('readCleanup');for(const copy of copies)assert.deepEqual(item(edited,copy.name),copy);
|
|
const saved=await call('save',{path:'/tmp/MeshConversionBusiness.FCStd'});await call('close');
|
|
const restored=await call('open',{path:'/tmp/MeshConversionBusiness.FCStd',bytes:saved.bytes});
|
|
assert.equal(item(restored,'BoxMesh').type,'Mesh::Feature');assert.equal(item(restored,'BoxMesh').meshSource.facets,12);
|
|
for(const copy of copies){const restoredCopy=item(restored,copy.name);assert.deepEqual(restoredCopy.meshConversion,copy.meshConversion);near(restoredCopy.topology.area,copy.topology.area);assert.deepEqual(restoredCopy.dependencies,[]);}
|
|
const erased=await call('remove',{object:'BoxMesh'});for(const copy of copies)assert.ok(item(erased,copy.name));
|
|
record({operations:['edit-source-mesh','FCStd-save','close','open','remove-source']},{bytes:saved.bytes.byteLength,copies:copies.length},
|
|
'FCStd restores both mesh and converted shapes; static conversion results survive mesh edits and source deletion');
|
|
kernel.PThread?.terminateAllThreads();kernel=null;
|
|
stage('mounted browser conversion');
|
|
browser=await playwright().chromium.launch({executablePath:process.env.FREECAD_CHROME??'/usr/bin/google-chrome',headless:true,args:['--no-sandbox','--disable-dev-shm-usage']});
|
|
const page=await browser.newPage({viewport:{width:1440,height:900},deviceScaleFactor:1});const errors=[];page.on('pageerror',e=>errors.push(String(e)));
|
|
await page.goto(`${process.env.FREECAD_BROWSER_ORIGIN??'http://127.0.0.1:5180'}/workbench.html?module=Part&command=Part_ShapeFromMesh&document=${document}`);
|
|
await page.getByLabel('Mesh conversion document',{exact:true}).fill(document);
|
|
await page.getByLabel('Mesh conversion FCStd file',{exact:true}).setInputFiles({name:'MeshConversionBusiness.FCStd',mimeType:'application/octet-stream',buffer:Buffer.from(fixture.bytes)});
|
|
await page.getByRole('checkbox',{name:'Convert mesh BoxMesh',exact:true}).check();
|
|
assert.equal(await page.getByRole('checkbox',{name:'Sew converted shape',exact:true}).isChecked(),false);
|
|
assert.equal(await page.getByRole('checkbox',{name:'Convert mesh EmptyMesh',exact:true}).isDisabled(),true);
|
|
await page.getByRole('button',{name:'Create shape',exact:true}).click();await page.locator('[data-converted-mesh="BoxMesh001"]').filter({hasText:'No'}).waitFor();
|
|
await page.getByRole('checkbox',{name:'Sew converted shape',exact:true}).check();await page.getByLabel('Mesh conversion tolerance',{exact:true}).fill('0.05');
|
|
await page.getByRole('checkbox',{name:'Convert mesh PlaneMesh',exact:true}).check();
|
|
await page.getByRole('button',{name:'Create shape',exact:true}).click();
|
|
for(const name of ['BoxMesh002','PlaneMesh001'])await page.locator(`[data-converted-mesh="${name}"]`).filter({hasText:'Yes'}).waitFor();
|
|
await page.getByRole('button',{name:'Mesh conversion undo',exact:true}).click();await page.locator('[data-converted-mesh="BoxMesh002"]').waitFor({state:'detached'});
|
|
await page.getByRole('button',{name:'Mesh conversion redo',exact:true}).click();await page.locator('[data-converted-mesh="BoxMesh002"]').waitFor();
|
|
await page.getByLabel('Mesh conversion tolerance',{exact:true}).fill('0');assert.equal(await page.getByRole('button',{name:'Create shape',exact:true}).isDisabled(),true);
|
|
await page.getByLabel('Mesh conversion tolerance',{exact:true}).fill('0.1');
|
|
const capture=async name=>{
|
|
const canvas=page.locator('[aria-label="Mesh conversion geometry"] canvas');const png=await canvas.screenshot({path:path.join(directory,name)});
|
|
const colors=await page.evaluate(async encoded=>{const img=new Image();img.src=`data:image/png;base64,${encoded}`;await img.decode();const c=document.createElement('canvas');
|
|
c.width=img.width;c.height=img.height;const ctx=c.getContext('2d');ctx.drawImage(img,0,0);const data=ctx.getImageData(0,0,c.width,c.height).data;const colors=new Set();
|
|
for(let i=0;i<data.length;i+=4)colors.add(`${data[i]},${data[i+1]},${data[i+2]}`);return colors.size;},png.toString('base64'));
|
|
assert.ok(colors>8);const hash=createHash('sha256').update(png).digest('hex');result.evidenceFiles[path.relative('.',path.join(directory,name))]=hash;return hash;
|
|
};
|
|
const before=await capture('desktop-geometry.png');const rect=await page.locator('[aria-label="Mesh conversion geometry"] canvas').boundingBox();
|
|
await page.mouse.move(rect.x+rect.width/2,rect.y+rect.height/2);await page.mouse.down();await page.mouse.move(rect.x+rect.width/2+60,rect.y+rect.height/2+30,{steps:8});await page.mouse.up();
|
|
assert.notEqual(await capture('rotated-geometry.png'),before);
|
|
await page.getByRole('button',{name:'Save mesh conversion document',exact:true}).click();await page.getByRole('link',{name:'Open mesh conversion in project',exact:true}).waitFor();
|
|
const bytes=await page.evaluate(async()=>{const {readDocumentFile}=await import('/src/storage.ts');return [...new Uint8Array(await readDocumentFile('MeshConversion-MeshConversionBusiness.FCStd'))]});
|
|
await page.getByRole('button',{name:'Close mesh conversion task',exact:true}).click();await page.getByRole('button',{name:'Create shape',exact:true}).waitFor({state:'detached'});
|
|
await page.reload();await page.getByLabel('Mesh conversion document',{exact:true}).fill(document);
|
|
await page.getByLabel('Mesh conversion FCStd file',{exact:true}).setInputFiles({name:'MeshConversionBusiness.FCStd',mimeType:'application/octet-stream',buffer:Buffer.from(bytes)});
|
|
await page.locator('[data-converted-mesh="BoxMesh002"]').waitFor();
|
|
await page.getByRole('checkbox',{name:'Convert mesh PlaneMesh',exact:true}).check();await page.getByRole('checkbox',{name:'Sew converted shape',exact:true}).check();
|
|
await page.getByRole('button',{name:'Create shape',exact:true}).click();await page.locator('[data-converted-mesh="PlaneMesh002"]').waitFor();
|
|
await page.setViewportSize({width:390,height:844});await capture('mobile-geometry.png');
|
|
const task=page.getByRole('region',{name:'Mesh conversion task',exact:true});assert.ok(await task.evaluate(e=>e.scrollWidth<=e.clientWidth+1));
|
|
const screenshot=await task.screenshot({path:path.join(directory,'mobile-task.png')});result.evidenceFiles[path.relative('.',path.join(directory,'mobile-task.png'))]=createHash('sha256').update(screenshot).digest('hex');
|
|
assert.deepEqual(errors,[]);
|
|
record({workflow:['open-FCStd','select-meshes','sew','tolerance','create','undo','redo','rotate','OPFS-save','close','reload','open','convert-again'],viewports:[[1440,900],[390,844]]},
|
|
{bytes:bytes.length,screenshots:Object.keys(result.evidenceFiles)},'Mounted mesh conversion controls, visible geometry, history and persistent reopening work on desktop and mobile');
|
|
result.status='passed';
|
|
}catch(error){result.error=[String(error.stack??error),error.stdout?.toString(),error.stderr?.toString()].filter(Boolean).join('\n');}
|
|
finally{await browser?.close();kernel?.PThread?.terminateAllThreads();fs.rmSync(temporary,{recursive:true,force:true});fs.writeFileSync(path.join(directory,'result.json'),JSON.stringify(result,null,2));}
|
|
return [result];
|
|
}
|