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')) })