Skip to content

Commit 4fd8b83

Browse files
authored
Merge pull request #500 from xyz-tools/codex/497-inch-units
Fix inch-unit conversion for coordinates and extrusion
2 parents e26da3b + e9d33f0 commit 4fd8b83

9 files changed

Lines changed: 152 additions & 45 deletions

File tree

src/__tests__/arc-tessellator.ts

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import { ArcTessellator, ArcMove, ArcPoint } from '../arc-tessellator';
44
describe('ArcTessellator', () => {
55
const tessellator = new ArcTessellator();
66

7-
function tessellate(start: ArcPoint, move: ArcMove, units: 'mm' | 'in' = 'mm'): ArcPoint[] {
7+
function tessellate(start: ArcPoint, move: ArcMove): ArcPoint[] {
88
const points: ArcPoint[] = [];
9-
tessellator.tessellate(start, move, (x, y, z) => points.push({ x, y, z }), units);
9+
tessellator.tessellate(start, move, (x, y, z) => points.push({ x, y, z }));
1010
return points;
1111
}
1212

@@ -162,14 +162,4 @@ describe('ArcTessellator', () => {
162162

163163
expect(points).toEqual([{ x: 10, y: 0, z: 0 }]);
164164
});
165-
166-
test('emits more segments for the same arc in inches', () => {
167-
const start = { x: 1, y: 0, z: 0 };
168-
const move = { cw: false, x: 0, y: 1, i: -1, j: 0 };
169-
170-
const mm = tessellate(start, move, 'mm');
171-
const inches = tessellate(start, move, 'in');
172-
173-
expect(inches.length).toBeGreaterThan(mm.length);
174-
});
175165
});

src/__tests__/interpreter/commands/set-units.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,98 @@ test('G21 sets the units to millimeters', () => {
2020

2121
expect(job.state.units).toEqual('mm');
2222
});
23+
24+
import { Parser } from '../../../parser/gcode-parser';
25+
import { Interpreter } from '../../../interpreter';
26+
27+
function run(gcode: string) {
28+
return new Interpreter().execute(new Parser().parseGCode(gcode).commands);
29+
}
30+
31+
test.each(['M82', 'M83'])('inch and mm straight moves have identical geometry and filament length in %s', (mode) => {
32+
const inch = run(`G20\n${mode}\nG28\nG0 X1 Y1 Z1\nG1 X2 E1\nG1 Y2 Z2 E${mode === 'M82' ? 2 : 1}`);
33+
const mm = run(
34+
`G21\n${mode}\nG28\nG0 X25.4 Y25.4 Z25.4\nG1 X50.8 E25.4\nG1 Y50.8 Z50.8 E${mode === 'M82' ? 50.8 : 25.4}`
35+
);
36+
37+
expect(inch.paths.map((path) => ({ type: path.type, vertices: path.vertices }))).toEqual(
38+
mm.paths.map((path) => ({ type: path.type, vertices: path.vertices }))
39+
);
40+
expect(inch.boundingBox).toEqual(mm.boundingBox);
41+
expect(inch.stats.extrusionDistance).toBeCloseTo(mm.stats.extrusionDistance);
42+
expect(inch.stats.extrusionDistance).toBeCloseTo(50.8);
43+
});
44+
45+
test.each(['M82', 'M83'])('inch E-only retract and prime preserve geometry and subsequent extrusion in %s', (mode) => {
46+
const start = `G20\n${mode}\nG28\nG1 X1 Y1 Z1 E2`;
47+
const baseline = run(start);
48+
const job = run(start);
49+
const execute = (gcode: string) => new Interpreter().execute(new Parser().parseGCode(gcode).commands, job);
50+
51+
for (const [command, expectedE] of [
52+
[mode === 'M82' ? 'G1 E1' : 'G1 E-1', 25.4],
53+
[mode === 'M82' ? 'G1 E2' : 'G1 E1', 50.8]
54+
] as const) {
55+
execute(command);
56+
expect(job.state.e).toBeCloseTo(expectedE);
57+
expect([job.state.x, job.state.y, job.state.z]).toEqual([25.4, 25.4, 25.4]);
58+
expect(job.paths.map((path) => path.vertices)).toEqual(baseline.paths.map((path) => path.vertices));
59+
expect(job.boundingBox).toEqual(baseline.boundingBox);
60+
expect(job.stats.extrusionDistance).toBeCloseTo(50.8);
61+
}
62+
63+
const nextMove = `G1 X2 E${mode === 'M82' ? 3 : 1}`;
64+
execute(nextMove);
65+
const uninterrupted = run(`${start}\n${nextMove}`);
66+
expect(job.paths.map((path) => ({ type: path.type, vertices: path.vertices }))).toEqual(
67+
uninterrupted.paths.map((path) => ({ type: path.type, vertices: path.vertices }))
68+
);
69+
expect(job.boundingBox).toEqual(uninterrupted.boundingBox);
70+
expect(job.state.e).toBeCloseTo(76.2);
71+
expect(job.stats.extrusionDistance).toBeCloseTo(76.2);
72+
});
73+
74+
test.each(['M82', 'M83'])('normalizes linear moves, E-only moves and G92 in %s', (mode) => {
75+
const job = run(`G20\n${mode}\nG28\nG1 X1 Y2 Z3 E1\nG1 E-1\nG92 X2 Y3 Z4 E2\nG1 X3 Y4 Z5 E3`);
76+
expect(job.state.x).toBeCloseTo(50.8);
77+
expect(job.state.y).toBeCloseTo(76.2);
78+
expect(job.state.z).toBeCloseTo(101.6);
79+
expect(job.state.e).toBeCloseTo(mode === 'M82' ? 76.2 : 127);
80+
});
81+
82+
test('unit switches preserve stored coordinates and omitted axes', () => {
83+
const job = run('G20\nG28\nG1 X1 Y2 Z3 E1\nG21\nG1 X50.8 E50.8\nG92 X0 E0\nG20\nG1 X1 E1');
84+
expect(job.state.x).toBeCloseTo(76.2);
85+
expect(job.state.y).toBeCloseTo(50.8);
86+
expect(job.state.z).toBeCloseTo(76.2);
87+
expect(job.stats.extrusionDistance).toBeCloseTo(76.2);
88+
expect(job.state.e).toBeCloseTo(25.4);
89+
});
90+
91+
test.each(['G2 X2 Y0 Z1 I1 J0 E2', 'G3 X2 Y0 Z1 R1 E2'])('inch and mm arcs have identical geometry: %s', (arc) => {
92+
const inch = run(`G20\nG28\n${arc}`);
93+
const mmArc = arc.replace(/([XYZIJRE])(-?\d+)/g, (_, word, value) => `${word}${Number(value) * 25.4}`);
94+
const mm = run(`G21\nG28\n${mmArc}`);
95+
expect(inch.paths.map((path) => path.vertices)).toEqual(mm.paths.map((path) => path.vertices));
96+
expect(inch.boundingBox).toEqual(mm.boundingBox);
97+
expect(inch.stats.extrusionDistance).toBeCloseTo(50.8);
98+
});
99+
100+
test.each(['G31', 'G38.2', 'G38.3', 'G38.4', 'G38.5'])('normalizes %s targets and preserves Z0 contact', (probe) => {
101+
const job = run(`G20\nG28\nG1 Z1\n${probe} X2 Y3 Z-1`);
102+
expect(job.state.x).toBeCloseTo(50.8);
103+
expect(job.state.y).toBeCloseTo(76.2);
104+
expect(job.state.z).toBe(0);
105+
const unknown = run(`G20\n${probe} Z-1`);
106+
expect(unknown.state.z).toBeCloseTo(-25.4);
107+
});
108+
109+
test('slicer dimensions remain millimeters in inch mode', () => {
110+
const parser = new Parser();
111+
const parsed = parser.parseGCode('; generated by PrusaSlicer\nG20\n;WIDTH:0.45\n;HEIGHT:0.2\nG28\nG1 X1 E1');
112+
const job = new Job();
113+
job.metadata = parser.metadata;
114+
new Interpreter().execute(parsed.commands, job);
115+
expect(job.state.extrusionWidth).toBe(0.45);
116+
expect(job.state.lineHeight).toBe(0.2);
117+
});

src/arc-tessellator.ts

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { Units, MM_PER_INCH } from './units';
2-
31
/** A point along a tessellated arc, in absolute G-code coordinates */
42
export interface ArcPoint {
53
x: number;
@@ -68,17 +66,15 @@ export class ArcTessellator {
6866
}
6967
/**
7068
* Converts an arc move into the points to draw, ending on the arc's endpoint
71-
* @param start - Absolute position at the start of the arc
72-
* @param move - Arc parameters from the G2/G3 command
69+
* @param start - Absolute position in millimeters at the start of the arc
70+
* @param move - Arc parameters normalized to millimeters
7371
* @param emit - Called once per point, in order, endpoint last. A callback
7472
* instead of a returned array so arc-heavy files do not allocate a throwaway
7573
* point object per segment.
76-
* @param units - Current units; the chord tolerance is defined in
77-
* millimeters, so inch-based arcs are tessellated proportionally finer
7874
* @returns The arc's exact endpoint (also the last point emitted). Emits at
7975
* least the endpoint, even for degenerate arcs.
8076
*/
81-
tessellate(start: ArcPoint, move: ArcMove, emit: EmitPoint, units: Units = 'mm'): ArcPoint {
77+
tessellate(start: ArcPoint, move: ArcMove, emit: EmitPoint): ArcPoint {
8278
const { cw } = move;
8379
let { i, j, r } = move;
8480
// Omitted words are defaults, not "unset": G-code reads a missing I/J as a zero
@@ -150,8 +146,7 @@ export class ArcTessellator {
150146
// step would satisfy it and only the MAX_SEGMENT_ANGLE cap matters. A
151147
// non-finite radius flows through as NaN or a 0 step, making totalSegments
152148
// non-finite; the guard below the z handling skips the loop for those.
153-
const radiusMm = units == 'in' ? arcRadius * MM_PER_INCH : arcRadius;
154-
const maxStep = 2 * Math.acos(Math.max(1 - this.chordTolerance / radiusMm, -1));
149+
const maxStep = 2 * Math.acos(Math.max(1 - this.chordTolerance / arcRadius, -1));
155150
const step = Math.min(maxStep, MAX_SEGMENT_ANGLE);
156151

157152
let totalSegments = totalArc / step;

src/interpreter/commands/arc-move.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toMillimeters } from '../../units';
12
import { PathType } from '../../path';
23
import { ArcTessellator, ArcTessellatorOptions } from '../../arc-tessellator';
34
import type { CommandHandler } from '../../interpreter';
@@ -15,15 +16,19 @@ import type { CommandHandler } from '../../interpreter';
1516
export const makeArcMove = (options: ArcTessellatorOptions = {}): CommandHandler => {
1617
const arcTessellator = new ArcTessellator(options);
1718
return (command, job) => {
18-
const { e, i, j, r } = command.params;
1919
const { state } = job;
20+
const { units } = state;
21+
const e = toMillimeters(command.params.e, units);
22+
const i = toMillimeters(command.params.i, units);
23+
const j = toMillimeters(command.params.j, units);
24+
const r = toMillimeters(command.params.r, units);
2025
// The endpoint arrives in logical coordinates; translate it into physical
2126
// space up front so the tessellator's derived values agree with `from`.
2227
// I/J/R are relative distances and need no shift.
2328
const { positionShift } = state;
24-
const x = command.params.x === undefined ? undefined : command.params.x + positionShift.x;
25-
const y = command.params.y === undefined ? undefined : command.params.y + positionShift.y;
26-
const z = command.params.z === undefined ? undefined : command.params.z + positionShift.z;
29+
const x = command.params.x === undefined ? undefined : toMillimeters(command.params.x, units)! + positionShift.x;
30+
const y = command.params.y === undefined ? undefined : toMillimeters(command.params.y, units)! + positionShift.y;
31+
const z = command.params.z === undefined ? undefined : toMillimeters(command.params.z, units)! + positionShift.z;
2732
// Starting position for the arc, with any un-homed axis assumed at the origin.
2833
const from = job.resolvePosition();
2934

@@ -45,17 +50,12 @@ export const makeArcMove = (options: ArcTessellatorOptions = {}): CommandHandler
4550
// The tessellator runs on the resolved position and emits every point,
4651
// ending with the exact endpoint -- which equals resolvePosition() after
4752
// the state update below, so no separate endpoint emission is needed.
48-
arcTessellator.tessellate(
49-
from,
50-
{ cw, x, y, z, i, j, r },
51-
(px, py, pz) => {
52-
currentPath.addPoint(px, py, pz);
53-
if (pathType === PathType.Extrusion) {
54-
job.boundingBox.update(px, py, pz);
55-
}
56-
},
57-
state.units
58-
);
53+
arcTessellator.tessellate(from, { cw, x, y, z, i, j, r }, (px, py, pz) => {
54+
currentPath.addPoint(px, py, pz);
55+
if (pathType === PathType.Extrusion) {
56+
job.boundingBox.update(px, py, pz);
57+
}
58+
});
5959

6060
// `??` not `||`: an arc ending on X0, Y0 or Z0 used to silently keep the previous
6161
// coordinate. Safe now that the parser drops non-finite params -- `||` was also

src/interpreter/commands/linear-move.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toMillimeters } from '../../units';
12
import { PathType } from '../../path';
23
import type { CommandHandler } from '../../interpreter';
34

@@ -11,8 +12,13 @@ import type { CommandHandler } from '../../interpreter';
1112
* G0 is for rapid moves (non-extrusion), G1 is for linear moves (with optional extrusion).
1213
*/
1314
export const linearMove: CommandHandler = (command, job) => {
14-
const { x, y, z, e, f } = command.params;
1515
const { state } = job;
16+
const { units } = state;
17+
const x = toMillimeters(command.params.x, units);
18+
const y = toMillimeters(command.params.y, units);
19+
const z = toMillimeters(command.params.z, units);
20+
const e = toMillimeters(command.params.e, units);
21+
const f = command.params.f;
1622

1723
// discard zero length moves
1824
if (x === undefined && y === undefined && z === undefined) {

src/interpreter/commands/probe.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toMillimeters } from '../../units';
12
import { PathType } from '../../path';
23
import type { CommandHandler } from '../../interpreter';
34

@@ -23,16 +24,17 @@ import type { CommandHandler } from '../../interpreter';
2324
*/
2425
export const probe: CommandHandler = (command, job) => {
2526
const { state } = job;
27+
const { units } = state;
2628
const { params } = command;
2729

2830
if (params.p !== undefined) {
2931
return;
3032
}
3133

3234
const { positionShift } = state;
33-
const x = params.x === undefined ? undefined : params.x + positionShift.x;
34-
const y = params.y === undefined ? undefined : params.y + positionShift.y;
35-
let z = params.z === undefined ? undefined : params.z + positionShift.z;
35+
const x = params.x === undefined ? undefined : toMillimeters(params.x, units)! + positionShift.x;
36+
const y = params.y === undefined ? undefined : toMillimeters(params.y, units)! + positionShift.y;
37+
let z = params.z === undefined ? undefined : toMillimeters(params.z, units)! + positionShift.z;
3638

3739
if (x === undefined && y === undefined && z === undefined) {
3840
return;

src/interpreter/commands/set-position.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toMillimeters } from '../../units';
12
import type { CommandHandler } from '../../interpreter';
23

34
/**
@@ -15,8 +16,12 @@ import type { CommandHandler } from '../../interpreter';
1516
* the axes.
1617
*/
1718
export const setPosition: CommandHandler = (command, job) => {
18-
const { x, y, z, e } = command.params;
1919
const { state } = job;
20+
const { units } = state;
21+
const x = toMillimeters(command.params.x, units);
22+
const y = toMillimeters(command.params.y, units);
23+
const z = toMillimeters(command.params.z, units);
24+
const e = toMillimeters(command.params.e, units);
2025
const { positionShift } = state;
2126
const physical = job.resolvePosition();
2227

src/state.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { Units } from './units';
66
* Tracks the current position, extrusion state, active tool, and units
77
*/
88
export class State {
9-
/** Current X position, or `undefined` until the axis is homed (G28) */
9+
/** Current X position in millimeters, or `undefined` until the axis is homed (G28) */
1010
x: number | undefined = undefined;
11-
/** Current Y position, or `undefined` until the axis is homed (G28) */
11+
/** Current Y position in millimeters, or `undefined` until the axis is homed (G28) */
1212
y: number | undefined = undefined;
13-
/** Current Z position, or `undefined` until the axis is homed (G28) */
13+
/** Current Z position in millimeters, or `undefined` until the axis is homed (G28) */
1414
z: number | undefined = undefined;
15-
/** Current extruder position, tracked by `applyExtrusion` and reset by G92 */
15+
/** Current extruder position in millimeters, tracked by `applyExtrusion` and reset by G92 */
1616
e = 0;
1717
/**
1818
* Whether E parameters are relative distances (M83) rather than absolute

src/units.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,17 @@ export type Units = 'mm' | 'in';
33

44
/** Millimeters per inch, for converting values from inch-based G-code */
55
export const MM_PER_INCH = 25.4;
6+
7+
/**
8+
* Converts a command distance to millimeters
9+
* @param value - A distance in the current units, or `undefined` when the command omits the word
10+
* @param units - The units the value is expressed in
11+
* @returns The distance in millimeters, or `undefined` for an omitted word
12+
* @remarks
13+
* Everything downstream of the interpreter works in millimeters; converting at
14+
* the command boundary keeps inch files (G20) from leaking their units into
15+
* the state, the paths or the rendered geometry.
16+
*/
17+
export function toMillimeters(value: number | undefined, units: Units): number | undefined {
18+
return value === undefined ? undefined : value * (units === 'in' ? MM_PER_INCH : 1);
19+
}

0 commit comments

Comments
 (0)