Goal: a machine.json whose machine coordinates are the DDCS machine coordinates, so that work offsets copy straight from the controller. You have no 3D models, so the meshes are boxes and cylinders made by a script. All numbers in the files below are example values; replace each with your measurement. Both example projects in this section load without errors and run their programs through the real loader and checks.
The demo machine (tools/gen-demo.ts, public/demo/machine.json) has the same structure; the example differs from it in one convention that makes measuring easier: the world origin is under the spindle at machine X0 Y0.
Step 1: choose the world frame
Put the world origin on the table top, directly below the spindle axis when the controller shows machine X0 Y0. X points right, Y away from you (towards the back), Z up. Then, for anything the spindle axis can be placed over, world x = machine X and world y = machine Y, which you can read off the controller.
Step 2: record the axes on the controller
Home the machine. For each axis:
- Note the machine coordinate the DDCS shows at home. It should be 0 (otherwise see the note below).
- Jog in the positive direction and note which way the spindle or gantry moves. In this frame, +X should move the spindle right, +Y the gantry towards the back, +Z the spindle up. If a positive jog moves the other way, negate that joint's
axisvector (for example[0, -1, 0]). - Jog carefully to both ends of travel and note the machine coordinates. Enter them as
limits(a little inside the hard stops). They must include 0.
If home is not machine 0 on your controller, the joint value 0 must still be the controller's machine 0, so the measurements below must be taken relative to machine 0, not to home. The simulator starts every program at machine 0 on all axes.
Step 3: measure the spindle nose height N
N is the height of the spindle nose above the table top when Z is at machine 0.
- Clamp a tool and measure its length L from the spindle nose to the tip.
- Jog Z down until the tip touches the table (paper test). Read machine Z, call it Zt (negative).
- N = L − Zt. Example: L = 75, Zt = −325 gives N = 400.
N goes into the Z joint origin of the Z head: [0, 0, N].
Step 4: describe the moving links
With every prismatic origin except the Z head's at [0, 0, 0], the gantry and X carriage frames coincide with the world frame at machine zero. Measure their parts with a tape measure relative to the spindle axis at X0 Y0 Z0 and enter them as boxes in world coordinates. The Z head frame sits at the spindle nose, so its shapes are measured from the nose: the spindle body is a cylinder from [0, 0, 0] upwards.
- Make every box a few millimetres larger than the real part, but keep at least 1 mm (better 10 mm) between a moving box and a table-side mesh it slides past. A gantry box that encloses the rails it rides on touches them at every pose and every move is reported as a collision (that is why the demo's gantry uprights start 10 mm above the rails).
- Include everything that can hit the work: spindle body, motor and bracket, the Z plate, the gantry beam underside, the uprights, dust shoe, coolant nozzles, cable chains near the table. What you leave out is not checked.
- Table-side links (table, rails, rotary housing, tailstock) need a mesh: a table-side link without a mesh is silently not checked.
Step 5: make the meshes
Save this as make-meshes.mjs in the project folder, edit SHAPES with your numbers, and run node make-meshes.mjs. It needs no other files. Coordinates are in each link's own frame (for base, tailstock, gantry, x_carriage: world coordinates at machine zero; for z_head: relative to the spindle nose; for a_chuck: relative to the rotary joint origin, x along the axis).
// Writes binary STL files built from boxes and cylinders. Edit SHAPES, then run: node make-meshes.mjs
// box: [[xmin, ymin, zmin], [xmax, ymax, zmax]]
// cylinder: { axis: 'x' | 'y' | 'z', base: [x, y, z] (centre of the first end face), length, radius } — runs from base along +axis
import { writeFileSync } from 'node:fs';
const SHAPES = {
'base.stl': [
{ box: [[-60, -80, -60], [660, 680, 0]] }, // table, top at z 0
{ box: [[-140, -100, -60], [-100, 760, 20]] }, // left rail
{ box: [[700, -100, -60], [740, 760, 20]] }, // right rail
{ box: [[-40, 400, 0], [30, 500, 130]] }, // rotary housing
],
'tailstock.stl': [{ box: [[480, 425, 0], [520, 475, 110]] }],
'a_chuck.stl': [{ cylinder: { axis: 'x', base: [0, 0, 0], length: 45, radius: 50 } }],
'gantry.stl': [
{ box: [[-140, 50, 30], [-100, 150, 560]] },
{ box: [[700, 50, 30], [740, 150, 560]] },
{ box: [[-140, 70, 460], [740, 150, 560]] },
],
'x_carriage.stl': [{ box: [[-60, 50, 300], [60, 70, 620]] }],
'z_head.stl': [
{ cylinder: { axis: 'z', base: [0, 0, 0], length: 250, radius: 40 } },
{ box: [[-45, 30, 60], [45, 50, 220]] },
],
'vise.stl': [
{ box: [[0, 0, 0], [160, 120, 70]] }, // body
{ box: [[0, 0, 70], [160, 20, 95]] }, // fixed jaw
{ box: [[0, 100, 70], [160, 120, 95]] }, // moving jaw
],
};
function boxTris([a, b]) {
const c = (i) => [i & 1 ? b[0] : a[0], i & 2 ? b[1] : a[1], i & 4 ? b[2] : a[2]];
return [[0, 4, 6, 2], [1, 3, 7, 5], [0, 1, 5, 4], [2, 6, 7, 3], [0, 2, 3, 1], [4, 5, 7, 6]]
.flatMap(([p, q, r, s]) => [c(p), c(q), c(r), c(p), c(r), c(s)]);
}
function cylinderTris({ axis, base, length, radius: r }, sides = 48) {
const k = 'xyz'.indexOf(axis), iu = (k + 1) % 3, iv = (k + 2) % 3;
const pt = (w, u, v) => { const p = [...base]; p[k] += w; p[iu] += u; p[iv] += v; return p; };
const R = r / Math.cos(Math.PI / sides); // circumscribed: the true cylinder is inside the mesh
const ring = (i, w) => pt(w, R * Math.cos((2 * Math.PI * i) / sides), R * Math.sin((2 * Math.PI * i) / sides));
const t = [];
for (let i = 0; i < sides; i++) {
t.push(ring(i, 0), ring(i + 1, 0), ring(i + 1, length), ring(i, 0), ring(i + 1, length), ring(i, length));
t.push(pt(0, 0, 0), ring(i + 1, 0), ring(i, 0), pt(length, 0, 0), ring(i, length), ring(i + 1, length));
}
return t;
}
for (const [file, shapes] of Object.entries(SHAPES)) {
const v = shapes.flatMap((s) => (s.box ? boxTris(s.box) : cylinderTris(s.cylinder)));
const buf = Buffer.alloc(84 + 50 * v.length / 3);
buf.writeUInt32LE(v.length / 3, 80);
v.flat().forEach((f, i) => buf.writeFloatLE(f, 84 + Math.floor(i / 9) * 50 + 12 + (i % 9) * 4));
writeFileSync(file, buf);
console.log(`${file}: ${v.length / 3} triangles`);
}
The tool-side meshes (gantry.stl, x_carriage.stl, z_head.stl) are only for display; keep them equal to the collision shapes in machine.json so that what you see is what is checked. Model the rotary chuck as the cylinder that encloses its jaws: it turns, so a round envelope is correct at every angle.
Step 6: measure the rotary axis
The rotary joint origin is a point on the rotation axis; in the example it is where the axis leaves the rotary housing, and the chuck mesh runs from there along +x. Measure with a straight ground pin of diameter d clamped in the chuck:
- Y of the axis: touch both sides of the pin with an edge finder (or indicate them), read machine Y each time, take the average. In this frame that is world y.
- Height of the axis: with the tool of length L from step 3, touch the top of the pin and read machine Z = Zp. Axis height z = N + Zp − L − d/2.
- X of the housing face (the origin's x): touch the face with an edge finder from the +X side and correct for the edge finder radius.
- Alignment: the example assumes the axis is exactly parallel to X. Indicate along the pin while jogging X. If it is noticeably off, you can enter the measured direction as the
axisvector (for example[-1, 0.002, 0]); the check accepts any rotary axis direction.
Enter origin: [x, y, z] and start with axis: [-1, 0, 0] (the ISO convention for a rotary table that carries the work: +A turns the tool right-handed about +X relative to the work, so the work turns the other way).
Step 7: check the A direction on the real machine
The sign of A is the one thing you cannot measure with a ruler. Check it before trusting any 4-axis result:
Put a mark (tape) on the top of the chuck. Spindle off, tool well clear.
Run this program (
programs/a90.ncin the example) on the controller, or the same move in MDI:(direction check: run this on the machine and in the simulator, compare which way the top of the chuck moves) G21 G90 G91 G0 A90 G90 M30With
axis: [-1, 0, 0], the simulator turns the top of the chuck away from you, towards the back of the machine (+Y): a point at the top of the chuck ends at the back, a point at the back ends at the bottom. Run the program in DryRun and watch the chuck to see it.If the real top of the chuck comes towards you, change the joint to
axis: [1, 0, 0]and reload. Whatever the axis orientation of your machine, the rule is the same: if the simulator and the machine turn opposite ways, negateaxis.
This only makes the simulator agree with the controller. Whether your CAM post-processor expects that direction is a separate question; if it does not, the toolpath on a rotary part comes out mirrored, which you will see in the viewport.
Step 8: the resulting machine.json
{
"name": "My gantry mill 600x600x300 + A",
"units": "mm",
"links": [
{ "name": "base", "mesh": "base.stl" },
{ "name": "tailstock", "parent": "base", "mesh": "tailstock.stl" },
{ "name": "gantry", "parent": "base", "mesh": "gantry.stl",
"joint": { "type": "prismatic", "letter": "Y", "axis": [0, 1, 0], "origin": [0, 0, 0], "limits": [0, 600] },
"collision": [
{ "box": { "min": [-140, 50, 30], "max": [-100, 150, 560] } },
{ "box": { "min": [700, 50, 30], "max": [740, 150, 560] } },
{ "box": { "min": [-140, 70, 460], "max": [740, 150, 560] } }
] },
{ "name": "x_carriage", "parent": "gantry", "mesh": "x_carriage.stl",
"joint": { "type": "prismatic", "letter": "X", "axis": [1, 0, 0], "origin": [0, 0, 0], "limits": [0, 600] },
"collision": [ { "box": { "min": [-60, 50, 300], "max": [60, 70, 620] } } ] },
{ "name": "z_head", "parent": "x_carriage", "mesh": "z_head.stl",
"joint": { "type": "prismatic", "letter": "Z", "axis": [0, 0, 1], "origin": [0, 0, 400], "limits": [-300, 0] },
"collision": [
{ "cylinder": { "from": [0, 0, 0], "to": [0, 0, 250], "radius": 40 } },
{ "box": { "min": [-45, 30, 60], "max": [45, 50, 220] } }
] },
{ "name": "a_chuck", "parent": "base", "mesh": "a_chuck.stl",
"joint": { "type": "revolute", "letter": "A", "axis": [-1, 0, 0], "origin": [30, 450, 80], "limits": null } }
],
"toolMount": { "link": "z_head", "origin": [0, 0, 0], "direction": [0, 0, -1] },
"workMounts": [ { "name": "table", "link": "base" }, { "name": "rotary", "link": "a_chuck" } ],
"controller": {
"dialect": "ddcs-v4.1",
"ignoreProgramFeed": true,
"defaultFeed": 3000,
"rapidFeed": 3000,
"rotaryWrap": false,
"rotaryFeedMode": "combined",
"dwellPUnits": "ms",
"rapidMode": "interpolated"
}
}
Set the controller values from your controller's parameter pages: #101, #102, #103, #120 and #036 (section 4.2). The values above are the factory defaults from the manual.
Step 9: work offsets and tool lengths
Copy the G54–G59 values from the controller's offset screen into workOffsets. They mean the same thing in DryRun if steps 2–3 were done (joint value 0 = machine 0, same positive directions) and the tool lengths match how your programs use H:
| How you set up tools on the DDCS | What to enter in DryRun |
|---|---|
Programs use G43 H<n>, and the DDCS H table holds each tool's length from the spindle nose. |
length = the H value; G54 Z copied unchanged. |
Programs use G43 H<n>, and H values are relative to a master tool (master = 0). |
length = the real length from the spindle nose; G54 Z = the controller's G54 Z − the master tool's length. |
No G43 (or H0); each tool is touched off by setting G54 Z. |
length = real length; G54 Z copied unchanged. This is right only for the tool G54 Z was set with. |
In the example frame: G54 X and Y are the machine X and Y of the work zero, and G54 Z = (height of work Z0 above the table) − N when G43 is used with lengths from the spindle nose. The example's work zero is on the A axis (z = 80) at the free end of the stock: G54 Z = 80 − 400 = −320.
Doing it in the app (the Project tab → Work zero, section 4.5.6). Worked example 1, on the demo (/app?demo=demo): the stock is the box 100×60×40 on the vise at table position [220, 270, 60]; the program part.nc uses G54 with T1 (length 50) and G43 H1; the zero is the stock's top front-left corner.
- Open the Project tab → Program
(the work zero). The top line reads G54 — used by the program, T1, G43 H1 (50 mm). - Point on the stock:
Front-left corner, topis selected — Xmin, Ymin, Zmax, the point (0, 0, 40) of the stock. The block ENTER ON YOUR CONTROLLER readsG54 X 220.000 Y 270.000 Z -200.000 A 0.000, and under itMachine position of work zero with T1: X 220.000 Y 270.000 Z -150.000 A 0.000. The tool tip is then at world (220, 270, 100), on the stock top (60 + 40).Front-left corner, bottomwould move G54 Z by −40. - The same zero "Like on the machine": choose that way; the jog starts at the zero with T1 (X 220, Y 270, Z −150, the tip on the top corner in the viewport). Jog away and back with the ±1/±10 buttons — or
+10twice on X to put the zero 20 mm further along, X 240 — then press Set zero here (X0 Y0 Z0). At the corner G54 stays X 220 Y 270 Z −200: Z −150 minus the 50 mm of G43 H1. - On the controller, enter the G54 line; with T1 at the corner, the controller's machine position must read the machine-position line. Without G43 in the program, G54 Z would be −150 instead.
Two checks that catch most mistakes:
- Touch the real tool on the real stock top and note machine Z. In DryRun, seek to a point in the program where that tool is at the same machine Z (or write a one-line program
G53 G0 Z<value>): the tip must sit on the modelled stock top. If not, N, the tool length or the stock position is wrong. - Run a program in air on the machine, single block, and compare the controller's machine and work coordinates with DryRun's readout at the same line.
Step 10: the job (setup.json) and a first run
Rotary job, stock Ø40 × 150 held 20 mm from the housing face (it overlaps the chuck; table-side bodies are not checked against each other). The stock gets the default resolution, 0.15 mm for this size; add "resolution": 0.2 to the stock object to choose one (section 8):
{
"machine": "machine.json",
"stock": { "cylinder": { "diameter": 40, "length": 150 }, "mount": "rotary", "position": [20, 0, 0] },
"workOffsets": {
"G54": { "X": 200, "Y": 450, "Z": -320, "A": 0 }
},
"tools": [
{ "number": 1, "type": "flat", "diameter": 6, "fluteLength": 20, "length": 75,
"holder": [ { "diameter": 35, "length": 40 } ] },
{ "number": 2, "type": "ball", "diameter": 6, "fluteLength": 15, "length": 80, "shankDiameter": 6,
"holder": [ { "diameter": 35, "length": 40 } ] }
],
"program": "programs/wrap.nc"
}
programs/wrap.nc:
(wrap test: Ø40 cylinder on A, work zero on the axis at the free end of the stock)
G21 G90 G94 G17
G54
M6 T1
G43 H1
G0 Z40
G0 X-10 Y0 A0
G1 Z18 F300
G1 X-100 A360 F800
G0 Z40
G0 A0
M30
Result in the example: one warning (the controller ignores programmed F (#101 = default speed); feeds use defaultFeed 3000 mm/min), No collisions found, and the stock status Stock 0.15 mm (default) · removed ≈ 1.85 of 189 cm³ with a helical groove 2 mm deep around the cylinder. programs/a90.nc gives the warning no tool change in program — T1 from setup.json assumed and no collision.
A job on the table uses its own project folder. This one keeps the machine in a sub-folder, so the link meshes are found next to machine/machine.json (mymill-vise/machine/*.stl) while vise.stl and part.nc sit next to setup.json:
{
"machine": "machine/machine.json",
"fixture": { "mesh": "vise.stl", "mount": "table", "position": [220, 240, 0], "rotation": [0, 0, 0] },
"stock": { "box": [100, 80, 30], "mount": "table", "position": [250, 260, 80] },
"workOffsets": {
"G54": { "X": 250, "Y": 260, "Z": -290 }
},
"tools": [
{ "number": 1, "type": "flat", "diameter": 6, "fluteLength": 20, "length": 75,
"holder": [ { "diameter": 35, "length": 40 } ] }
],
"program": "part.nc"
}
Here the stock's minimum corner is at machine X 250, Y 260 (measured with an edge finder), its top at z 110, so G54 Z = 110 − 400 = −290.
Open the folder with "Open folder…", fix any load errors (section 9), then:
- Look at the machine from all sides. Every table-side part must appear where it is on the real machine.
- Play
sweep-style moves at a safe height across the full travel (for exampleG53 G0 X600,G53 Y600,G53 X0,G53 Y0afterM6 T1) and confirm no collision is reported. A collision at a pose that is clear on the real machine means a shape is too big or misplaced. - Run the direction check of step 7.
- Only then load real programs.
The same for a GRBL router
The steps above hold for a GRBL machine (Shapeoko, X-Carve, 3018, LEAD, LongMill), with four differences. The catalogue's GRBL routers (section 4.2) are built this way; copying one of their folders is the quickest start.
- Limits from
$130–$132. After homing, GRBL counts every axis from minus its travel to 0, with 0 at the positive end (back, right, top on most routers). Type$$in your sender and read$130,$131and$132(max travel, mm). Enter"limits": [-<$130>, 0]on X,[-<$131>, 0]on Y and[-<$132>, 0]on Z, and put each joint'soriginwhere its carriage is at that positive end. In step 1 the world origin is then under the spindle at machine X0 Y0, the back-right corner, and x and y of the table are negative machine values. Set"dialect": "grbl-1.1"(or"grblhal") incontroller, and leave out the DDCS switches (#101, #102, #120 have no GRBL equivalent; the loader names any that are left). - The homing corner. Home (
$H), then send?: theMPosit reports is where every job starts. Enter it ascontroller.home, for example[-5, -5, -5]for a back-right homing machine with a 5 mm pull-off, or about[-<$130> + 5, -<$131> + 5, -5]for a front-left one.$23says which corner the switches are in. Enter$20ascontroller.softLimits, and the[G28:…]and[G30:…]lines of$#asg28andg30(they are 0 unless you stored others withG28.1/G30.1). - The touch-off. A GRBL router has no tool table: you touch off Z with each tool on the work zero (touch plate, BitSetter, tool-length sensor).
"toolChange": "touch-off"(the GRBL default) models that, so each tool'slengthplaces the holder and the spindle for the collision check but does not change the depths. Step 9's table does not apply. Set the zero in the Project tab → Work zero (section 4.5.6): the block shows the G54 Z the controller reads after touching off with the main program's tool, for exampleG54 X -738.000 Y -738.000 Z -64.000 (after touching off T1). To copy a zero from$#instead: G54 X and Y unchanged, and G54 Z insetup.json= the controller's G54 Z − thelengthof the tool you touched off with (the GRBL demo: −64 − 40 = −104). - A spoilboard. Model the board the stock lies on as its own table-side link with a mesh and
"spoilboard": 1.5(section 4.2), and point thetablework mount at it, so a through-cut that goes a little into it is not a collision (section 7.4).
The first run is the same as above: look at the machine from all sides, move along the travel in air with G53 G0 (on GRBL with negative values, for example G53 G0 X-700), and check that the readout's machine coordinates match the sender's MPos at the same line.