109 lines
7.6 KiB
TypeScript
109 lines
7.6 KiB
TypeScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import { createDataModules } from '../src/facade/dataModules'
|
|
|
|
const bytes = (value: string) => new TextEncoder().encode(value)
|
|
const close = (actual: number, expected: number, tolerance = 1e-9) => assert.ok(Math.abs(actual - expected) <= tolerance, `${actual} != ${expected}`)
|
|
|
|
test('data adapters expose specialist module coverage and proxy records', () => {
|
|
const data = createDataModules()
|
|
assert.equal(data.descriptors().length, 7)
|
|
assert.equal(data.descriptors().find((entry) => entry.module === 'Points')?.status, 'supported')
|
|
assert.equal(data.descriptors().find((entry) => entry.module === 'ReverseEngineering')?.status, 'supported')
|
|
const record = data.importRecord({ id: 'openscad', module: 'OpenSCAD', format: 'SCAD', bytes: bytes('cube(1);'), metadata: { units: 'mm' } })
|
|
assert.equal(record.proxy, true)
|
|
assert.equal(record.format, 'scad')
|
|
assert.match(data.exportManifest(), /"module": "OpenSCAD"/)
|
|
})
|
|
|
|
test('Points imports, exports, translates, crops, downsamples, structures and merges point clouds', () => {
|
|
const data = createDataModules()
|
|
const cloud = data.importPointCloud({
|
|
id: 'grid',
|
|
format: 'csv',
|
|
bytes: bytes('x,y,z\n0,0,0\n1,0,1\n2,0,2\n0,1,3\n1,1,4\n2,1,5\n'),
|
|
})
|
|
assert.equal(cloud.points.length, 6)
|
|
assert.deepEqual(cloud.bounds, { min: { x: 0, y: 0, z: 0 }, max: { x: 2, y: 1, z: 5 } })
|
|
assert.deepEqual(cloud.centroid, { x: 1, y: 0.5, z: 2.5 })
|
|
|
|
const structured = data.structurePointCloud({ id: 'structured', sourceId: 'grid' })
|
|
assert.deepEqual(structured.structured, { width: 3, height: 2, grid: [0, 1, 2, 3, 4, 5] })
|
|
const translated = data.translatePointCloud({ id: 'centered', sourceId: 'grid', offset: { x: -1, y: -0.5, z: -2.5 } })
|
|
assert.deepEqual(translated.centroid, { x: 0, y: 0, z: 0 })
|
|
const cropped = data.cropPointCloud({ id: 'cropped', sourceId: 'grid', bounds: { min: { x: 1, y: 0, z: 0 }, max: { x: 2, y: 1, z: 5 } } })
|
|
assert.equal(cropped.points.length, 4)
|
|
const polygonCropped = data.polygonCropPointCloud({ id: 'polygon-cropped', sourceId: 'grid', polygon: [{ x: 0, y: 0 }, { x: 1.1, y: 0 }, { x: 1.1, y: 1.1 }, { x: 0, y: 1.1 }] })
|
|
assert.equal(polygonCropped.points.length, 4)
|
|
const reduced = data.voxelDownsample({ id: 'reduced', sourceId: 'grid', size: 10 })
|
|
assert.equal(reduced.points.length, 1)
|
|
assert.deepEqual(reduced.points[0], cloud.centroid)
|
|
const merged = data.mergePointClouds({ id: 'merged', sourceIds: ['grid', 'centered'] })
|
|
assert.equal(merged.points.length, 12)
|
|
|
|
const xyz = new TextDecoder().decode(data.exportPointCloud('grid', 'xyz'))
|
|
assert.equal(xyz, '0 0 0\n1 0 1\n2 0 2\n0 1 3\n1 1 4\n2 1 5\n')
|
|
const roundTrip = createDataModules().importPointCloud({ id: 'roundtrip', format: 'xyz', bytes: bytes(xyz) })
|
|
assert.deepEqual(roundTrip.points, cloud.points)
|
|
})
|
|
|
|
test('Points parses PTS, ASCII PCD and ASCII PLY with explicit format errors', () => {
|
|
const data = createDataModules()
|
|
assert.equal(data.importPointCloud({ id: 'pts', format: 'pts', bytes: bytes('2\n1 2 3\n4 5 6\n') }).points.length, 2)
|
|
assert.equal(data.importPointCloud({ id: 'pcd', format: 'pcd', bytes: bytes('VERSION 0.7\nFIELDS x y z\nWIDTH 2\nHEIGHT 1\nPOINTS 2\nDATA ascii\n1 2 3\n4 5 6\n') }).points.length, 2)
|
|
assert.equal(data.importPointCloud({ id: 'ply', format: 'ply', bytes: bytes('ply\nformat ascii 1.0\nelement vertex 2\nproperty float x\nproperty float y\nproperty float z\nend_header\n1 2 3\n4 5 6\n') }).points.length, 2)
|
|
assert.throws(() => data.importPointCloud({ id: 'short', format: 'pts', bytes: bytes('3\n1 2 3\n4 5 6\n') }), /declared 3 points/)
|
|
assert.throws(() => data.importPointCloud({ id: 'binary', format: 'ply', bytes: bytes('ply\nformat binary_little_endian 1.0\nend_header\n') }), /Only ASCII PLY/)
|
|
assert.throws(() => data.importPointCloud({ id: 'empty', format: 'xyz', bytes: bytes('# no points\n') }), /at least one point/)
|
|
})
|
|
|
|
test('ReverseEngineering fits FreeCAD-style plane, sphere and cylinder primitives', () => {
|
|
const data = createDataModules()
|
|
const planeRows: string[] = []
|
|
for (const x of [-2, 0, 2]) for (const y of [-1, 1]) planeRows.push(`${x},${y},${2 * x + 3 * y + 4}`)
|
|
data.importPointCloud({ id: 'plane-points', format: 'csv', bytes: bytes(`x,y,z\n${planeRows.join('\n')}\n`) })
|
|
const plane = data.fitPlane({ id: 'plane-fit', sourceId: 'plane-points', referenceNormal: { x: -2, y: -3, z: 1 } })
|
|
close(plane.rms, 0)
|
|
close(plane.normal.x, -2 / Math.sqrt(14))
|
|
close(plane.normal.y, -3 / Math.sqrt(14))
|
|
close(plane.normal.z, 1 / Math.sqrt(14))
|
|
|
|
const spherePoints = [
|
|
[3, 2, 3], [-1, 2, 3], [1, 4, 3], [1, 0, 3], [1, 2, 5], [1, 2, 1],
|
|
[1 + Math.sqrt(2), 2 + Math.sqrt(2), 3], [1 - Math.sqrt(2), 2 - Math.sqrt(2), 3],
|
|
].map((entry) => entry.join(' ')).join('\n')
|
|
data.importPointCloud({ id: 'sphere-points', format: 'xyz', bytes: bytes(`${spherePoints}\n`) })
|
|
const sphere = data.fitSphere({ id: 'sphere-fit', sourceId: 'sphere-points' })
|
|
close(sphere.center.x, 1); close(sphere.center.y, 2); close(sphere.center.z, 3); close(sphere.radius, 2); close(sphere.rms, 0)
|
|
|
|
const cylinderRows: string[] = []
|
|
for (const z of [-4, 0, 4]) for (let index = 0; index < 8; index += 1) { const angle = index * Math.PI / 4; cylinderRows.push(`${2 + 3 * Math.cos(angle)} ${-1 + 3 * Math.sin(angle)} ${z}`) }
|
|
data.importPointCloud({ id: 'cylinder-points', format: 'xyz', bytes: bytes(`${cylinderRows.join('\n')}\n`) })
|
|
const cylinder = data.fitCylinder({ id: 'cylinder-fit', sourceId: 'cylinder-points' })
|
|
close(cylinder.radius, 3); close(cylinder.height, 8); close(cylinder.rms, 0, 1e-8); close(Math.abs(cylinder.axis.z), 1)
|
|
|
|
const polynomialRows: string[] = []
|
|
for (const x of [-1, 0, 1]) for (const y of [-1, 0, 1]) polynomialRows.push(`${x} ${y} ${x * x + 2 * x * y + 3 * y * y + 4 * x + 5 * y + 6}`)
|
|
data.importPointCloud({ id: 'polynomial-points', format: 'xyz', bytes: bytes(`${polynomialRows.join('\n')}\n`) })
|
|
const polynomial = data.fitPolynomialSurface({ id: 'polynomial-fit', sourceId: 'polynomial-points' })
|
|
assert.deepEqual(polynomial.coefficients.map((value) => Number(value.toFixed(8))), [1, 2, 3, 4, 5, 6])
|
|
close(polynomial.rms, 0)
|
|
|
|
data.importPointCloud({ id: 'segmentation-points', format: 'xyz', bytes: bytes('0 0 0\n0 0.1 0\n10 0 0\n10 0.1 0\n') })
|
|
const segments = data.segmentPointCloud({ id: 'segments', sourceId: 'segmentation-points', radius: 0.3 })
|
|
assert.deepEqual(segments.clusters.map((cluster) => cluster.pointIndices.length), [2, 2])
|
|
assert.equal(data.snapshot().reverseEngineering.length, 4)
|
|
assert.equal(data.snapshot().segments.length, 1)
|
|
})
|
|
|
|
test('data adapters reject unknown formats, degenerate fits and oversized records', () => {
|
|
const data = createDataModules()
|
|
assert.throws(() => data.importRecord({ id: 'bad', module: 'JtReader', format: 'step', bytes: new Uint8Array([1]) }), /does not accept/)
|
|
assert.throws(() => data.importRecord({ id: 'large', module: 'Import', format: 'step', bytes: { length: 8 * 1024 * 1024 + 1 } }), /outside the supported limit/)
|
|
data.importPointCloud({ id: 'line', format: 'xyz', bytes: bytes('0 0 0\n1 0 0\n2 0 0\n3 0 0\n') })
|
|
assert.throws(() => data.fitPlane({ id: 'bad-plane', sourceId: 'line' }), /degenerate|non-zero/)
|
|
assert.throws(() => data.cropPointCloud({ id: 'empty-crop', sourceId: 'line', bounds: { min: { x: 9, y: 9, z: 9 }, max: { x: 10, y: 10, z: 10 } } }), /removed every point/)
|
|
assert.throws(() => data.polygonCropPointCloud({ id: 'bad-polygon', sourceId: 'line', polygon: [{ x: 0, y: 0 }, { x: 1, y: 1 }] }), /non-degenerate/)
|
|
assert.throws(() => data.segmentPointCloud({ id: 'bad-segment', sourceId: 'line', radius: 0 }), /positive/)
|
|
})
|