Files
Web_FreeCAD_Bitbybit/tests/cam.test.ts

292 lines
19 KiB
TypeScript

import test from 'node:test'
import assert from 'node:assert/strict'
import { createCamJob } from '../src/facade/cam'
import { menuDefinitions, workbenchDefinitions } from '../src/freecadManifest'
test('CAM job generates a FreeCAD-style Profile path, simulates and posts deterministic G-code', () => {
const cam = createCamJob('job', 'Job', { min: [0, 0, 0], max: [10, 8, 6], mode: 'from-base-bound-box' })
cam.addTool({ id: 'T1', name: '6mm end mill', shape: 'endmill', diameter: 6, length: 30 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 2, feed: 120 })
const operation = cam.generateRectangularPath('profile')
const simulation = cam.simulate()
assert.equal(operation.path.length, 5)
assert.equal(simulation.status, 'pass')
assert.deepEqual(simulation.collisions, [])
assert.equal(simulation.activeOperations, 1)
assert.equal(simulation.pathPoints, 5)
assert.deepEqual(cam.postprocessors(), ['grbl', 'linuxcnc', 'mach3-mach4', 'centroid', 'marlin', 'masso-g3', 'snapmaker'])
const first = cam.exportGcode('linuxcnc')
assert.match(first, /post:linuxcnc/)
assert.match(first, /T1 M6/)
assert.equal(first, cam.exportGcode('linuxcnc'))
assert.throws(() => cam.exportGcode('unsafe' as never), /not allowed/)
})
test('CAM SetupSheet, Tool Controller, Pocket and drilling operations retain native manufacturing parameters', () => {
const cam = createCamJob('job', 'Machining Job', { min: [0, 0, 0], max: [20, 12, 8] })
cam.updateSetupSheet({ safeHeightOffset: 2, clearanceHeightOffset: 4, coolantMode: 'flood' })
cam.addTool({ id: 'T2', name: '4mm ball end', shape: 'ballend', diameter: 4, length: 40, cuttingEdgeHeight: 12, shankDiameter: 4 })
cam.addToolController({ id: 'TC2', label: 'Ball End Controller', toolId: 'T2', toolNumber: 2, spindleSpeed: 18000, spindleDirection: 'forward', horizontalFeed: 480, verticalFeed: 120, horizontalRapid: 2500, verticalRapid: 1000 })
cam.addOperation({ id: 'pocket', label: 'Pocket Shape', kind: 'pocket', toolId: 'T2', controllerId: 'TC2', depth: 4, feed: 480, verticalFeed: 120, stepDown: 2, stepOver: 50, direction: 'climb', coolantMode: 'flood' })
const pocket = cam.generatePath('pocket')
cam.addOperation({ id: 'drill', kind: 'drilling', toolId: 'T2', controllerId: 'TC2', depth: 3, feed: 120, locations: [[4, 4, 8], [16, 8, 8]] })
const drilling = cam.generatePath('drill')
assert.ok(pocket.path.length > 10)
assert.equal(drilling.path.length, 6)
assert.equal(cam.simulate().status, 'pass')
assert.match(cam.exportGcode('grbl'), /M8/)
const snapshot = cam.snapshot()
assert.equal(snapshot.setupSheet.clearanceHeightOffset, 4)
assert.equal(snapshot.toolControllers[0].spindleSpeed, 18000)
assert.equal(snapshot.operations[0].stepDown, 2)
})
test('CAM dress-ups, operation lifecycle and Sanity Check remain deterministic', () => {
const cam = createCamJob('job', 'Dress-up Job')
cam.addTool({ id: 'T1', name: 'Tool', diameter: 2, length: 20 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
cam.generatePath('profile')
cam.applyDressup('profile', { kind: 'lead-in-out', parameters: { length: 1 } })
const dressed = cam.generatePath('profile')
const copied = cam.copyOperation('profile', 'profile-copy')
assert.equal(dressed.path.length, 7)
assert.equal(copied.path.length, 7)
assert.equal(cam.sanityCheck().status, 'pass')
cam.toggleOperation('profile', false)
assert.equal(cam.sanityCheck().status, 'warning')
assert.equal(cam.simulate().activeOperations, 1)
cam.removeOperation('profile')
assert.equal(cam.snapshot().operations.length, 1)
})
test('CAM rejects invalid setup, depth, duplicate tools and G-code on collision', () => {
const cam = createCamJob('job', 'Job')
assert.throws(() => cam.updateSetupSheet({ safeHeightOffset: 6, clearanceHeightOffset: 5 }), /cannot be below/)
cam.addTool({ id: 'T1', name: 'Tool', diameter: 2, length: 10 })
assert.throws(() => cam.addTool({ id: 'T1', name: 'Duplicate', diameter: 2, length: 10 }), /already exists/)
assert.throws(() => cam.addOperation({ id: 'bad', kind: 'pocket', toolId: 'T1', depth: 11, feed: 10 }), /outside stock/)
cam.addOperation({ id: 'draft', kind: 'contour', toolId: 'T1', depth: 1, feed: 10 })
const simulation = cam.simulate()
assert.equal(simulation.status, 'collision')
assert.equal(simulation.diagnostics[0].type, 'missing-path')
assert.throws(() => cam.exportGcode(), /collisions/)
})
test('CAM ToolBit assets, controller edits and dependency-safe removal are deterministic', () => {
const source = createCamJob('source')
source.addTool({ id: 'T1', name: '3 mm Drill', shape: 'drill', diameter: 3, length: 35, cuttingEdgeHeight: 18, shankDiameter: 3 })
const asset = source.exportToolBit('T1')
assert.equal(asset, source.exportToolBit('T1'))
const cam = createCamJob('target')
const loaded = cam.importToolBit(asset, 'T7')
assert.equal(loaded.id, 'T7')
assert.equal(loaded.shape, 'drill')
cam.addToolController({ id: 'TC7', label: 'Drill Controller', toolId: 'T7', toolNumber: 7, spindleSpeed: 9000, spindleDirection: 'forward', horizontalFeed: 250, verticalFeed: 80, horizontalRapid: 1000, verticalRapid: 500 })
assert.equal(cam.updateToolController('TC7', { spindleSpeed: 10000 }).spindleSpeed, 10000)
assert.throws(() => cam.removeTool('T7'), /used by a Tool Controller/)
cam.removeToolController('TC7')
cam.removeTool('T7')
assert.equal(cam.snapshot().tools.length, 0)
assert.throws(() => cam.importToolBit('{bad json'), /valid JSON/)
})
test('CAM extended operations, Drag Knife, Dogbone and start-point editing generate stable paths', () => {
const cam = createCamJob('extended', 'Extended CAM', { min: [0, 0, 0], max: [12, 10, 6] })
cam.addTool({ id: 'T1', name: '2 mm Tool', diameter: 2, length: 30 })
const kinds = ['area', 'area-workplane', 'custom', 'shape', 'path-shape-tool-controller'] as const
for (const [index, kind] of kinds.entries()) {
const id = `${kind}-${index}`
cam.addOperation({ id, kind, toolId: 'T1', depth: 1, feed: 100 })
assert.ok(cam.generatePath(id).path.length >= 2)
}
cam.applyDressup('shape-3', { kind: 'dogbone', parameters: { radius: 0.4 } })
const dogbone = cam.generatePath('shape-3')
assert.ok(dogbone.path.length > 5)
cam.applyDressup('area-0', { kind: 'drag-knife', parameters: { offset: 0.3 } })
const dragged = cam.generatePath('area-0')
assert.ok(dragged.path.length >= 5)
const originalStart = cam.snapshot().operations.find((operation) => operation.id === 'area-workplane-1')?.path[0]
const shifted = cam.setStartPoint('area-workplane-1', 1)
assert.notDeepEqual(shifted.path[0], originalStart)
assert.deepEqual(shifted.path[0], shifted.path.at(-1))
})
test('CAM material removal timeline and fixture holder collision are deterministic', () => {
const cam = createCamJob('simulation', 'Simulation', { min: [0, 0, 0], max: [10, 10, 5] })
cam.addTool({ id: 'T1', name: 'Short edge tool', diameter: 2, length: 20, cuttingEdgeHeight: 1, shankDiameter: 2, holderDiameter: 4, holderLength: 20 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
cam.generatePath('profile')
const first = cam.simulate()
assert.equal(first.status, 'pass')
assert.equal(first.materialRemoval.enabled, true)
assert.equal(first.materialRemoval.stockVolume, 500)
assert.ok(first.materialRemoval.removedVolume > 0)
assert.equal(first.materialRemoval.timeline.length, first.pathPoints)
assert.equal(first.materialRemoval.remainingVolume, first.materialRemoval.stockVolume - first.materialRemoval.removedVolume)
cam.setMaterialRemovalEnabled(false)
const disabled = cam.simulate()
assert.equal(disabled.materialRemoval.enabled, false)
assert.equal(disabled.materialRemoval.removedVolume, 0)
assert.equal(disabled.materialRemoval.timeline.length, 0)
cam.setMaterialRemovalEnabled(true)
cam.updateCollisionFixtures([{ id: 'clamp', kind: 'fixture', min: [-1, -1, 5], max: [1, 1, 7] }])
const collision = cam.simulate()
assert.equal(collision.status, 'collision')
assert.ok(collision.diagnostics.some((diagnostic) => diagnostic.type === 'fixture' && diagnostic.message.includes('clamp')))
assert.throws(() => cam.updateCollisionFixtures([{ id: 'clamp', min: [0, 0, 0], max: [1, 1, 1] }, { id: 'clamp', min: [2, 2, 2], max: [3, 3, 3] }]), /unique/)
})
test('CAM browser raster timeline records feed time and does not remove overlapping stock twice', () => {
const cam = createCamJob('raster', 'Raster simulation', { min: [0, 0, 0], max: [12, 6, 4] })
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 20, cuttingEdgeHeight: 10, holderDiameter: 5, holderLength: 20 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 2, feed: 60 })
const profile = cam.generatePath('profile')
const first = cam.materialRemovalTimeline({ resolution: 0.5, maxCells: 4096 })
assert.equal(first.status, 'pass')
assert.equal(first.backend, 'browser-raster')
assert.equal(first.model, '2d-column-raster')
assert.equal(first.nativeEquivalent, false)
assert.equal(first.events.length, profile.path.length)
assert.ok(first.removedVolume > 0 && first.removedVolume <= first.initialVolume)
assert.equal(first.remainingVolume, first.initialVolume - first.removedVolume)
assert.equal(first.events.at(-1)?.cumulativeRemovedVolume, first.removedVolume)
assert.equal(first.events.at(-1)?.endTimeMinutes, first.durationMinutes)
assert.ok(first.events.every((event, index) => index === 0 || event.startTimeMinutes === first.events[index - 1].endTimeMinutes))
assert.deepEqual(cam.materialRemovalTimeline({ resolution: 0.5, maxCells: 4096 }), first)
cam.copyOperation('profile', 'profile-copy')
const repeated = cam.materialRemovalTimeline({ resolution: 0.5, maxCells: 4096 })
assert.equal(repeated.removedVolume, first.removedVolume)
assert.ok(Math.abs(repeated.durationMinutes - first.durationMinutes * 2) < 1e-12)
assert.equal(repeated.events.length, first.events.length * 2)
assert.throws(() => cam.materialRemovalTimeline({ resolution: 0 }), /positive/)
assert.throws(() => cam.materialRemovalTimeline({ maxCells: 1.5 }), /integer/)
})
test('CAM collision report distinguishes holder-stock and swept cutter-fixture collisions', () => {
const holderCam = createCamJob('holder', 'Holder collision', { min: [0, 0, 0], max: [10, 10, 5] })
holderCam.addTool({ id: 'T1', name: 'Short flute', diameter: 2, length: 20, cuttingEdgeHeight: 0.5, holderDiameter: 4, holderLength: 12 })
holderCam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
holderCam.generatePath('profile')
const holderReport = holderCam.collisionReport()
assert.equal(holderReport.status, 'collision')
assert.ok(holderReport.holderCollisions > 0)
assert.ok(holderReport.collisions.some((collision) => collision.kind === 'holder-stock' && collision.penetration > 0 && collision.clearance < 0))
assert.ok(holderCam.simulate().diagnostics.some((diagnostic) => diagnostic.type === 'tool-holder'))
const fixtureCam = createCamJob('fixture', 'Swept fixture collision', { min: [0, 0, 0], max: [10, 10, 5] })
fixtureCam.addTool({ id: 'T1', name: '1 mm End Mill', diameter: 1, length: 20, cuttingEdgeHeight: 10, holderDiameter: 4, holderLength: 20 })
fixtureCam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
fixtureCam.generatePath('profile')
fixtureCam.updateCollisionFixtures([{ id: 'mid-span-clamp', min: [4.8, -0.2, 3.5], max: [5.2, 0.2, 4.5] }])
const fixtureReport = fixtureCam.collisionReport()
const swept = fixtureReport.collisions.find((collision) => collision.kind === 'cutter-fixture' && collision.fixtureId === 'mid-span-clamp')
assert.equal(fixtureReport.status, 'collision')
assert.ok(fixtureReport.fixtureCollisions > 0)
assert.equal(swept?.pathIndex, 1)
assert.ok((swept?.point[0] ?? 0) > 4 && (swept?.point[0] ?? 0) < 6)
const simulation = fixtureCam.simulate()
assert.equal(simulation.status, 'collision')
assert.deepEqual(simulation.collisionReport, fixtureReport)
assert.ok(simulation.diagnostics.some((diagnostic) => diagnostic.type === 'fixture' && diagnostic.message.includes('mid-span-clamp')))
})
test('CAM Job assets and Undo/Redo preserve the complete browser manufacturing graph', () => {
const cam = createCamJob('asset-job', 'Asset Job', { min: [0, 0, 0], max: [8, 6, 4], mode: 'existing-solid' })
cam.updateSetupSheet({ safeHeightOffset: 1, clearanceHeightOffset: 2, coolantMode: 'mist' })
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 20, cuttingEdgeHeight: 8, holderDiameter: 5, holderLength: 20 })
cam.addToolController({ id: 'TC1', label: 'Controller', toolId: 'T1', toolNumber: 1, spindleSpeed: 12000, spindleDirection: 'forward', horizontalFeed: 100, verticalFeed: 50, horizontalRapid: 500, verticalRapid: 300 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', controllerId: 'TC1', depth: 1, feed: 100 })
cam.applyDressup('profile', { kind: 'dogbone', parameters: { radius: 0.25 } })
cam.generatePath('profile')
cam.updateCollisionFixtures([{ id: 'clamp', min: [3, -1, 3.5], max: [4, 0.2, 5] }])
const asset = cam.exportJob()
assert.equal(asset, cam.exportJob())
cam.toggleOperation('profile', false)
assert.equal(cam.snapshot().operations[0].active, false)
assert.equal(cam.canUndo(), true)
assert.equal(cam.undo().operations[0].active, true)
assert.equal(cam.canRedo(), true)
assert.equal(cam.redo().operations[0].active, false)
const reopened = createCamJob('asset-job', 'Asset Job')
const loaded = reopened.importJob(asset)
assert.equal(reopened.exportJob(), asset)
assert.equal(loaded.operations[0].dressups[0].kind, 'dogbone')
assert.equal(loaded.collisionFixtures[0].id, 'clamp')
assert.equal(reopened.canUndo(), true)
assert.equal(reopened.undo().operations.length, 0)
assert.equal(reopened.redo().operations.length, 1)
assert.throws(() => reopened.importJob('{bad json'), /valid JSON/)
assert.throws(() => createCamJob('other', 'Other').importJob(asset), /identity/)
const invalid = JSON.parse(asset)
invalid.job.operations[0].path[0][0] = null
assert.throws(() => reopened.importJob(JSON.stringify(invalid)), /finite coordinates/)
})
test('CAM 4-axis and 5-axis kinematics enforce limits and emit controlled G-code', () => {
const cam = createCamJob('multi-axis', 'Multi Axis', { min: [0, 0, 0], max: [10, 8, 5] })
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 30, cuttingEdgeHeight: 30 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
const operation = cam.generatePath('profile')
const fourAxis = cam.kinematics('profile', { configuration: '4-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 90 } })
assert.equal(fourAxis.status, 'pass')
assert.equal(fourAxis.nativeEquivalent, false)
assert.equal(fourAxis.poses.length, operation.path.length)
assert.equal(fourAxis.poses[0].a, 0)
assert.equal(fourAxis.poses.at(-1)?.a, 90)
const fiveAxis = cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } })
assert.equal(fiveAxis.status, 'pass')
assert.equal(fiveAxis.poses.at(-1)?.b, 45)
const limited = cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 20 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 150 } })
assert.equal(limited.status, 'limit')
assert.ok(limited.violations.some((violation) => violation.axis === 'B'))
const gcode = cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'C', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } })
assert.match(gcode, /post:linuxcnc xyzbc-rtcp/)
assert.match(gcode, /B45\.000 C30\.000/)
assert.match(gcode, /M428/)
assert.match(gcode, /G93/)
assert.match(gcode, /G94\nM429/)
assert.equal(gcode, cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'C', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } }))
assert.throws(() => cam.exportMultiAxisGcode('profile', 'linuxcnc', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: -30, endDegrees: 30 }, tilt: { axis: 'B', startDegrees: 0, endDegrees: 45 } }), /requires distinct B and C/)
assert.throws(() => cam.exportMultiAxisGcode('profile', 'grbl', { configuration: '4-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 90 } }), /does not support/)
assert.throws(() => cam.kinematics('profile', { configuration: '5-axis', rotary: { axis: 'A', startDegrees: 0, endDegrees: 10 }, tilt: { axis: 'A', startDegrees: 0, endDegrees: 10 } }), /different/)
})
test('CAM Job comments, typed properties and operation compounds round-trip with undo/redo', () => {
const cam = createCamJob('metadata', 'Metadata Job')
cam.addTool({ id: 'T1', name: '2 mm End Mill', diameter: 2, length: 20 })
cam.addOperation({ id: 'profile', kind: 'profile', toolId: 'T1', depth: 1, feed: 100 })
cam.addOperation({ id: 'pocket', kind: 'pocket', toolId: 'T1', depth: 1, feed: 100 })
assert.equal(cam.addComment(' Setup note '), 'Setup note')
cam.setPropertyBagValue('Material', 'Al6061')
cam.setPropertyBagValue('BatchSize', 12)
cam.setPropertyBagValue('DryRun', true)
const compound = cam.createCompound(['profile', 'pocket'], 'roughing', 'Roughing Group')
assert.deepEqual(compound, { id: 'roughing', label: 'Roughing Group', operationIds: ['profile', 'pocket'] })
assert.deepEqual(cam.snapshot().metadata, { comments: ['Setup note'], propertyBag: { Material: 'Al6061', BatchSize: 12, DryRun: true }, compounds: [compound] })
const asset = cam.exportJob()
const reopened = createCamJob('metadata', 'Metadata Job')
assert.deepEqual(reopened.importJob(asset).metadata, cam.snapshot().metadata)
reopened.removeOperation('pocket')
assert.deepEqual(reopened.snapshot().metadata.compounds, [])
assert.deepEqual(reopened.undo().metadata.compounds, [compound])
assert.deepEqual(reopened.redo().metadata.compounds, [])
assert.throws(() => cam.setPropertyBagValue('bad name', 'value'), /property name/)
assert.throws(() => cam.createCompound(['profile', 'missing']), /existing, unique operations/)
})
test('CAM manifest exposes every actionable FreeCAD CAM command exactly once', () => {
const toolbar = workbenchDefinitions.CAM.groups.flatMap((group) => group.commands)
const menu = menuDefinitions.CAM
assert.equal(workbenchDefinitions.CAM.groups.length, 4)
assert.equal(toolbar.length, 60)
assert.equal(menu.length, 60)
assert.equal(new Set(toolbar.map((command) => command.id)).size, 60)
assert.deepEqual(new Set(menu.map((command) => command.command)), new Set(toolbar.map((command) => command.id)))
assert.ok(!toolbar.some((command) => command.id === 'CAM_%s'))
})