738 lines
70 KiB
TypeScript
738 lines
70 KiB
TypeScript
import { spawnSync } from 'node:child_process'
|
|
import { createHash } from 'node:crypto'
|
|
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { join, resolve } from 'node:path'
|
|
import { unzipSync } from 'fflate'
|
|
import { decodeFcstdPropertyValue, inspectFcstdArchive, rewriteFcstdMetadataArchive, serializeFcstdMetadataArchive } from '../src/facade/fcstd'
|
|
import { createSketch } from '../src/facade/sketcher'
|
|
import type { DocumentSnapshot, ShapeResourceValue } from '../src/facade/types'
|
|
|
|
const root = fileURLToPath(new URL('..', import.meta.url))
|
|
const executable = resolve(root, '.cache/freecad/install-desktop/bin/FreeCAD')
|
|
const examplePath = resolve(root, '.cache/freecad/install-desktop/share/examples/PartDesignExample.FCStd')
|
|
const probePath = resolve(root, 'scripts/freecad-fcstd-gui-probe.py')
|
|
const pythonPath = resolve(root, '.cache/freecad/sysroot/usr/lib/python3/dist-packages')
|
|
const libraryPath = resolve(root, '.cache/freecad/sysroot/usr/lib/x86_64-linux-gnu')
|
|
const matplotlibConfig = resolve(root, '.cache/freecad/sysroot/etc/matplotlibrc')
|
|
const fail = (message: string): never => { throw new Error(`FreeCAD FCStd native oracle: ${message}`) }
|
|
const near = (actual: number | null, expected: number, tolerance = 1e-8) => typeof actual === 'number' && Number.isFinite(actual) && Math.abs(actual - expected) <= tolerance
|
|
type ProbeResult = {
|
|
freecadVersion: string
|
|
shapeNull: boolean | null
|
|
shapeValid: boolean | null
|
|
solidCount: number | null
|
|
faceCount: number | null
|
|
volume: number | null
|
|
boundingBox: { min: number[]; max: number[] } | null
|
|
shapeError: string | null
|
|
properties: Record<string, unknown>
|
|
geometryCount: number | null
|
|
constraintCount: number | null
|
|
constructionGeometry: number[] | null
|
|
constraintNames: string[] | null
|
|
constraintTypes: string[] | null
|
|
externalGeometryCount: number | null
|
|
externalGeoCount: number | null
|
|
externalTypes: number[] | null
|
|
externalLinks: Array<{ objectName: string; subElements: string[] }> | null
|
|
externalStates: Array<{ ref: string; id: number; geometryType: string; flags: number }> | null
|
|
mapMode: string | null
|
|
attachmentSupport: Array<{ objectName: string; subElements: string[] }> | null
|
|
attachmentOffset: { position: number[]; axis: number[]; angle: number } | null
|
|
}
|
|
|
|
const runFreecadProbe = (archivePath: string, objectName: string, extraEnv: NodeJS.ProcessEnv = {}): ProbeResult => {
|
|
const child = spawnSync('xvfb-run', ['-a', executable, '--python-path', pythonPath, probePath], {
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
maxBuffer: 16 * 1024 * 1024,
|
|
timeout: 30_000,
|
|
env: {
|
|
...process.env,
|
|
PYTHONPATH: pythonPath,
|
|
LD_LIBRARY_PATH: libraryPath,
|
|
MATPLOTLIBRC: matplotlibConfig,
|
|
MPLBACKEND: 'Agg',
|
|
FREECAD_FCSTD_PATH: archivePath,
|
|
FREECAD_FCSTD_OBJECT: objectName,
|
|
...extraEnv,
|
|
},
|
|
})
|
|
const output = `${child.stdout ?? ''}\n${child.stderr ?? ''}`
|
|
if (child.status !== 0) fail(`FreeCAD exited with ${child.status}: ${output.trim()}`)
|
|
if (/Invalid Document\.xml|Reading failed from embedded file|Property .* already exists/i.test(output)) fail(`FreeCAD reported an archive read failure: ${output.trim()}`)
|
|
const marker = output.match(/FREECAD_FCSTD_GUI_RESULT=(\{[^\r\n]+\})/)
|
|
if (!marker) fail(`probe result marker is missing: ${output.trim()}`)
|
|
return JSON.parse(marker[1]) as ProbeResult
|
|
}
|
|
|
|
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'bitbybit-fcstd-oracle-'))
|
|
try {
|
|
const sourceBytes = new Uint8Array(await readFile(examplePath))
|
|
const sourceInspection = inspectFcstdArchive(sourceBytes)
|
|
const proxyPreservedBytes = rewriteFcstdMetadataArchive(sourceBytes, sourceInspection.proxyDocument)
|
|
const sha256 = (bytes: Uint8Array) => createHash('sha256').update(bytes).digest('hex')
|
|
const sourceSha256 = sha256(sourceBytes)
|
|
const proxyPreservedSha256 = sha256(proxyPreservedBytes)
|
|
if (sourceSha256 !== proxyPreservedSha256 || !Buffer.from(sourceBytes).equals(Buffer.from(proxyPreservedBytes))) fail('read-only proxy archive was not preserved byte-for-byte.')
|
|
const proxyPreservedPath = join(temporaryDirectory, 'proxy-preserved.fcstd')
|
|
const proxyResavedPath = join(temporaryDirectory, 'proxy-freecad-resaved.fcstd')
|
|
await writeFile(proxyPreservedPath, proxyPreservedBytes)
|
|
const proxyResult = runFreecadProbe(proxyPreservedPath, 'Body', { FREECAD_FCSTD_RESAVE_PATH: proxyResavedPath })
|
|
if (proxyResult.freecadVersion !== '1.1.1' || proxyResult.shapeNull !== false || proxyResult.shapeValid !== true || proxyResult.faceCount !== 23) fail(`unexpected byte-preserved proxy archive result: ${JSON.stringify(proxyResult)}`)
|
|
const proxyResavedInspection = inspectFcstdArchive(new Uint8Array(await readFile(proxyResavedPath)))
|
|
if (sourceInspection.compatibility.proxyObjects !== 8 || sourceInspection.compatibility.blockedObjects !== 0 || proxyResavedInspection.objects.length !== sourceInspection.objects.length || proxyResavedInspection.compatibility.proxyObjects !== sourceInspection.compatibility.proxyObjects) fail('FreeCAD resave did not preserve the native proxy object inventory.')
|
|
const sourceObject = sourceInspection.objects.find((object) => object.name === 'Body') ?? fail('PartDesignExample Body is missing.')
|
|
const sourceShapeProperty = sourceObject.properties.find((property) => property.name === 'Shape') ?? fail('PartDesignExample Body.Shape is missing.')
|
|
const decoded = decodeFcstdPropertyValue(sourceShapeProperty)
|
|
if (!decoded.decoded || !decoded.value || typeof decoded.value !== 'object') fail('Body.Shape could not be decoded.')
|
|
const shapeValue = decoded.value as ShapeResourceValue
|
|
if (shapeValue.hasherIndex !== 0 || shapeValue.elementMapResource !== 'Body.Shape.Map.txt') fail('Body.Shape native history pointers do not match the locked oracle.')
|
|
const sourceFiles = unzipSync(sourceBytes)
|
|
const document: DocumentSnapshot = {
|
|
id: 'fcstd-native-oracle',
|
|
label: 'FCStd native oracle',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'Body', label: 'Body', type: 'feature', state: 'up-to-date' }],
|
|
objects: [{
|
|
id: 'Body',
|
|
typeId: 'Part::Feature',
|
|
properties: [{ name: 'Shape', label: 'Shape', group: 'Base', scope: 'data', type: 'Part::PropertyPartShape', value: shapeValue }],
|
|
}],
|
|
dependencies: [],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { Body: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const requiredResources = ['StringHasher.Table.txt', 'Body.Shape.brp', 'Body.Shape.Map.txt'] as const
|
|
for (const path of requiredResources) if (!sourceFiles[path]) fail(`locked oracle resource is missing: ${path}`)
|
|
const opaqueEntries: Record<string, Uint8Array> = Object.fromEntries(requiredResources.map((path) => [path, new Uint8Array(sourceFiles[path])]))
|
|
const webArchive = serializeFcstdMetadataArchive(document, { opaqueEntries })
|
|
const webArchivePath = join(temporaryDirectory, 'web.fcstd')
|
|
const resavedPath = join(temporaryDirectory, 'freecad-resaved.fcstd')
|
|
await writeFile(webArchivePath, webArchive)
|
|
|
|
const result = runFreecadProbe(webArchivePath, 'Body', { FREECAD_FCSTD_RESAVE_PATH: resavedPath })
|
|
if (result.freecadVersion !== '1.1.1' || result.shapeNull !== false || result.shapeValid !== true || result.shapeError !== null || result.solidCount !== 1 || result.faceCount !== 23 || !near(result.volume, 583719.3669394433, 1e-6)) fail(`unexpected desktop Shape result: ${JSON.stringify(result)}`)
|
|
|
|
const resavedInspection = inspectFcstdArchive(new Uint8Array(await readFile(resavedPath)))
|
|
const resavedShape = resavedInspection.objects.find((object) => object.name === 'Body')?.properties.find((property) => property.name === 'Shape')?.shapeResource
|
|
const resavedMap = resavedInspection.elementMapResources.find((resource) => resource.path === 'Body.Shape.Map.txt')
|
|
const resavedHasher = resavedInspection.stringHasherResource
|
|
if (resavedShape?.hasherIndex !== 0 || resavedShape.elementMap !== '1.15.70200.5' || resavedShape.elementMapResource !== 'Body.Shape.Map.txt' || resavedShape.elementMapEntries?.[0]?.key !== 'Dummy' || resavedShape.elementMapEntries[0].value !== 'Dummy') fail('FreeCAD resave did not preserve Shape history pointers.')
|
|
if (resavedMap?.postfixCount !== 63 || resavedMap.mapCount !== 2 || resavedHasher?.status !== 'available') fail('FreeCAD resave did not preserve ElementMap2/StringHasher resources.')
|
|
|
|
const primitiveDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-part-box',
|
|
label: 'FCStd native Part Box',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: ['Box', 'Cylinder', 'Sphere', 'Ellipsoid', 'Cone', 'Torus', 'Prism', 'Wedge', 'Tool', 'Fuse', 'Cut', 'Common'].map((id) => ({ id, label: id, type: 'feature', state: 'up-to-date' })),
|
|
objects: [
|
|
{ id: 'Box', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 3 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 4 },
|
|
{ name: 'WebMode', label: 'Web mode', group: 'Web', scope: 'data', type: 'App::PropertyEnumeration', value: 'Manufacturing', options: ['Design', 'Manufacturing', 'Inspection'] },
|
|
{ name: 'WebProgress', label: 'Web progress', group: 'Web', scope: 'data', type: 'App::PropertyPercent', value: 35 },
|
|
] },
|
|
{ id: 'Cylinder', typeId: 'Part::Cylinder', properties: [
|
|
{ name: 'Radius', label: 'Radius', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Height', label: 'Height', group: 'Cylinder', scope: 'data', type: 'App::PropertyLength', value: 5 },
|
|
{ name: 'Angle', label: 'Angle', group: 'Cylinder', scope: 'data', type: 'App::PropertyAngle', value: 180 },
|
|
] },
|
|
{ id: 'Sphere', typeId: 'Part::Sphere', properties: [{ name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data', type: 'App::PropertyLength', value: 3 }] },
|
|
{ id: 'Ellipsoid', typeId: 'Part::Ellipsoid', properties: [
|
|
{ name: 'Radius1', label: 'Z radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Radius2', label: 'X radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 4 },
|
|
{ name: 'Radius3', label: 'Y radius', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: -90 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: 90 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Ellipsoid', scope: 'data', type: 'App::PropertyAngle', value: 360 },
|
|
] },
|
|
{ id: 'Cone', typeId: 'Part::Cone', properties: [
|
|
{ name: 'Radius1', label: 'Radius 1', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 4 },
|
|
{ name: 'Radius2', label: 'Radius 2', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Height', label: 'Height', group: 'Cone', scope: 'data', type: 'App::PropertyLength', value: 6 },
|
|
{ name: 'Angle', label: 'Angle', group: 'Cone', scope: 'data', type: 'App::PropertyAngle', value: 360 },
|
|
] },
|
|
{ id: 'Torus', typeId: 'Part::Torus', properties: [
|
|
{ name: 'Radius1', label: 'Major radius', group: 'Torus', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Radius2', label: 'Minor radius', group: 'Torus', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: -180 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: 180 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Torus', scope: 'data', type: 'App::PropertyAngle', value: 270 },
|
|
] },
|
|
{ id: 'Prism', typeId: 'Part::Prism', properties: [
|
|
{ name: 'Polygon', label: 'Polygon sides', group: 'Prism', scope: 'data', type: 'App::PropertyInteger', value: 6 },
|
|
{ name: 'Circumradius', label: 'Circumradius', group: 'Prism', scope: 'data', type: 'App::PropertyLength', value: 2 },
|
|
{ name: 'Height', label: 'Height', group: 'Prism', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'FirstAngle', label: 'First angle', group: 'Prism', scope: 'data', type: 'App::PropertyAngle', value: 10 },
|
|
{ name: 'SecondAngle', label: 'Second angle', group: 'Prism', scope: 'data', type: 'App::PropertyAngle', value: -5 },
|
|
] },
|
|
{ id: 'Wedge', typeId: 'Part::Wedge', properties: [
|
|
{ name: 'Xmin', label: 'X minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Ymin', label: 'Y minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Zmin', label: 'Z minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'X2min', label: 'X2 minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Z2min', label: 'Z2 minimum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 0 },
|
|
{ name: 'Xmax', label: 'X maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Ymax', label: 'Y maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Zmax', label: 'Z maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'X2max', label: 'X2 maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 8 },
|
|
{ name: 'Z2max', label: 'Z2 maximum', group: 'Wedge', scope: 'data', type: 'App::PropertyLength', value: 8 },
|
|
] },
|
|
{ id: 'Tool', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
] },
|
|
...['Fuse', 'Cut', 'Common'].map((id) => ({ id, typeId: `Part::${id}`, properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Boolean', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'Box' },
|
|
{ name: 'Tool', label: 'Tool', group: 'Boolean', scope: 'data' as const, type: 'App::PropertyLink' as const, value: 'Tool' },
|
|
{ name: 'Refine', label: 'Refine shape', group: 'Boolean', scope: 'data' as const, type: 'App::PropertyBool' as const, value: id === 'Common' },
|
|
] })),
|
|
],
|
|
dependencies: ['Fuse', 'Cut', 'Common'].flatMap((sourceId) => [
|
|
{ sourceId, targetId: 'Box', relation: 'link' as const, propertyName: 'Base' },
|
|
{ sourceId, targetId: 'Tool', relation: 'link' as const, propertyName: 'Tool' },
|
|
]),
|
|
recompute: { generation: 0, status: 'idle', objectStates: Object.fromEntries(['Box', 'Cylinder', 'Sphere', 'Ellipsoid', 'Cone', 'Torus', 'Prism', 'Wedge', 'Tool', 'Fuse', 'Cut', 'Common'].map((id) => [id, 'up-to-date' as const])), dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const primitiveArchivePath = join(temporaryDirectory, 'native-part-box.fcstd')
|
|
const primitiveResavedPath = join(temporaryDirectory, 'native-part-resaved.fcstd')
|
|
await writeFile(primitiveArchivePath, serializeFcstdMetadataArchive(primitiveDocument))
|
|
const primitiveCases = [
|
|
{ objectName: 'Box', expectedVolume: 24, expectedFaces: 6, propertyNames: 'Length,Width,Height,WebMode,WebProgress', properties: { Length: 2, Width: 3, Height: 4, WebMode: 'Manufacturing', WebProgress: 35 } },
|
|
{ objectName: 'Cylinder', expectedVolume: 10 * Math.PI, propertyNames: 'Radius,Height,Angle', properties: { Radius: 2, Height: 5, Angle: 180 } },
|
|
{ objectName: 'Sphere', expectedVolume: 36 * Math.PI, propertyNames: 'Radius', properties: { Radius: 3 } },
|
|
{ objectName: 'Ellipsoid', expectedVolume: 133.9826640573845, expectedFaces: 1, propertyNames: 'Radius1,Radius2,Radius3,Angle1,Angle2,Angle3', properties: { Radius1: 2, Radius2: 4, Radius3: 0, Angle1: -90, Angle2: 90, Angle3: 360 } },
|
|
{ objectName: 'Cone', expectedVolume: 56 * Math.PI, propertyNames: 'Radius1,Radius2,Height,Angle', properties: { Radius1: 4, Radius2: 2, Height: 6, Angle: 360 } },
|
|
{ objectName: 'Torus', expectedVolume: 60 * Math.PI ** 2, propertyNames: 'Radius1,Radius2,Angle1,Angle2,Angle3', properties: { Radius1: 10, Radius2: 2, Angle1: -180, Angle2: 180, Angle3: 270 } },
|
|
{ objectName: 'Prism', expectedVolume: 60 * Math.sqrt(3), expectedFaces: 8, propertyNames: 'Polygon,Circumradius,Height,FirstAngle,SecondAngle', properties: { Polygon: 6, Circumradius: 2, Height: 10, FirstAngle: 10, SecondAngle: -5 } },
|
|
{ objectName: 'Wedge', expectedVolume: 2440 / 3, expectedFaces: 6, propertyNames: 'Xmin,Ymin,Zmin,Z2min,X2min,Xmax,Ymax,Zmax,Z2max,X2max', properties: { Xmin: 0, Ymin: 0, Zmin: 0, Z2min: 0, X2min: 0, Xmax: 10, Ymax: 10, Zmax: 10, Z2max: 8, X2max: 8 } },
|
|
{ objectName: 'Fuse', expectedVolume: 24, propertyNames: 'Base,Tool,Refine', properties: { Base: 'Box', Tool: 'Tool', Refine: false } },
|
|
{ objectName: 'Cut', expectedVolume: 23, propertyNames: 'Base,Tool,Refine', properties: { Base: 'Box', Tool: 'Tool', Refine: false } },
|
|
{ objectName: 'Common', expectedVolume: 1, propertyNames: 'Base,Tool,Refine', properties: { Base: 'Box', Tool: 'Tool', Refine: true } },
|
|
]
|
|
const primitiveResults: Record<string, ProbeResult> = {}
|
|
for (const oracleCase of primitiveCases) {
|
|
const primitiveResult = runFreecadProbe(primitiveArchivePath, oracleCase.objectName, { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_PROPERTIES: oracleCase.propertyNames, ...(oracleCase.objectName === 'Common' ? { FREECAD_FCSTD_RESAVE_PATH: primitiveResavedPath } : {}) })
|
|
primitiveResults[oracleCase.objectName] = primitiveResult
|
|
if (primitiveResult.freecadVersion !== '1.1.1' || primitiveResult.shapeNull !== false || primitiveResult.shapeValid !== true || primitiveResult.shapeError !== null || primitiveResult.solidCount !== 1 || (oracleCase.expectedFaces !== undefined && primitiveResult.faceCount !== oracleCase.expectedFaces) || !near(primitiveResult.volume, oracleCase.expectedVolume, 1e-8)) fail(`unexpected native Part::${oracleCase.objectName} result: ${JSON.stringify(primitiveResult)}`)
|
|
if (Object.entries(oracleCase.properties).some(([name, expected]) => primitiveResult.properties[name] !== expected) || Object.keys(primitiveResult.properties).length !== Object.keys(oracleCase.properties).length) fail(`native Part::${oracleCase.objectName} properties did not restore: ${JSON.stringify(primitiveResult.properties)}`)
|
|
}
|
|
const primitiveResaved = inspectFcstdArchive(new Uint8Array(await readFile(primitiveResavedPath)))
|
|
const decodedProperty = (objectName: string, propertyName: string) => {
|
|
const property = primitiveResaved.objects.find((object) => object.name === objectName)?.properties.find((candidate) => candidate.name === propertyName) ?? fail(`FreeCAD resave is missing ${objectName}.${propertyName}.`)
|
|
const decodedPropertyValue = decodeFcstdPropertyValue(property)
|
|
if (!decodedPropertyValue.decoded) fail(`FreeCAD resave could not decode ${objectName}.${propertyName}: ${decodedPropertyValue.error ?? 'unknown error'}`)
|
|
return decodedPropertyValue.value
|
|
}
|
|
if (decodedProperty('Box', 'Length') !== 2 || decodedProperty('Box', 'Width') !== 3 || decodedProperty('Box', 'Height') !== 4 || decodedProperty('Box', 'WebMode') !== 'Manufacturing' || decodedProperty('Box', 'WebProgress') !== 35) fail('Web inspector did not recover the resaved Box parameters.')
|
|
const prismRoundTripProperties = Object.fromEntries(['Polygon', 'Circumradius', 'Height', 'FirstAngle', 'SecondAngle'].map((name) => {
|
|
const native = primitiveResaved.objects.find((object) => object.name === 'Prism')?.properties.find((property) => property.name === name)
|
|
return [name, { typeId: native?.typeId, value: decodedProperty('Prism', name) }]
|
|
}))
|
|
if (prismRoundTripProperties.Polygon.value !== 6 || prismRoundTripProperties.Circumradius.value !== 2 || prismRoundTripProperties.Height.value !== 10 || prismRoundTripProperties.FirstAngle.value !== 10 || prismRoundTripProperties.SecondAngle.value !== -5) fail(`Web inspector did not recover the resaved Prism parameters: ${JSON.stringify(prismRoundTripProperties)}`)
|
|
const wedgeValues = { Xmin: 0, Ymin: 0, Zmin: 0, Z2min: 0, X2min: 0, Xmax: 10, Ymax: 10, Zmax: 10, Z2max: 8, X2max: 8 }
|
|
const wedgeRoundTripProperties = Object.fromEntries(Object.keys(wedgeValues).map((name) => {
|
|
const native = primitiveResaved.objects.find((object) => object.name === 'Wedge')?.properties.find((property) => property.name === name)
|
|
return [name, { typeId: native?.typeId, value: decodedProperty('Wedge', name) }]
|
|
}))
|
|
if (Object.entries(wedgeValues).some(([name, expected]) => wedgeRoundTripProperties[name].value !== expected)) fail(`Web inspector did not recover the resaved Wedge parameters: ${JSON.stringify(wedgeRoundTripProperties)}`)
|
|
const ellipsoidValues = { Radius1: 2, Radius2: 4, Radius3: 0, Angle1: -90, Angle2: 90, Angle3: 360 }
|
|
const ellipsoidRoundTripProperties = Object.fromEntries(Object.keys(ellipsoidValues).map((name) => {
|
|
const native = primitiveResaved.objects.find((object) => object.name === 'Ellipsoid')?.properties.find((property) => property.name === name)
|
|
return [name, { typeId: native?.typeId, value: decodedProperty('Ellipsoid', name) }]
|
|
}))
|
|
if (Object.entries(ellipsoidValues).some(([name, expected]) => ellipsoidRoundTripProperties[name].value !== expected)) fail(`Web inspector did not recover the resaved Ellipsoid parameters: ${JSON.stringify(ellipsoidRoundTripProperties)}`)
|
|
for (const booleanName of ['Fuse', 'Cut', 'Common']) if (decodedProperty(booleanName, 'Base') !== 'Box' || decodedProperty(booleanName, 'Tool') !== 'Tool' || decodedProperty(booleanName, 'Refine') !== (booleanName === 'Common')) fail(`Web inspector did not recover the resaved ${booleanName} links and Refine value.`)
|
|
for (const objectName of ['Box', 'Cylinder', 'Sphere', 'Ellipsoid', 'Cone', 'Torus', 'Prism', 'Wedge', 'Tool', 'Fuse', 'Cut', 'Common']) {
|
|
const shape = primitiveResaved.objects.find((object) => object.name === objectName)?.properties.find((property) => property.name === 'Shape')?.shapeResource
|
|
if (!shape || !primitiveResaved.shapeResources.some((resource) => resource.path === shape.path && resource.status === 'available')) fail(`FreeCAD resave did not produce an available ${objectName}.Shape resource.`)
|
|
}
|
|
|
|
const sphereTrimPath = join(temporaryDirectory, 'native-sphere-trim.fcstd')
|
|
const sphereTrimResavedPath = join(temporaryDirectory, 'native-sphere-trim-resaved.fcstd')
|
|
const sphereTrimProperties = { Radius: 5, Angle1: -45, Angle2: 45, Angle3: 120 }
|
|
const sphereTrimDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-sphere-trim', label: 'FCStd native Sphere trim', version: 1, dirty: false, readOnly: false, units: 'mm',
|
|
tree: [{ id: 'SphereTrim', label: 'SphereTrim', type: 'feature', state: 'up-to-date' }],
|
|
objects: [{ id: 'SphereTrim', typeId: 'Part::Sphere', properties: [
|
|
{ name: 'Radius', label: 'Radius', group: 'Sphere', scope: 'data', type: 'App::PropertyLength', value: sphereTrimProperties.Radius },
|
|
{ name: 'Angle1', label: 'Lower angle', group: 'Sphere', scope: 'data', type: 'App::PropertyAngle', value: sphereTrimProperties.Angle1 },
|
|
{ name: 'Angle2', label: 'Upper angle', group: 'Sphere', scope: 'data', type: 'App::PropertyAngle', value: sphereTrimProperties.Angle2 },
|
|
{ name: 'Angle3', label: 'Azimuth angle', group: 'Sphere', scope: 'data', type: 'App::PropertyAngle', value: sphereTrimProperties.Angle3 },
|
|
] }],
|
|
dependencies: [],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { SphereTrim: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
await writeFile(sphereTrimPath, serializeFcstdMetadataArchive(sphereTrimDocument))
|
|
const sphereTrimResult = runFreecadProbe(sphereTrimPath, 'SphereTrim', {
|
|
FREECAD_FCSTD_RECOMPUTE: '1',
|
|
FREECAD_FCSTD_PROPERTIES: 'Radius,Angle1,Angle2,Angle3',
|
|
FREECAD_FCSTD_RESAVE_PATH: sphereTrimResavedPath,
|
|
})
|
|
const sphereTrimZ = 5 * Math.sin(Math.PI / 4)
|
|
const sphereTrimVolume = (Math.PI * (25 * (sphereTrimZ * 2) - (sphereTrimZ ** 3 - (-sphereTrimZ) ** 3) / 3)) / 3
|
|
const sphereTrimBounds = { min: [-2.5, 0, -sphereTrimZ], max: [5, 5, sphereTrimZ] }
|
|
if (sphereTrimResult.freecadVersion !== '1.1.1' || sphereTrimResult.shapeNull !== false || sphereTrimResult.shapeValid !== true || sphereTrimResult.solidCount !== 1 || !near(sphereTrimResult.volume, sphereTrimVolume, 1e-8) || !sphereTrimResult.boundingBox || sphereTrimResult.boundingBox.min.some((value, index) => !near(value, sphereTrimBounds.min[index], 1e-8)) || sphereTrimResult.boundingBox.max.some((value, index) => !near(value, sphereTrimBounds.max[index], 1e-8))) fail(`unexpected native Part::Sphere trim result: ${JSON.stringify(sphereTrimResult)}`)
|
|
if (Object.entries(sphereTrimProperties).some(([name, expected]) => sphereTrimResult.properties[name] !== expected)) fail(`native Part::Sphere trim properties did not restore: ${JSON.stringify(sphereTrimResult.properties)}`)
|
|
const sphereTrimResaved = inspectFcstdArchive(new Uint8Array(await readFile(sphereTrimResavedPath)))
|
|
const sphereTrimObject = sphereTrimResaved.objects.find((object) => object.name === 'SphereTrim') ?? fail('FreeCAD resave is missing SphereTrim.')
|
|
for (const [name, expected] of Object.entries(sphereTrimProperties)) {
|
|
const property = sphereTrimObject.properties.find((candidate) => candidate.name === name) ?? fail(`FreeCAD resave is missing SphereTrim.${name}.`)
|
|
const decodedValue = decodeFcstdPropertyValue(property)
|
|
if (!decodedValue.decoded || decodedValue.value !== expected) fail(`Web inspector did not recover SphereTrim.${name}.`)
|
|
}
|
|
|
|
const featureProfile = createSketch('FeatureProfile', [
|
|
{ id: 'profile-bottom', type: 'line', start: { x: 2, y: 0 }, end: { x: 4, y: 0 } },
|
|
{ id: 'profile-right', type: 'line', start: { x: 4, y: 0 }, end: { x: 4, y: 3 } },
|
|
{ id: 'profile-top', type: 'line', start: { x: 4, y: 3 }, end: { x: 2, y: 3 } },
|
|
{ id: 'profile-left', type: 'line', start: { x: 2, y: 3 }, end: { x: 2, y: 0 } },
|
|
])
|
|
const partFeatureDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-part-features', label: 'FCStd native Part features', version: 1, dirty: false, readOnly: false, units: 'mm',
|
|
tree: [
|
|
{ id: 'FeatureProfile', label: 'FeatureProfile', type: 'sketch', state: 'up-to-date' },
|
|
{ id: 'Extrusion', label: 'Extrusion', type: 'feature', state: 'up-to-date' },
|
|
{ id: 'Revolution', label: 'Revolution', type: 'feature', state: 'up-to-date' },
|
|
],
|
|
objects: [
|
|
{ id: 'FeatureProfile', typeId: 'Sketcher::SketchObject', properties: [], sketch: featureProfile },
|
|
{ id: 'Extrusion', typeId: 'Part::Extrusion', properties: [
|
|
{ name: 'Base', label: 'Base', group: 'Extrude', scope: 'data', type: 'App::PropertyLink', value: 'FeatureProfile' },
|
|
{ name: 'Dir', label: 'Direction', group: 'Extrude', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 1 } },
|
|
{ name: 'DirMode', label: 'Direction mode', group: 'Extrude', scope: 'data', type: 'App::PropertyEnumeration', value: 'Custom', options: ['Custom', 'Edge', 'Normal'] },
|
|
{ name: 'LengthFwd', label: 'Forward length', group: 'Extrude', scope: 'data', type: 'App::PropertyDistance', value: 5 },
|
|
{ name: 'LengthRev', label: 'Reverse length', group: 'Extrude', scope: 'data', type: 'App::PropertyDistance', value: 0 },
|
|
{ name: 'Solid', label: 'Solid', group: 'Extrude', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
{ name: 'Reversed', label: 'Reversed', group: 'Extrude', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Symmetric', label: 'Symmetric', group: 'Extrude', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'TaperAngle', label: 'Taper angle', group: 'Extrude', scope: 'data', type: 'App::PropertyAngle', value: 0 },
|
|
{ name: 'TaperAngleRev', label: 'Reverse taper angle', group: 'Extrude', scope: 'data', type: 'App::PropertyAngle', value: 0 },
|
|
] },
|
|
{ id: 'Revolution', typeId: 'Part::Revolution', properties: [
|
|
{ name: 'Source', label: 'Source', group: 'Revolve', scope: 'data', type: 'App::PropertyLink', value: 'FeatureProfile' },
|
|
{ name: 'Base', label: 'Axis base', group: 'Revolve', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 0, z: 0 } },
|
|
{ name: 'Axis', label: 'Axis', group: 'Revolve', scope: 'data', type: 'App::PropertyVector', value: { x: 0, y: 1, z: 0 } },
|
|
{ name: 'Angle', label: 'Angle', group: 'Revolve', scope: 'data', type: 'App::PropertyAngle', value: 360 },
|
|
{ name: 'Symmetric', label: 'Symmetric', group: 'Revolve', scope: 'data', type: 'App::PropertyBool', value: false },
|
|
{ name: 'Solid', label: 'Solid', group: 'Revolve', scope: 'data', type: 'App::PropertyBool', value: true },
|
|
] },
|
|
],
|
|
dependencies: [
|
|
{ sourceId: 'Extrusion', targetId: 'FeatureProfile', relation: 'link', propertyName: 'Base' },
|
|
{ sourceId: 'Revolution', targetId: 'FeatureProfile', relation: 'link', propertyName: 'Source' },
|
|
],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { FeatureProfile: 'up-to-date', Extrusion: 'up-to-date', Revolution: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const partFeaturePath = join(temporaryDirectory, 'native-part-features.fcstd')
|
|
const partFeatureResavedPath = join(temporaryDirectory, 'native-part-features-resaved.fcstd')
|
|
await writeFile(partFeaturePath, serializeFcstdMetadataArchive(partFeatureDocument))
|
|
const extrusionResult = runFreecadProbe(partFeaturePath, 'Extrusion', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_PROPERTIES: 'Base,DirMode,LengthFwd,LengthRev,Solid,Reversed,Symmetric,TaperAngle,TaperAngleRev' })
|
|
if (extrusionResult.freecadVersion !== '1.1.1' || extrusionResult.shapeNull !== false || extrusionResult.shapeValid !== true || extrusionResult.shapeError !== null || extrusionResult.solidCount !== 1 || !near(extrusionResult.volume, 30, 1e-8)) fail(`unexpected native Part::Extrusion result: ${JSON.stringify(extrusionResult)}`)
|
|
if (JSON.stringify(extrusionResult.properties) !== '{"Base":"FeatureProfile","DirMode":"Custom","LengthFwd":5,"LengthRev":0,"Reversed":false,"Solid":true,"Symmetric":false,"TaperAngle":0,"TaperAngleRev":0}') fail(`native Part::Extrusion properties did not restore: ${JSON.stringify(extrusionResult.properties)}`)
|
|
const revolutionResult = runFreecadProbe(partFeaturePath, 'Revolution', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_PROPERTIES: 'Source,Angle,Symmetric,Solid' })
|
|
if (revolutionResult.freecadVersion !== '1.1.1' || revolutionResult.shapeNull !== false || revolutionResult.shapeValid !== true || revolutionResult.shapeError !== null || revolutionResult.solidCount !== 1 || !near(revolutionResult.volume, 36 * Math.PI, 1e-8)) fail(`unexpected native Part::Revolution result: ${JSON.stringify(revolutionResult)}`)
|
|
if (JSON.stringify(revolutionResult.properties) !== '{"Angle":360,"Solid":true,"Source":"FeatureProfile","Symmetric":false}') fail(`native Part::Revolution properties did not restore: ${JSON.stringify(revolutionResult.properties)}`)
|
|
const revolutionResavedResult = runFreecadProbe(partFeaturePath, 'Revolution', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_PROPERTIES: 'Source,Angle,Symmetric,Solid', FREECAD_FCSTD_RESAVE_PATH: partFeatureResavedPath })
|
|
if (revolutionResavedResult.shapeValid !== true) fail('FreeCAD did not resave the verified native Part::Revolution document.')
|
|
const partFeatureResaved = inspectFcstdArchive(new Uint8Array(await readFile(partFeatureResavedPath)))
|
|
const resavedFeatureValue = (objectName: string, propertyName: string) => {
|
|
const property = partFeatureResaved.objects.find((object) => object.name === objectName)?.properties.find((candidate) => candidate.name === propertyName) ?? fail(`FreeCAD resave is missing ${objectName}.${propertyName}.`)
|
|
const decodedValue = decodeFcstdPropertyValue(property)
|
|
if (!decodedValue.decoded) fail(`Web inspector could not decode ${objectName}.${propertyName}.`)
|
|
return decodedValue.value
|
|
}
|
|
if (partFeatureResaved.objects.find((object) => object.name === 'Extrusion')?.typeId !== 'Part::Extrusion' || resavedFeatureValue('Extrusion', 'Base') !== 'FeatureProfile' || resavedFeatureValue('Extrusion', 'LengthFwd') !== 5 || resavedFeatureValue('Extrusion', 'Solid') !== true) fail('Web inspector did not recover the resaved native Part::Extrusion schema.')
|
|
if (partFeatureResaved.objects.find((object) => object.name === 'Revolution')?.typeId !== 'Part::Revolution' || resavedFeatureValue('Revolution', 'Source') !== 'FeatureProfile' || resavedFeatureValue('Revolution', 'Angle') !== 360 || resavedFeatureValue('Revolution', 'Solid') !== true) fail('Web inspector did not recover the resaved native Part::Revolution schema.')
|
|
for (const objectName of ['Extrusion', 'Revolution']) {
|
|
const shape = partFeatureResaved.objects.find((object) => object.name === objectName)?.properties.find((property) => property.name === 'Shape')?.shapeResource
|
|
if (!shape || !partFeatureResaved.shapeResources.some((resource) => resource.path === shape.path && resource.status === 'available')) fail(`FreeCAD resave did not produce an available ${objectName}.Shape resource.`)
|
|
}
|
|
|
|
const sketch = createSketch('Sketch', [
|
|
{ id: 'bottom', type: 'line', start: { x: 0, y: 0 }, end: { x: 20, y: 0 } },
|
|
{ id: 'right', type: 'line', start: { x: 20, y: 0 }, end: { x: 20, y: 10 } },
|
|
{ id: 'top', type: 'line', start: { x: 20, y: 10 }, end: { x: 0, y: 10 } },
|
|
{ id: 'left', type: 'line', start: { x: 0, y: 10 }, end: { x: 0, y: 0 } },
|
|
{ id: 'construction-axis', type: 'line', start: { x: 10, y: 0 }, end: { x: 10, y: 10 }, construction: true },
|
|
{ id: 'reference-circle', type: 'circle', center: { x: 30, y: 5 }, radius: 2 },
|
|
{ id: 'datum-point', type: 'point', position: { x: 35, y: 5 } },
|
|
{ id: 'profile-arc', type: 'arc', center: { x: 40, y: 5 }, radius: 2, startAngle: 0, endAngle: Math.PI },
|
|
{ id: 'profile-ellipse', type: 'ellipse', center: { x: 47, y: 5 }, majorRadius: 3, minorRadius: 1.5, rotation: 0.25 },
|
|
{ id: 'profile-spline', type: 'bspline', degree: 2, controlPoints: [{ x: 52, y: 3 }, { x: 54, y: 7 }, { x: 56, y: 3 }], weights: [1, 1, 1], knots: [0, 0, 0, 1, 1, 1], periodic: false },
|
|
], [
|
|
{ id: 'bottom-horizontal', type: 'horizontal', geometryId: 'bottom' },
|
|
{ id: 'right-vertical', type: 'vertical', geometryId: 'right' },
|
|
{ id: 'top-horizontal', type: 'horizontal', geometryId: 'top' },
|
|
{ id: 'left-vertical', type: 'vertical', geometryId: 'left' },
|
|
{ id: 'join-bottom-right', type: 'coincident', first: { geometryId: 'bottom', point: 'end' }, second: { geometryId: 'right', point: 'start' } },
|
|
{ id: 'join-right-top', type: 'coincident', first: { geometryId: 'right', point: 'end' }, second: { geometryId: 'top', point: 'start' } },
|
|
{ id: 'join-top-left', type: 'coincident', first: { geometryId: 'top', point: 'end' }, second: { geometryId: 'left', point: 'start' } },
|
|
{ id: 'join-left-bottom', type: 'coincident', first: { geometryId: 'left', point: 'end' }, second: { geometryId: 'bottom', point: 'start' } },
|
|
{ id: 'axis-vertical', type: 'vertical', geometryId: 'construction-axis' },
|
|
{ id: 'circle-reference-radius', type: 'radius', geometryId: 'reference-circle', value: 2, driving: false },
|
|
{ id: 'external-point-on-edge', type: 'pointOnObject', point: { geometryId: 'datum-point', point: 'position' }, geometryId: 'external-box-edge-projection' },
|
|
])
|
|
const externalSource = { schemaVersion: 1 as const, objectId: 'ExternalBox', kind: 'edge' as const, persistentId: 'Edge1', topologyVersion: 3, generation: 2, status: 'stable' as const, signature: 'native-box-edge-1' }
|
|
const supportSource = { schemaVersion: 1 as const, objectId: 'ExternalBox', kind: 'face' as const, persistentId: 'Face1', topologyVersion: 3, generation: 2, status: 'stable' as const, signature: 'native-box-face-1' }
|
|
sketch.externalGeometry = [{
|
|
id: 'external-box-edge',
|
|
source: externalSource,
|
|
projection: { id: 'external-box-edge-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 20, y: 0 }, construction: true },
|
|
construction: true,
|
|
}]
|
|
const sketchDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-sketch',
|
|
label: 'FCStd native Sketch',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'ExternalBox', label: 'ExternalBox', type: 'feature', state: 'up-to-date' }, { id: 'Sketch', label: 'Sketch', type: 'sketch', state: 'up-to-date' }],
|
|
objects: [
|
|
{ id: 'ExternalBox', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 20 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
] },
|
|
{ id: 'Sketch', typeId: 'Sketcher::SketchObject', properties: [
|
|
{ name: 'Support', label: 'Support', group: 'Attachment', scope: 'data', type: 'App::PropertyLink', value: { objectId: 'ExternalBox', subElement: supportSource } },
|
|
{ name: 'MapMode', label: 'Map mode', group: 'Attachment', scope: 'data', type: 'App::PropertyEnumeration', value: 'FlatFace', options: ['Deactivated', 'ObjectXY', 'ObjectXZ', 'ObjectYZ', 'FlatFace', 'NormalToEdge'] },
|
|
{ name: 'AttachmentOffset', label: 'Attachment offset', group: 'Attachment', scope: 'data', type: 'App::PropertyPlacement', value: { position: { x: 0, y: 0, z: 0.25 }, rotation: { axis: { x: 0, y: 0, z: 1 }, angle: 0 } } },
|
|
], sketch },
|
|
],
|
|
dependencies: [{ sourceId: 'Sketch', targetId: 'ExternalBox', relation: 'topo-ref', propertyName: 'ExternalGeometry:external-box-edge', reference: 'Edge1' }],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { ExternalBox: 'up-to-date', Sketch: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const sketchArchivePath = join(temporaryDirectory, 'native-sketch.fcstd')
|
|
const sketchResavedPath = join(temporaryDirectory, 'native-sketch-resaved.fcstd')
|
|
await writeFile(sketchArchivePath, serializeFcstdMetadataArchive(sketchDocument))
|
|
const sketchResult = runFreecadProbe(sketchArchivePath, 'Sketch', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_PROPERTIES: 'WebGeometryIds,WebExternalIds,WebExternalProjectionIds', FREECAD_FCSTD_RESAVE_PATH: sketchResavedPath })
|
|
const expectedConstraintNames = sketch.constraints.map((constraint) => constraint.id)
|
|
if (sketchResult.freecadVersion !== '1.1.1' || sketchResult.geometryCount !== 10 || sketchResult.constraintCount !== 11 || sketchResult.externalGeometryCount !== 1 || sketchResult.externalGeoCount !== 3 || JSON.stringify(sketchResult.externalTypes) !== '[0]' || JSON.stringify(sketchResult.externalLinks) !== '[{"objectName":"ExternalBox","subElements":["Edge1"]}]' || sketchResult.mapMode !== 'FlatFace' || JSON.stringify(sketchResult.attachmentSupport) !== '[{"objectName":"ExternalBox","subElements":["Face1"]}]' || JSON.stringify(sketchResult.attachmentOffset?.position) !== '[0,0,0.25]' || !near(sketchResult.attachmentOffset?.angle ?? null, 0) || JSON.stringify(sketchResult.constructionGeometry) !== '[4]' || JSON.stringify(sketchResult.constraintNames) !== JSON.stringify(expectedConstraintNames) || JSON.stringify(sketchResult.properties.WebGeometryIds) !== JSON.stringify(sketch.geometry.map((geometry) => geometry.id)) || JSON.stringify(sketchResult.properties.WebExternalIds) !== '["external-box-edge"]' || JSON.stringify(sketchResult.properties.WebExternalProjectionIds) !== '["external-box-edge-projection"]') fail(`unexpected native Sketch result: ${JSON.stringify(sketchResult)}`)
|
|
const resavedSketchInspection = inspectFcstdArchive(new Uint8Array(await readFile(sketchResavedPath)))
|
|
const resavedSketchObject = resavedSketchInspection.objects.find((object) => object.name === 'Sketch') ?? fail('Web inspector did not find the resaved native Sketch object.')
|
|
const resavedSketch = resavedSketchObject.sketch ?? fail('Web inspector did not reconstruct the resaved native Sketch.')
|
|
if (JSON.stringify(resavedSketch.geometry.map((geometry) => geometry.id)) !== JSON.stringify(sketch.geometry.map((geometry) => geometry.id)) || JSON.stringify(resavedSketch.constraints.map((constraint) => constraint.id)) !== JSON.stringify(expectedConstraintNames) || resavedSketch.geometry[4]?.construction !== true || resavedSketch.externalGeometry[0]?.id !== 'external-box-edge' || resavedSketch.externalGeometry[0]?.projection.id !== 'external-box-edge-projection' || JSON.stringify(resavedSketch.externalGeometry[0]?.source) !== JSON.stringify(externalSource)) fail('Web inspector did not preserve native Sketch IDs, external TopoRef, or construction state.')
|
|
const resavedMapMode = resavedSketchObject.properties.find((property) => property.name === 'MapMode') ?? fail('FreeCAD resave did not preserve Sketch.MapMode.')
|
|
const resavedAttachmentSupport = resavedSketchObject.properties.find((property) => property.name === 'AttachmentSupport') ?? fail('FreeCAD resave did not preserve Sketch.AttachmentSupport.')
|
|
if (decodeFcstdPropertyValue(resavedMapMode).value !== 'FlatFace' || JSON.stringify(decodeFcstdPropertyValue(resavedAttachmentSupport).value) !== '{"schemaVersion":1,"entries":[{"objectId":"ExternalBox","subElement":"Face1"}]}') fail('Web inspector did not decode the native Sketch attachment fields.')
|
|
const proxySketch = resavedSketchInspection.proxyDocument.objects.find((object) => object.id === 'Sketch') ?? fail('Web proxy document did not preserve the resaved Sketch.')
|
|
if (JSON.stringify(proxySketch.properties.find((property) => property.name === 'Support')?.value) !== JSON.stringify({ objectId: 'ExternalBox', subElement: supportSource }) || proxySketch.properties.find((property) => property.name === 'MapMode')?.value !== 'FlatFace') fail('Web proxy document did not recover the exact Support TopoRef or MapMode.')
|
|
|
|
const faceSketch = createSketch('FaceSketch', [{ id: 'datum', type: 'point', position: { x: 0, y: 5 } }])
|
|
const faceSource = { schemaVersion: 1 as const, objectId: 'FaceBox', kind: 'face' as const, persistentId: 'Face6', topologyVersion: 5, generation: 4, status: 'stable' as const, signature: 'native-box-face-6' }
|
|
faceSketch.externalGeometry = [
|
|
{ id: 'face-left', source: faceSource, projection: { id: 'face-left-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 0, y: 10 }, construction: true }, construction: true },
|
|
{ id: 'face-top', source: faceSource, projection: { id: 'face-top-projection', type: 'line', start: { x: 0, y: 10 }, end: { x: 20, y: 10 }, construction: true }, construction: true },
|
|
{ id: 'face-right', source: faceSource, projection: { id: 'face-right-projection', type: 'line', start: { x: 20, y: 0 }, end: { x: 20, y: 10 }, construction: true }, construction: true },
|
|
{ id: 'face-bottom', source: faceSource, projection: { id: 'face-bottom-projection', type: 'line', start: { x: 0, y: 0 }, end: { x: 20, y: 0 }, construction: true }, construction: true },
|
|
]
|
|
faceSketch.constraints = [{ id: 'datum-on-face-left', type: 'pointOnObject', point: { geometryId: 'datum', point: 'position' }, geometryId: 'face-left-projection' }]
|
|
const faceDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-face-projection',
|
|
label: 'FCStd native Face projection',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'FaceBox', label: 'FaceBox', type: 'feature', state: 'up-to-date' }, { id: 'FaceSketch', label: 'FaceSketch', type: 'sketch', state: 'up-to-date' }],
|
|
objects: [
|
|
{ id: 'FaceBox', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 20 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
] },
|
|
{ id: 'FaceSketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: faceSketch },
|
|
],
|
|
dependencies: [{ sourceId: 'FaceSketch', targetId: 'FaceBox', relation: 'topo-ref', propertyName: 'ExternalGeometry:face-left', reference: 'Face6' }],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { FaceBox: 'up-to-date', FaceSketch: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const faceArchivePath = join(temporaryDirectory, 'native-face-projection.fcstd')
|
|
const faceResavedPath = join(temporaryDirectory, 'native-face-projection-resaved.fcstd')
|
|
await writeFile(faceArchivePath, serializeFcstdMetadataArchive(faceDocument))
|
|
const faceResult = runFreecadProbe(faceArchivePath, 'FaceSketch', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_RESAVE_PATH: faceResavedPath })
|
|
if (faceResult.freecadVersion !== '1.1.1' || faceResult.geometryCount !== 1 || faceResult.constraintCount !== 1 || faceResult.externalGeometryCount !== 1 || faceResult.externalGeoCount !== 6 || JSON.stringify(faceResult.externalTypes) !== '[0]' || JSON.stringify(faceResult.externalLinks) !== '[{"objectName":"FaceBox","subElements":["Face6"]}]' || JSON.stringify(faceResult.constraintNames) !== '["datum-on-face-left"]' || JSON.stringify(faceResult.constraintTypes) !== '["PointOnObject"]') fail(`unexpected native Face projection result: ${JSON.stringify(faceResult)}`)
|
|
const resavedFace = inspectFcstdArchive(new Uint8Array(await readFile(faceResavedPath))).objects.find((object) => object.name === 'FaceSketch')?.sketch ?? fail('Web inspector did not reconstruct the resaved native Face projection Sketch.')
|
|
if (JSON.stringify(resavedFace.externalGeometry.map((external) => external.id)) !== JSON.stringify(faceSketch.externalGeometry.map((external) => external.id)) || JSON.stringify(resavedFace.externalGeometry.map((external) => external.projection.id)) !== JSON.stringify(faceSketch.externalGeometry.map((external) => external.projection.id)) || resavedFace.externalGeometry.some((external) => JSON.stringify(external.source) !== JSON.stringify(faceSource)) || JSON.stringify(resavedFace.constraints) !== JSON.stringify([{ ...faceSketch.constraints[0], driving: true }])) fail(`Web inspector did not preserve the native Face projection group: ${JSON.stringify(resavedFace)}`)
|
|
|
|
const modeSketch = createSketch('ModeSketch')
|
|
const intersectionSource = { schemaVersion: 1 as const, objectId: 'ModeBox', kind: 'face' as const, persistentId: 'Face1', topologyVersion: 6, generation: 4, status: 'stable' as const, signature: 'native-box-face-1-intersection' }
|
|
const bothSource = { schemaVersion: 1 as const, objectId: 'ModeBox', kind: 'face' as const, persistentId: 'Face6', topologyVersion: 6, generation: 4, status: 'stable' as const, signature: 'native-box-face-6-both' }
|
|
const syncSource = { schemaVersion: 1 as const, objectId: 'ModeBox', kind: 'edge' as const, persistentId: 'Edge1', topologyVersion: 6, generation: 4, status: 'stable' as const, signature: 'native-box-edge-1-sync' }
|
|
modeSketch.externalGeometry = [
|
|
{ id: 'intersection-face', source: intersectionSource, projection: { id: 'intersection-face-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 0, y: 10 }, construction: true }, construction: true, mode: 'intersection', defining: true },
|
|
{ id: 'both-left', source: bothSource, projection: { id: 'both-left-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 0, y: 10 }, construction: true }, construction: true, mode: 'both', defining: true },
|
|
{ id: 'both-top', source: bothSource, projection: { id: 'both-top-line', type: 'line', start: { x: 0, y: 10 }, end: { x: 20, y: 10 }, construction: true }, construction: true, mode: 'both', defining: true },
|
|
{ id: 'both-right', source: bothSource, projection: { id: 'both-right-line', type: 'line', start: { x: 20, y: 0 }, end: { x: 20, y: 10 }, construction: true }, construction: true, mode: 'both', defining: true },
|
|
{ id: 'both-bottom', source: bothSource, projection: { id: 'both-bottom-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 20, y: 0 }, construction: true }, construction: true, mode: 'both', defining: true },
|
|
{ id: 'frozen-edge', source: syncSource, projection: { id: 'frozen-edge-line', type: 'line', start: { x: 0, y: 0 }, end: { x: 20, y: 0 }, construction: true }, construction: true, frozen: true },
|
|
]
|
|
const modeDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-external-modes',
|
|
label: 'FCStd native external modes',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'ModeBox', label: 'ModeBox', type: 'feature', state: 'up-to-date' }, { id: 'ModeSketch', label: 'ModeSketch', type: 'sketch', state: 'up-to-date' }],
|
|
objects: [
|
|
{ id: 'ModeBox', typeId: 'Part::Box', properties: [
|
|
{ name: 'Length', label: 'Length', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 20 },
|
|
{ name: 'Width', label: 'Width', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 10 },
|
|
{ name: 'Height', label: 'Height', group: 'Box', scope: 'data', type: 'App::PropertyLength', value: 1 },
|
|
] },
|
|
{ id: 'ModeSketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: modeSketch },
|
|
],
|
|
dependencies: [
|
|
{ sourceId: 'ModeSketch', targetId: 'ModeBox', relation: 'topo-ref', propertyName: 'ExternalGeometry:intersection-face', reference: 'Face1' },
|
|
{ sourceId: 'ModeSketch', targetId: 'ModeBox', relation: 'topo-ref', propertyName: 'ExternalGeometry:both-left', reference: 'Face6' },
|
|
{ sourceId: 'ModeSketch', targetId: 'ModeBox', relation: 'topo-ref', propertyName: 'ExternalGeometry:frozen-edge', reference: 'Edge1' },
|
|
],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { ModeBox: 'up-to-date', ModeSketch: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const modeArchivePath = join(temporaryDirectory, 'native-external-modes.fcstd')
|
|
const modeResavedPath = join(temporaryDirectory, 'native-external-modes-resaved.fcstd')
|
|
await writeFile(modeArchivePath, serializeFcstdMetadataArchive(modeDocument))
|
|
const modeResult = runFreecadProbe(modeArchivePath, 'ModeSketch', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_RESAVE_PATH: modeResavedPath })
|
|
if (modeResult.freecadVersion !== '1.1.1' || modeResult.geometryCount !== 0 || modeResult.constraintCount !== 0 || modeResult.externalGeometryCount !== 1 || modeResult.externalGeoCount !== 8 || JSON.stringify(modeResult.externalTypes) !== '[1,2,0]' || JSON.stringify(modeResult.externalLinks) !== '[{"objectName":"ModeBox","subElements":["Face1","Face6","Edge1"]}]' || JSON.stringify(modeResult.externalStates?.map((state) => state.flags)) !== '[1,1,1,1,1,2]') fail(`unexpected native external mode result: ${JSON.stringify(modeResult)}`)
|
|
const resavedModes = inspectFcstdArchive(new Uint8Array(await readFile(modeResavedPath))).objects.find((object) => object.name === 'ModeSketch')?.sketch ?? fail('Web inspector did not reconstruct the resaved native external mode Sketch.')
|
|
if (JSON.stringify(resavedModes.externalGeometry.map((external) => external.id)) !== JSON.stringify(modeSketch.externalGeometry.map((external) => external.id)) || JSON.stringify(resavedModes.externalGeometry.map((external) => external.projection.id)) !== JSON.stringify(modeSketch.externalGeometry.map((external) => external.projection.id)) || JSON.stringify(resavedModes.externalGeometry.map((external) => external.mode ?? 'projection')) !== '["intersection","both","both","both","both","projection"]' || resavedModes.externalGeometry.slice(0, 5).some((external) => external.defining !== true) || resavedModes.externalGeometry[5].frozen !== true) fail(`Web inspector did not preserve native external modes and flags: ${JSON.stringify(resavedModes)}`)
|
|
|
|
const snellSketch = createSketch('SnellSketch', [
|
|
{ id: 'incident-ray', type: 'line', start: { x: 0, y: 0 }, end: { x: 5, y: 5 } },
|
|
{ id: 'refracted-ray', type: 'line', start: { x: 5, y: 5 }, end: { x: 10, y: 2 } },
|
|
{ id: 'boundary', type: 'line', start: { x: 0, y: 5 }, end: { x: 10, y: 5 }, construction: true },
|
|
], [{ id: 'refraction', type: 'snellsLaw', first: { geometryId: 'incident-ray', point: 'end' }, second: { geometryId: 'refracted-ray', point: 'start' }, boundaryGeometryId: 'boundary', value: 1.2 }])
|
|
const snellDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-snells-law',
|
|
label: 'FCStd native SnellsLaw',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'SnellSketch', label: 'SnellSketch', type: 'sketch', state: 'up-to-date' }],
|
|
objects: [{ id: 'SnellSketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: snellSketch }],
|
|
dependencies: [],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { SnellSketch: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const snellArchivePath = join(temporaryDirectory, 'native-snells-law.fcstd')
|
|
const snellResavedPath = join(temporaryDirectory, 'native-snells-law-resaved.fcstd')
|
|
await writeFile(snellArchivePath, serializeFcstdMetadataArchive(snellDocument))
|
|
const snellResult = runFreecadProbe(snellArchivePath, 'SnellSketch', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_RESAVE_PATH: snellResavedPath })
|
|
if (snellResult.freecadVersion !== '1.1.1' || snellResult.geometryCount !== 3 || snellResult.constraintCount !== 1 || JSON.stringify(snellResult.constructionGeometry) !== '[2]' || JSON.stringify(snellResult.constraintNames) !== '["refraction"]' || JSON.stringify(snellResult.constraintTypes) !== '["SnellsLaw"]') fail(`unexpected native SnellsLaw result: ${JSON.stringify(snellResult)}`)
|
|
const resavedSnell = inspectFcstdArchive(new Uint8Array(await readFile(snellResavedPath))).objects.find((object) => object.name === 'SnellSketch')?.sketch ?? fail('Web inspector did not reconstruct the resaved native SnellsLaw Sketch.')
|
|
if (JSON.stringify(resavedSnell.geometry.map((geometry) => geometry.id)) !== JSON.stringify(snellSketch.geometry.map((geometry) => geometry.id)) || JSON.stringify(resavedSnell.constraints) !== JSON.stringify([{ ...snellSketch.constraints[0], driving: true }])) fail('Web inspector did not preserve the native SnellsLaw references or value.')
|
|
|
|
const weightSketch = createSketch('WeightSketch', [{
|
|
id: 'weighted-spline',
|
|
type: 'bspline',
|
|
degree: 2,
|
|
controlPoints: [{ x: 0, y: 0 }, { x: 3, y: 4 }, { x: 6, y: 0 }],
|
|
weights: [1, 0.75, 1],
|
|
knots: [0, 0, 0, 1, 1, 1],
|
|
periodic: false,
|
|
}], [{ id: 'middle-weight', type: 'weight', geometryId: 'weighted-spline', controlPointIndex: 1, value: 0.75 }])
|
|
const weightDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-weight',
|
|
label: 'FCStd native Weight',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'WeightSketch', label: 'WeightSketch', type: 'sketch', state: 'up-to-date' }],
|
|
objects: [{ id: 'WeightSketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: weightSketch }],
|
|
dependencies: [],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { WeightSketch: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const weightArchivePath = join(temporaryDirectory, 'native-weight.fcstd')
|
|
const weightResavedPath = join(temporaryDirectory, 'native-weight-resaved.fcstd')
|
|
await writeFile(weightArchivePath, serializeFcstdMetadataArchive(weightDocument))
|
|
const weightResult = runFreecadProbe(weightArchivePath, 'WeightSketch', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_RESAVE_PATH: weightResavedPath })
|
|
if (weightResult.freecadVersion !== '1.1.1' || weightResult.geometryCount !== 2 || weightResult.constraintCount !== 2 || JSON.stringify(weightResult.constructionGeometry) !== '[1]' || JSON.stringify(weightResult.constraintNames) !== '["__WebSyntheticAlignment0","middle-weight"]' || JSON.stringify(weightResult.constraintTypes) !== '["InternalAlignment","Weight"]') fail(`unexpected native B-spline Weight result: ${JSON.stringify(weightResult)}`)
|
|
const resavedWeight = inspectFcstdArchive(new Uint8Array(await readFile(weightResavedPath))).objects.find((object) => object.name === 'WeightSketch')?.sketch ?? fail('Web inspector did not reconstruct the resaved native B-spline Weight Sketch.')
|
|
if (JSON.stringify(resavedWeight.geometry.map((geometry) => geometry.id)) !== '["weighted-spline"]' || JSON.stringify(resavedWeight.constraints) !== JSON.stringify([{ ...weightSketch.constraints[0], driving: true }])) fail(`Web inspector did not preserve the native B-spline Weight constraint: ${JSON.stringify(resavedWeight)}`)
|
|
|
|
const internalSketch = createSketch('InternalSketch', [
|
|
{ id: 'ellipse', type: 'ellipse', center: { x: 5, y: 4 }, majorRadius: 3, minorRadius: 2, rotation: 0.25 },
|
|
{ id: 'spline', type: 'bspline', degree: 2, controlPoints: [{ x: 0, y: 0 }, { x: 2, y: 3 }, { x: 4, y: 2 }, { x: 6, y: 0 }], weights: [1, 0.8, 1.2, 1], knots: [0, 0, 0, 0.5, 1, 1, 1], periodic: false },
|
|
], [
|
|
{ id: 'ellipse-major', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-major' },
|
|
{ id: 'ellipse-minor', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-minor' },
|
|
{ id: 'ellipse-focus-1', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 0, alignmentType: 'ellipse-focus' },
|
|
{ id: 'ellipse-focus-2', type: 'internalAlignment', geometryId: 'ellipse', internalGeometryIndex: 1, alignmentType: 'ellipse-focus' },
|
|
{ id: 'spline-middle-knot', type: 'internalAlignment', geometryId: 'spline', internalGeometryIndex: 1, alignmentType: 'bspline-knot' },
|
|
])
|
|
const internalDocument: DocumentSnapshot = {
|
|
id: 'fcstd-native-internal-alignment',
|
|
label: 'FCStd native InternalAlignment',
|
|
version: 1,
|
|
dirty: false,
|
|
readOnly: false,
|
|
units: 'mm',
|
|
tree: [{ id: 'InternalSketch', label: 'InternalSketch', type: 'sketch', state: 'up-to-date' }],
|
|
objects: [{ id: 'InternalSketch', typeId: 'Sketcher::SketchObject', properties: [], sketch: internalSketch }],
|
|
dependencies: [],
|
|
recompute: { generation: 0, status: 'idle', objectStates: { InternalSketch: 'up-to-date' }, dirtyObjects: [], order: [], errors: [] },
|
|
}
|
|
const internalArchivePath = join(temporaryDirectory, 'native-internal-alignment.fcstd')
|
|
const internalResavedPath = join(temporaryDirectory, 'native-internal-alignment-resaved.fcstd')
|
|
await writeFile(internalArchivePath, serializeFcstdMetadataArchive(internalDocument))
|
|
const internalResult = runFreecadProbe(internalArchivePath, 'InternalSketch', { FREECAD_FCSTD_RECOMPUTE: '1', FREECAD_FCSTD_RESAVE_PATH: internalResavedPath })
|
|
if (internalResult.freecadVersion !== '1.1.1' || internalResult.geometryCount !== 7 || internalResult.constraintCount !== 5 || JSON.stringify(internalResult.constructionGeometry) !== '[2,3,4,5,6]' || JSON.stringify(internalResult.constraintNames) !== JSON.stringify(internalSketch.constraints.map((constraint) => constraint.id)) || JSON.stringify(internalResult.constraintTypes) !== '["InternalAlignment","InternalAlignment","InternalAlignment","InternalAlignment","InternalAlignment"]') fail(`unexpected native InternalAlignment result: ${JSON.stringify(internalResult)}`)
|
|
const resavedInternal = inspectFcstdArchive(new Uint8Array(await readFile(internalResavedPath))).objects.find((object) => object.name === 'InternalSketch')?.sketch ?? fail('Web inspector did not reconstruct the resaved native InternalAlignment Sketch.')
|
|
if (JSON.stringify(resavedInternal.geometry.map((geometry) => geometry.id)) !== '["ellipse","spline"]' || JSON.stringify(resavedInternal.constraints) !== JSON.stringify(internalSketch.constraints.map((constraint) => ({ ...constraint, driving: true })))) fail(`Web inspector did not preserve native ellipse/B-spline InternalAlignment: ${JSON.stringify(resavedInternal)}`)
|
|
|
|
const summary = {
|
|
status: 'freecad-fcstd-native-oracle-pass',
|
|
freecadVersion: result.freecadVersion,
|
|
shape: { solidCount: result.solidCount, faceCount: result.faceCount, volume: result.volume },
|
|
primitives: Object.fromEntries(Object.entries(primitiveResults).map(([objectName, primitiveResult]) => [objectName, { properties: primitiveResult.properties, solidCount: primitiveResult.solidCount, faceCount: primitiveResult.faceCount, volume: primitiveResult.volume }])),
|
|
primitiveRoundTrip: { objectCount: primitiveResaved.objects.length, shapeResourceCount: primitiveResaved.shapeResources.length, stringHasher: primitiveResaved.stringHasherResource?.status ?? 'absent' },
|
|
sketch: { geometryCount: sketchResult.geometryCount, constraintCount: sketchResult.constraintCount, constructionGeometry: sketchResult.constructionGeometry, constraintTypes: sketchResult.constraintTypes, externalGeometryCount: sketchResult.externalGeometryCount, externalGeoCount: sketchResult.externalGeoCount, externalLinks: sketchResult.externalLinks, mapMode: sketchResult.mapMode, attachmentSupport: sketchResult.attachmentSupport, attachmentOffset: sketchResult.attachmentOffset },
|
|
sketchRoundTrip: { geometryIds: resavedSketch.geometry.map((geometry) => geometry.id), constraintIds: resavedSketch.constraints.map((constraint) => constraint.id), externalIds: resavedSketch.externalGeometry.map((external) => external.id), externalSources: resavedSketch.externalGeometry.map((external) => external.source), support: proxySketch.properties.find((property) => property.name === 'Support')?.value, mapMode: proxySketch.properties.find((property) => property.name === 'MapMode')?.value },
|
|
faceProjection: { geometryCount: faceResult.geometryCount, constraintCount: faceResult.constraintCount, externalGeometryCount: faceResult.externalGeometryCount, externalGeoCount: faceResult.externalGeoCount, externalLinks: faceResult.externalLinks, roundTripIds: resavedFace.externalGeometry.map((external) => external.id) },
|
|
externalModes: { externalGeometryCount: modeResult.externalGeometryCount, externalGeoCount: modeResult.externalGeoCount, externalTypes: modeResult.externalTypes, externalStates: modeResult.externalStates, roundTripModes: resavedModes.externalGeometry.map((external) => external.mode ?? 'projection') },
|
|
snellsLaw: { geometryCount: snellResult.geometryCount, constraintCount: snellResult.constraintCount, constraintTypes: snellResult.constraintTypes, roundTrip: resavedSnell.constraints },
|
|
weight: { geometryCount: weightResult.geometryCount, constraintCount: weightResult.constraintCount, constructionGeometry: weightResult.constructionGeometry, constraintTypes: weightResult.constraintTypes, roundTrip: resavedWeight.constraints },
|
|
internalAlignment: { geometryCount: internalResult.geometryCount, constraintCount: internalResult.constraintCount, constructionGeometry: internalResult.constructionGeometry, constraintTypes: internalResult.constraintTypes, roundTrip: resavedInternal.constraints },
|
|
elementMap: { postfixCount: resavedMap.postfixCount, mapCount: resavedMap.mapCount },
|
|
stringHasherBytes: resavedHasher.byteLength,
|
|
}
|
|
const webStages = ['web-model', 'web-write', 'freecad-open', 'freecad-recompute', 'freecad-resave', 'web-inspect']
|
|
const sourceStages = ['freecad-source', 'web-inspect', 'web-write', 'freecad-open', 'freecad-resave', 'web-inspect-resaved']
|
|
const proxyStages = ['freecad-source', 'web-inspect', 'web-byte-preserving-write', 'byte-compare', 'freecad-open', 'freecad-resave', 'web-inspect-resaved']
|
|
const scenario = (id: string, direction: 'freecad-web-freecad' | 'web-freecad-web', domains: string[], evidence: Record<string, unknown>, stages?: string[]) => ({
|
|
id,
|
|
direction,
|
|
stages: stages ?? (direction === 'freecad-web-freecad' ? sourceStages : webStages),
|
|
domains,
|
|
freecadVersion: '1.1.1',
|
|
status: 'pass',
|
|
differences: [],
|
|
evidence,
|
|
})
|
|
const roundTripScenarios = [
|
|
scenario('proxy-byte-preservation', 'freecad-web-freecad', ['objectTree', 'properties', 'gui', 'resources', 'proxy'], {
|
|
sourceArchiveBytes: sourceBytes.byteLength,
|
|
preservedArchiveBytes: proxyPreservedBytes.byteLength,
|
|
sourceSha256,
|
|
preservedSha256: proxyPreservedSha256,
|
|
byteIdentical: true,
|
|
proxyObjectCount: sourceInspection.compatibility.proxyObjects,
|
|
blockedObjectCount: sourceInspection.compatibility.blockedObjects,
|
|
unknownTypeIds: sourceInspection.compatibility.unknownTypeIds,
|
|
resavedObjectCount: proxyResavedInspection.objects.length,
|
|
resavedProxyObjectCount: proxyResavedInspection.compatibility.proxyObjects,
|
|
resavedShapeValid: proxyResult.shapeValid,
|
|
}, proxyStages),
|
|
scenario('locked-partdesign-shape', 'freecad-web-freecad', ['shape', 'topology', 'resources'], {
|
|
solidCount: result.solidCount,
|
|
faceCount: result.faceCount,
|
|
volume: result.volume,
|
|
elementMapPostfixCount: resavedMap.postfixCount,
|
|
elementMapCount: resavedMap.mapCount,
|
|
stringHasherBytes: resavedHasher.byteLength,
|
|
}),
|
|
...primitiveCases.map((oracleCase) => {
|
|
const primitiveResult = primitiveResults[oracleCase.objectName]
|
|
return scenario(`part-${oracleCase.objectName.toLowerCase()}`, 'web-freecad-web', ['objectTree', 'properties', 'shape', 'resources'], {
|
|
typeId: `Part::${oracleCase.objectName}`,
|
|
solidCount: primitiveResult.solidCount,
|
|
faceCount: primitiveResult.faceCount,
|
|
volume: primitiveResult.volume,
|
|
boundingBox: primitiveResult.boundingBox,
|
|
propertyCount: Object.keys(primitiveResult.properties).length,
|
|
...(oracleCase.objectName === 'Prism' || oracleCase.objectName === 'Wedge' || oracleCase.objectName === 'Ellipsoid' ? { properties: primitiveResult.properties } : {}),
|
|
refine: primitiveResult.properties.Refine,
|
|
resavedShapeAvailable: primitiveResaved.shapeResources.some((resource) => resource.path === `${oracleCase.objectName}.Shape.brp` && resource.status === 'available'),
|
|
})
|
|
}),
|
|
scenario('part-sphere-trim', 'web-freecad-web', ['properties', 'shape', 'resources'], {
|
|
solidCount: sphereTrimResult.solidCount,
|
|
faceCount: sphereTrimResult.faceCount,
|
|
volume: sphereTrimResult.volume,
|
|
boundingBox: sphereTrimResult.boundingBox,
|
|
properties: sphereTrimResult.properties,
|
|
resavedShapeAvailable: sphereTrimResaved.shapeResources.some((resource) => resource.path === 'SphereTrim.Shape.brp' && resource.status === 'available'),
|
|
}),
|
|
scenario('part-extrusion', 'web-freecad-web', ['objectTree', 'properties', 'sketch', 'shape', 'resources'], {
|
|
typeId: partFeatureResaved.objects.find((object) => object.name === 'Extrusion')?.typeId,
|
|
solidCount: extrusionResult.solidCount,
|
|
faceCount: extrusionResult.faceCount,
|
|
volume: extrusionResult.volume,
|
|
properties: extrusionResult.properties,
|
|
resavedShapeAvailable: partFeatureResaved.shapeResources.some((resource) => resource.path === 'Extrusion.Shape.brp' && resource.status === 'available'),
|
|
}),
|
|
scenario('part-revolution', 'web-freecad-web', ['objectTree', 'properties', 'sketch', 'shape', 'resources'], {
|
|
typeId: partFeatureResaved.objects.find((object) => object.name === 'Revolution')?.typeId,
|
|
solidCount: revolutionResult.solidCount,
|
|
faceCount: revolutionResult.faceCount,
|
|
volume: revolutionResult.volume,
|
|
properties: revolutionResult.properties,
|
|
resavedShapeAvailable: partFeatureResaved.shapeResources.some((resource) => resource.path === 'Revolution.Shape.brp' && resource.status === 'available'),
|
|
}),
|
|
scenario('sketch-core-external-attachment', 'web-freecad-web', ['objectTree', 'properties', 'sketch', 'topology'], {
|
|
geometryCount: sketchResult.geometryCount,
|
|
constraintCount: sketchResult.constraintCount,
|
|
externalTypes: sketchResult.externalTypes,
|
|
mapMode: sketchResult.mapMode,
|
|
roundTripGeometryIds: resavedSketch.geometry.length,
|
|
roundTripConstraintIds: resavedSketch.constraints.length,
|
|
}),
|
|
scenario('sketch-face-projection', 'web-freecad-web', ['sketch', 'topology'], {
|
|
externalTypes: faceResult.externalTypes,
|
|
externalGeoCount: faceResult.externalGeoCount,
|
|
roundTripIds: resavedFace.externalGeometry.length,
|
|
}),
|
|
scenario('sketch-external-modes', 'web-freecad-web', ['sketch', 'topology', 'properties'], {
|
|
externalTypes: modeResult.externalTypes,
|
|
externalGeoCount: modeResult.externalGeoCount,
|
|
nativeFlags: modeResult.externalStates?.map((state) => state.flags),
|
|
roundTripModes: resavedModes.externalGeometry.map((external) => external.mode ?? 'projection'),
|
|
}),
|
|
scenario('sketch-snells-law', 'web-freecad-web', ['sketch', 'properties'], {
|
|
geometryCount: snellResult.geometryCount,
|
|
constraintTypes: snellResult.constraintTypes,
|
|
roundTripConstraints: resavedSnell.constraints.length,
|
|
}),
|
|
scenario('sketch-bspline-weight', 'web-freecad-web', ['sketch', 'properties'], {
|
|
geometryCount: weightResult.geometryCount,
|
|
constraintTypes: weightResult.constraintTypes,
|
|
roundTripConstraints: resavedWeight.constraints.length,
|
|
}),
|
|
scenario('sketch-internal-alignment', 'web-freecad-web', ['sketch', 'properties'], {
|
|
geometryCount: internalResult.geometryCount,
|
|
constraintTypes: internalResult.constraintTypes,
|
|
roundTripConstraints: resavedInternal.constraints.length,
|
|
}),
|
|
]
|
|
const roundTripReport = {
|
|
schemaVersion: 1,
|
|
baselineId: 'freecad-1.1.1',
|
|
freecadCommit: '0108fd4b4850cc46e625b60e53cea7a7bbe69f8d',
|
|
bitbybitVersion: '1.1.1',
|
|
unknownDifferencesFail: true,
|
|
directions: ['freecad-web-freecad', 'web-freecad-web'],
|
|
scenarioCount: roundTripScenarios.length,
|
|
scenarios: roundTripScenarios,
|
|
status: 'verified',
|
|
}
|
|
const reportPath = resolve(root, 'config/freecad-fcstd-roundtrip-verification.json')
|
|
await writeFile(reportPath, `${JSON.stringify(roundTripReport, null, 2)}\n`)
|
|
console.log(JSON.stringify({ ...summary, roundTripReport: { path: 'config/freecad-fcstd-roundtrip-verification.json', scenarioCount: roundTripScenarios.length, directions: roundTripReport.directions } }, null, 2))
|
|
} finally {
|
|
if (process.env.FREECAD_FCSTD_KEEP_TEMP === '1') console.error(`FREECAD_FCSTD_TEMP=${temporaryDirectory}`)
|
|
else await rm(temporaryDirectory, { recursive: true, force: true })
|
|
}
|