Cuts a model too big for the printer into pieces that fit the bed, places connectors and part numbers on the cut faces, and lists the pieces.
How the precision is kept
Planning is 64-bit: the grid of cuts, the sampled cut sections, connector sizes and the checks along each connector. The cutting and the connector solids are made by manifold-3d, which receives the corners as a Float32Array measured from the middle of the model and returns 32-bit corners, so every piece's geometry, size and file pass through 32-bit numbers. The code then moves corners that lie within 2e-4 mm of a slanting cut plane back onto it.
Open item. Read the pieces back from the boolean library in 64-bit, or build the cut faces from the 64-bit plane equations, so the snap back onto the plane is not needed.
Calculations under this policy
23 calculations in 2 source files. The code is read from the released app when this page loads. It is the calculation people run, not a copy kept for this page.
print-split.jsModelCraft 0.8.1 32-bit in part20 calculationsReviewed
Tests tools/modelcraft-test/split08.mjs
Fewest cells that fit the bed
64-bit
How many equal cells along x, y and z make every piece fit the printer bed, allowing a piece to lie on its side.
start: nₖ = ⌈sizeₖ ÷ largest bed side − 10⁻⁹⌉; cellₖ = sizeₖ ÷ nₖ (+ extra on a cut axis); fits if cellₖ ≤ bed_p(k) + 10⁻⁹
The fit test allows 1e-9 mm, an absolute tolerance that is not scaled to the model. Among grids with the same number of pieces the unturned order wins, then the one with more than 1e-9 mm extra room.
print-split.js
lines 77 to 105
ModelCraft 0.8.1
export function gridForBed(size, bed, { extra = 0, maxN = 32 } = {}) {
if (!bed || bed.length !== 3 || bed.some((b) => !(b > 0))) return null;
const big = Math.max(...bed);
const lo = size.map((s) => Math.max(1, Math.ceil(s / big - 1e-9)));
let best = null;
for (let nx = lo[0]; nx <= maxN; nx++) {
if (best && nx * lo[1] * lo[2] > best.pieces) break;
for (let ny = lo[1]; ny <= maxN; ny++) {
if (best && nx * ny * lo[2] > best.pieces) break;
for (let nz = lo[2]; nz <= maxN; nz++) {
const pieces = nx * ny * nz;
if (best && pieces > best.pieces) break;
const n = [nx, ny, nz], cell = size.map((s, k) => s / n[k]);
const ext = cell.map((c, k) => c + (n[k] > 1 ? extra : 0));
for (let pi = 0; pi < PERMS.length; pi++) {
const p = PERMS[pi];
if (!ext.every((e, k) => e <= bed[p[k]] + 1e-9)) continue;
const slack = Math.min(...ext.map((e, k) => bed[p[k]] - e));
// fewer pieces first; then no turning; then the most room left
const better = !best || pieces < best.pieces || (pieces === best.pieces && ((pi === 0) > (best.permIndex === 0) || ((pi === 0) === (best.permIndex === 0) && slack > best.slack + 1e-9)));
if (better) best = { counts: n, cell, pieces, perm: p.slice(), permIndex: pi, slack };
}
if (best && best.pieces === pieces) break;
}
}
}
if (best) delete best.permIndex;
return best;
}
Cut planes of the grid
64-bit
The planes that cut the model's box into the chosen number of cells.
d = lo + (hi − lo) × i ÷ n
Each position is computed directly from the box and the index, not by adding a step repeatedly, so rounding does not build up from one plane to the next.
print-split.js
lines 107 to 114
ModelCraft 0.8.1
export function gridPlanes(lo, hi, counts) {
const out = [];
for (let k = 0; k < 3; k++) for (let i = 1; i < counts[k]; i++) {
const n = [0, 0, 0]; n[k] = 1;
out.push({ n, d: lo[k] + (hi[k] - lo[k]) * i / counts[k], axis: k });
}
return out;
}
Planes and triangles about the model's middle
64-bit
Normalizes the given planes and moves planes and triangles to coordinates measured from the middle of the model's box.
n̂ = n ÷ |n|; d = d ÷ |n|; d_local = d − n̂·org; p_local = p − org
A plane with a zero normal has its length replaced by 1 instead of being rejected. The shift to the box's middle is done in 64-bit before anything is stored in 32-bit.
print-split.js
lines 357 to 365
ModelCraft 0.8.1
if (o.planes && o.planes.length) planes = o.planes.map((p) => { const n = norm(p.n); const l = Math.hypot(...p.n) || 1; return { n, d: p.d / l }; });
else if (o.bed) {
const g = gridForBed(size, o.bed, { extra: maleExtra });
if (!g) { const e = new Error('bed'); e.code = 'bed'; throw e; }
counts = g.counts;
planes = gridPlanes(lo, hi, counts);
} else planes = [];
const lp = planes.map((p) => ({ n: p.n, d: p.d - dot(p.n, org) })); // planes in local coordinates
const L = tris.map((tri) => tri.map((p) => [p[0] - org[0], p[1] - org[1], p[2] - org[2]]));
Model into the boolean library
32-bit in part
Hands the model's corners to manifold-3d, measured from the middle of its box.
q = p − org; corners welded where round(q × 10⁵) agree; stored as 32-bit
The corners are stored in a Float32Array. Corners whose coordinates round to the same 1e-5 mm cell are joined to the first one's position.
print-split.js
lines 118 to 133
ModelCraft 0.8.1
function toManifold(M, tris, o) {
const key = new Map(), verts = [], idx = [];
const vid = (p) => {
const q = [p[0] - o[0], p[1] - o[1], p[2] - o[2]];
const k = q.map((x) => Math.round(x * 1e5)).join(','); // 10 nm in mm
let i = key.get(k);
if (i === undefined) { i = verts.length / 3; key.set(k, i); verts.push(q[0], q[1], q[2]); }
return i;
};
for (const tri of tris) { const a = vid(tri[0]), b = vid(tri[1]), c = vid(tri[2]); if (a !== b && b !== c && a !== c) idx.push(a, b, c); }
const mesh = new M.Mesh({ numProp: 3, vertProperties: new Float32Array(verts), triVerts: new Uint32Array(idx) });
mesh.merge();
const man = new M.Manifold(mesh);
if (man.status() !== 'NoError' || man.isEmpty()) { man.delete?.(); const e = new Error('open'); e.code = 'open'; throw e; }
return man;
}
Pieces back from the boolean library
32-bit in part
Reads a piece's corners from manifold-3d and returns them to world coordinates.
world = org + 32-bit local corner
The library's vertProperties array is 32-bit; it is copied into a Float64Array and the model's middle is added in 64-bit. A 32-bit number between 128 and 256 has a spacing of about 1.5 × 10⁻⁵, so a corner that far from the middle, in millimeters, lies on a grid of about 15 nm.
print-split.js
lines 135 to 146
ModelCraft 0.8.1
function localMesh(man) {
const g = man.getMesh(), np = g.numProp, nv = g.vertProperties.length / np;
const positions = new Float64Array(nv * 3);
for (let i = 0; i < nv; i++) for (let k = 0; k < 3; k++) positions[i * 3 + k] = g.vertProperties[i * np + k];
return { positions, indices: new Uint32Array(g.triVerts) };
}
function worldTris(lm, o) {
const P = lm.positions, I = lm.indices, out = [];
const p = (i) => [P[i * 3] + o[0], P[i * 3 + 1] + o[1], P[i * 3 + 2] + o[2]];
for (let k = 0; k < I.length; k += 3) out.push([p(I[k]), p(I[k + 1]), p(I[k + 2])]);
return out;
}
Cutting by each plane
32-bit in part
Splits every cell by the next plane and keeps the parts that have volume.
The cut itself runs inside manifold-3d on the 32-bit corners it was given. This code keeps a part only when its volume, as the library reports it, is above 1e-9 mm³, an absolute threshold.
print-split.js
lines 373 to 383
ModelCraft 0.8.1
let cells = [{ m: O, sign: [] }];
for (const p of lp) {
const next = [];
for (const c of cells) {
const [above, below] = c.m.splitByPlane(p.n, p.d);
keep(above); keep(below);
if (!above.isEmpty() && above.volume() > 1e-9) next.push({ m: above, sign: [...c.sign, 1] });
if (!below.isEmpty() && below.volume() > 1e-9) next.push({ m: below, sign: [...c.sign, -1] });
}
cells = next;
}
Outline of a cut through the model
64-bit
The line segments where a plane crosses the model's triangles, in the plane's own two axes.
sᵢ = n·pᵢ − d; k = sᵢ ÷ (sᵢ − sⱼ); point = pᵢ + (pⱼ − pᵢ) × k
An edge is crossed only when its two ends have different signs, so sᵢ − sⱼ is never zero. A corner exactly on the plane counts as on the positive side.
print-split.js
lines 165 to 181
ModelCraft 0.8.1
function sectionSegments(L, n, d, B) {
const segs = [];
for (const tri of L) {
const s = tri.map((p) => dot(n, p) - d);
const pos = s.map((x) => x >= 0);
if (pos[0] === pos[1] && pos[1] === pos[2]) continue;
const pts = [];
for (let i = 0; i < 3; i++) {
const j = (i + 1) % 3;
if (pos[i] === pos[j]) continue;
const k = s[i] / (s[i] - s[j]), p = add(tri[i], [tri[j][0] - tri[i][0], tri[j][1] - tri[i][1], tri[j][2] - tri[i][2]], k);
pts.push(dot(p, B.U), dot(p, B.V));
}
if (pts.length === 4) segs.push(pts[0], pts[1], pts[2], pts[3]);
}
return segs;
}
Sampling a cut section
64-bit
For a grid of points over the cut: inside the model or not, and the distance to the outline.
step = max(largest extent ÷ samples, 0.2); inside = odd number of outline crossings to the left; distance = √(min squared distance to an outline segment)
Inside is decided at cell middles by sorting the crossings of each row. The nearest outline segment is exact in the cells the outline passes through and is then carried across the grid by two raster passes over the eight neighbors, so far from the outline the distance can be to a segment that is not the nearest one. The code's comment says the later 3D check measures connectors exactly.
print-split.js
lines 183 to 257
ModelCraft 0.8.1
/** Samples on a grid over the section: inside (crossings along each row) and distance to the outline (bucketed). */
function sampleSection(segs, samples) {
const ns = segs.length / 4;
if (!ns) return null;
let u0 = Infinity, v0 = Infinity, u1 = -Infinity, v1 = -Infinity;
for (let i = 0; i < segs.length; i += 2) { u0 = Math.min(u0, segs[i]); u1 = Math.max(u1, segs[i]); v0 = Math.min(v0, segs[i + 1]); v1 = Math.max(v1, segs[i + 1]); }
const step = Math.max(Math.max(u1 - u0, v1 - v0) / samples, 0.2);
const nu = Math.max(1, Math.ceil((u1 - u0) / step)), nv = Math.max(1, Math.ceil((v1 - v0) / step));
const inside = new Uint8Array(nu * nv), dist = new Float64Array(nu * nv);
const cu = (u) => Math.min(nu - 1, Math.max(0, Math.floor((u - u0) / step)));
const cv = (v) => Math.min(nv - 1, Math.max(0, Math.floor((v - v0) / step)));
const rows = Array.from({ length: nv }, () => []), cells = Array.from({ length: nu * nv }, () => null);
for (let s = 0; s < ns; s++) {
const a = segs[s * 4], b = segs[s * 4 + 1], c = segs[s * 4 + 2], e = segs[s * 4 + 3];
const j0 = cv(Math.min(b, e)), j1 = cv(Math.max(b, e)), i0 = cu(Math.min(a, c)), i1 = cu(Math.max(a, c));
for (let j = j0; j <= j1; j++) { rows[j].push(s); for (let i = i0; i <= i1; i++) (cells[j * nu + i] ||= []).push(s); }
}
const xs = [];
for (let j = 0; j < nv; j++) {
const v = v0 + (j + 0.5) * step;
xs.length = 0;
for (const s of rows[j]) {
const b = segs[s * 4 + 1], e = segs[s * 4 + 3];
if ((b > v) === (e > v)) continue;
const a = segs[s * 4], c = segs[s * 4 + 2];
xs.push(a + (v - b) * (c - a) / (e - b));
}
xs.sort((p, q) => p - q);
let k = 0;
for (let i = 0; i < nu; i++) {
const u = u0 + (i + 0.5) * step;
while (k < xs.length && xs[k] < u) k++;
inside[j * nu + i] = k & 1;
}
}
const segD2 = (s, u, v) => {
const a = segs[s * 4], b = segs[s * 4 + 1], dx = segs[s * 4 + 2] - a, dy = segs[s * 4 + 3] - b;
const l2 = dx * dx + dy * dy, tt = l2 ? Math.max(0, Math.min(1, ((u - a) * dx + (v - b) * dy) / l2)) : 0;
const x = a + dx * tt - u, y = b + dy * tt - v;
return x * x + y * y;
};
// nearest outline segment per sample: exact in the cells the outline crosses, then carried across the grid by two
// raster passes over the 8 neighbours (each candidate measured exactly); the 3D check measures connectors exactly anyway
const near = new Int32Array(nu * nv).fill(-1), best = new Float64Array(nu * nv).fill(Infinity);
const su = (i) => u0 + (i + 0.5) * step, sv = (j) => v0 + (j + 0.5) * step;
for (let j = 0; j < nv; j++) for (let i = 0; i < nu; i++) {
const list = cells[j * nu + i];
if (!list) continue;
// the segments of this cell and its neighbours, so a sample right beside the outline starts exact
for (let jj = Math.max(0, j - 1); jj <= Math.min(nv - 1, j + 1); jj++) for (let ii = Math.max(0, i - 1); ii <= Math.min(nu - 1, i + 1); ii++) {
const id = jj * nu + ii;
for (const sg of list) { const d2 = segD2(sg, su(ii), sv(jj)); if (d2 < best[id]) { best[id] = d2; near[id] = sg; } }
}
}
const relax = (i, j, di, dj) => {
const a = j + dj, b = i + di;
if (a < 0 || b < 0 || a >= nv || b >= nu) return;
const sg = near[a * nu + b], id = j * nu + i;
if (sg < 0 || sg === near[id]) return;
const d2 = segD2(sg, su(i), sv(j));
if (d2 < best[id]) { best[id] = d2; near[id] = sg; }
};
for (let pass = 0; pass < 2; pass++) {
for (let j = 0; j < nv; j++) {
for (let i = 0; i < nu; i++) { relax(i, j, -1, 0); relax(i, j, -1, -1); relax(i, j, 0, -1); relax(i, j, 1, -1); }
for (let i = nu - 1; i >= 0; i--) relax(i, j, 1, 0);
}
for (let j = nv - 1; j >= 0; j--) {
for (let i = nu - 1; i >= 0; i--) { relax(i, j, 1, 0); relax(i, j, 1, 1); relax(i, j, 0, 1); relax(i, j, -1, 1); }
for (let i = 0; i < nu; i++) relax(i, j, -1, 0);
}
}
for (let id = 0; id < nu * nv; id++) if (inside[id]) dist[id] = Math.sqrt(best[id]);
return { u0, v0, step, nu, nv, inside, dist };
}
Touching area of a joint
64-bit
The area where two pieces meet on a cut.
area += samples in the region × step²
The area is the number of grid samples in the region times the cell area, so it is accurate to about the outline's length times one step.
print-split.js
line 458
ModelCraft 0.8.1
J.area += reg.length * S.step * S.step;
Room a connector needs
64-bit
The least distance from a connector's axis to any outside surface, and how deep it goes each side.
peg: d ÷ 2 + c + w; key: (W ÷ 2)·√2 + c + w; dovetail: w₁ ÷ 2 + c + w with w₁ = W + 2H·tan(angle); dowel, magnet: d ÷ 2 + c + w
Closed-form sums of the options (c = clearance, w = wall). The key uses the square's half diagonal; the dovetail's wide end is computed with the 64-bit tangent of the angle.
print-split.js
lines 261 to 275
ModelCraft 0.8.1
/** What a connector needs: lateral = least distance from its axis to any outside (its size + clearance + wall). */
function connSpec(o) {
const c = o.clearance, w = o.wall;
switch (o.connector) {
case 'peg': return { kind: 'peg', lateral: o.pegD / 2 + c + w, depthA: 0, depthB: o.pegL + c };
case 'key': return { kind: 'key', lateral: o.keyW / 2 * Math.SQRT2 + c + w, depthA: 0, depthB: o.keyL + c };
case 'dovetail': {
const w1 = o.dovetailW + 2 * o.dovetailH * Math.tan(o.dovetailAngle * Math.PI / 180);
return { kind: 'dovetail', lateral: w1 / 2 + c + w, depthA: 0, depthB: o.dovetailH + c, w1 };
}
case 'dowel': return { kind: 'dowel', lateral: o.dowelD / 2 + c + w, depthA: o.dowelL / 2 + c, depthB: o.dowelL / 2 + c };
case 'magnet': return { kind: 'magnet', lateral: o.magnetD / 2 + c + w, depthA: o.magnetH + c, depthB: o.magnetH + c };
default: return null;
}
}
Round hole with its flats on the radius
64-bit
Sizes a faceted hole so its flat sides, not its corners, are at the asked radius.
corner radius = r ÷ cos(π ÷ segments)
Dividing by cos(π ÷ seg) makes the polygon circumscribe the circle of radius r, so the faceting does not reduce the clearance. With the default 32 segments the corner radius is about 0.48% larger than r.
print-split.js
lines 286 to 287
ModelCraft 0.8.1
/** A round hole whose flat sides (not its corners) lie at radius r, so the faceting never eats into the clearance. */
const hole = (M, r, z0, z1, seg) => cyl(M, r / Math.cos(Math.PI / seg), z0, z1, seg);
Connector and hole solids
64-bit
The peg, key, dovetail, dowel pin or magnet pocket and the matching hole, in the joint's own frame.
hole radius = r + c; hole depth = L + c; chamfer = min(0.5, r ÷ 3); dovetail half width at z: w₀ + (w₁ − w₀)·z ÷ H + growth
Sizes are 64-bit sums of the options. Each male part and hole starts 0.5 mm behind the cut face so the solids overlap the piece before the union. The solids are built by the library from these numbers.
print-split.js
lines 289 to 319
ModelCraft 0.8.1
/** The male part (on A, frame z = out of A into B) and the hole (in B; in A too for dowels and magnets), frame coordinates. */
function connSolids(M, o, spec, len) {
const c = o.clearance, seg = o.segments, root = 0.5, out = { male: null, holeB: null, holeA: null, pin: null };
if (spec.kind === 'peg') {
const r = o.pegD / 2, L = o.pegL, ch = Math.min(0.5, r / 3);
out.male = M.Manifold.union([cyl(M, r, -root, L - ch, seg), cyl(M, r, L - ch, L, seg, r - ch)]);
out.holeB = hole(M, r + c, -root, L + c, seg);
} else if (spec.kind === 'key') {
const h = o.keyW / 2, L = o.keyL;
out.male = boxHull(M, -h, h, -h, h, -root, L);
out.holeB = boxHull(M, -h - c, h + c, -h - c, h + c, -root, L + c);
} else if (spec.kind === 'dovetail') {
// runs along frame x over [len[0], len[1]]; narrow at the cut (root width W0), wide at the tip (W1)
const H = o.dovetailH, w0 = o.dovetailW / 2, w1 = spec.w1 / 2, [a, b] = len;
const tz = (z, w) => w0 + (w1 - w0) * (z / H) + w; // half width at height z, grown by w
const prism = (g, top) => M.Manifold.hull([a - g, b + g].flatMap((x) => [[x, -tz(-root, g), -root], [x, tz(-root, g), -root], [x, -tz(top, g), top], [x, tz(top, g), top]]));
out.male = prism(0, H);
out.holeB = prism(c, H + c);
} else if (spec.kind === 'dowel') {
const r = o.dowelD / 2, D = o.dowelL / 2 + c;
out.holeB = hole(M, r + c, -root, D, seg);
out.holeA = hole(M, r + c, -D, root, seg);
const L = o.dowelL - c, ch = Math.min(0.5, r / 3); // a printed pin, chamfered both ends
out.pin = M.Manifold.union([cyl(M, r - ch, 0, ch, seg, r), cyl(M, r, ch, L - ch, seg), cyl(M, r, L - ch, L, seg, r - ch)]);
} else if (spec.kind === 'magnet') {
const r = o.magnetD / 2 + c, D = o.magnetH + c;
out.holeB = hole(M, r, -root, D, seg);
out.holeA = hole(M, r, -D, root, seg);
}
return out;
}
Does a connector fit here
64-bit
Checks along a connector's depth that it stays inside the model with a wall all round and clear of the other cuts.
at steps of 0.75 mm along the depth: inside, nearest outside surface ≥ lateral, distance to every other cut ≥ lateral + deepest hole
The checks are made at points 0.75 mm apart along the axis and at 0.05 mm either side of the face, against the original mesh welded at 1e-6 mm. Inside means the nearest face along one fixed direction is met from behind. A narrowing of the model between two check points is not seen.
print-split.js
lines 465 to 480
ModelCraft 0.8.1
const fits3d = (x, n, others, sign, lat, dA, dB) => {
const need = lat;
const ok = (q) => {
if (!insideT(T0, q)) return false;
if (nearestDist(T0, q[0], q[1], q[2], need) < need) return false;
for (const p of others) if (sign[p.j] * (dot(p.n, q) - p.d) < need + Dmax) return false;
return true;
};
const stepT = 0.75;
for (const [dir, depth] of [[1, dB], [-1, dA]]) {
if (!depth) continue;
for (let tt = Math.min(0.1, depth); ; tt = Math.min(depth, tt + stepT)) { if (!ok(add(x, n, dir * tt))) return false; if (tt >= depth) break; }
}
// the face itself: the axis point a hair inside each side
return ok(add(x, n, 0.05)) && ok(add(x, n, -0.05));
};
Spreading connectors along a joint
64-bit
Picks up to k connector spots spread along the long axis of the touching area.
θ = ½·atan2(2·Σuv, Σu² − Σv²) about the mean; samples sorted by their position along that axis and cut into k slices
The long axis is the principal axis of the sample positions, from 64-bit sums of squares about the mean. Spots are grid samples; ties within 0.75 of a step go to the sample nearest the slice's middle, and a spot is refused within 2 × lateral of one already taken.
print-split.js
lines 616 to 656
ModelCraft 0.8.1
/**
* Up to k spots spread along the region's long axis: the region's good samples Q are cut into k slices by their position
* along the principal axis, and each slice gives its sample with the most room — room(id) = the distance to the outline or
* to another cut (less what that cut's holes need), whichever is nearer — (ties: the one nearest the slice's middle) that
* also passes the 3D check and stays 2·lateral from the spots already taken.
*/
function spreadPicks(Q, S, room, k, lat, check) {
const xy = (id) => { const i = id % S.nu; return [S.u0 + (i + 0.5) * S.step, S.v0 + ((id - i) / S.nu + 0.5) * S.step]; };
const P = Q.map(xy);
let mu = 0, mv = 0;
for (const [u, v] of P) { mu += u; mv += v; }
mu /= P.length; mv /= P.length;
let suu = 0, svv = 0, suv = 0;
for (const [u, v] of P) { suu += (u - mu) ** 2; svv += (v - mv) ** 2; suv += (u - mu) * (v - mv); }
const th = 0.5 * Math.atan2(2 * suv, suu - svv), ax = [Math.cos(th), Math.sin(th)];
const proj = P.map(([u, v]) => (u - mu) * ax[0] + (v - mv) * ax[1]);
const order = Q.map((id, i) => i).sort((a, b) => proj[a] - proj[b]);
const picks = [], pickXY = [];
for (let s = 0; s < k; s++) {
const slice = order.slice(Math.floor(order.length * s / k), Math.floor(order.length * (s + 1) / k));
if (!slice.length) continue;
let cu = 0, cv = 0;
for (const i of slice) { cu += P[i][0]; cv += P[i][1]; }
cu /= slice.length; cv /= slice.length;
// room inside the slice: an inner slice boundary counts as an edge, so each spot sits in the middle of its own share
const sLo = proj[slice[0]], sHi = proj[slice[slice.length - 1]];
const rs = (i) => Math.min(room(Q[i]), s > 0 ? proj[i] - sLo : Infinity, s < k - 1 ? sHi - proj[i] : Infinity);
const ranked = slice.slice().sort((a, b) => (rs(b) - rs(a)) || (Math.hypot(P[a][0] - cu, P[a][1] - cv) - Math.hypot(P[b][0] - cu, P[b][1] - cv)));
// within a step of the most room, nearest the slice's middle wins
const top = rs(ranked[0]);
const near = ranked.filter((i) => rs(i) >= top - S.step * 0.75).sort((a, b) => Math.hypot(P[a][0] - cu, P[a][1] - cv) - Math.hypot(P[b][0] - cu, P[b][1] - cv));
const tries = [...near, ...ranked.filter((i) => !near.includes(i))].slice(0, 400);
for (const i of tries) {
if (pickXY.some((q) => Math.hypot(q[0] - P[i][0], q[1] - P[i][1]) < 2 * lat)) continue;
if (!check(Q[i])) continue;
picks.push(Q[i]); pickXY.push(P[i]);
break;
}
}
return picks;
}
Length of a dovetail
64-bit
How far a dovetail runs each way from its spot, and whether each end is open.
Lengths are whole numbers of grid steps counted along the section's samples, so they are accurate to one step of the section grid.
print-split.js
lines 658 to 692
ModelCraft 0.8.1
/**
* A dovetail through sample id runs along U or V and must leave the model through its outside at one end at least (it
* slides in from there); an end that would meet another cut plane stops `lateral` short of where the samples stop being
* clear of that plane (a stopped dovetail). Both ends open is preferred, then the shorter run.
* → {dir 'u' | 'v', len [from, to] mm along it from the spot, open: 1 | 2} | null
*/
function dovetailRun(R, id, spec, o, Dmax) {
const { S, reg } = R;
const inReg = new Set(reg);
const i0 = id % S.nu, j0 = (id - i0) / S.nu;
const tries = [];
for (const dir of ['u', 'v']) {
const di = dir === 'u' ? 1 : 0, dj = dir === 'u' ? 0 : 1;
const ends = [];
let open = 0;
for (const sgn of [-1, 1]) {
let i = i0, j = j0, steps = 0, out = false;
for (;;) {
const ni = i + sgn * di, nj = j + sgn * dj;
const inGrid = ni >= 0 && nj >= 0 && ni < S.nu && nj < S.nv;
const nid = nj * S.nu + ni;
if (!inGrid || !S.inside[nid]) { out = true; break; } // leaves through the outside
if (!inReg.has(nid) || dPl0(R, nid) < spec.lateral + Dmax) break; // nears another cut plane
i = ni; j = nj; steps++;
}
if (out) { open++; ends.push(sgn * ((steps + 0.5) * S.step + o.wall + 1)); }
else ends.push(sgn * Math.max(0, steps * S.step - spec.lateral));
}
const span = ends[1] - ends[0];
if (open && span >= 2 * spec.lateral) tries.push({ dir, len: ends, open, span });
}
if (!tries.length) return null;
tries.sort((a, b) => b.open - a.open || a.span - b.span);
return tries[0];
}
Engraved joint number
64-bit
The joint's number as a solid: each stroke of the stroke font widened to a rectangle, joined and extruded.
stroke width = max(0.6, 0.14 × h); rectangle = segment grown by half the width on all sides
A stroke shorter than 1e-9 mm is given the direction [1, 0]. The joined outline is simplified by the library with a 0.01 mm tolerance before it is extruded, so the engraved outline can differ from the strokes by up to that much.
print-split.js
lines 321 to 342
ModelCraft 0.8.1
/**
* Text as one solid in frame coordinates: centred on the origin, reading along +x with +y up, z from z0 to z1. Each stroke
* is a rectangle with square ends; their outline is joined in 2D (manifold's CrossSection), cleaned of edges under 10 µm and
* extruded — a clean outline keeps the kernel's flat-face merge happy when the piece goes into the model.
*/
function textSolid(M, text, h, z0, z1) {
const sw = Math.max(0.6, h * 0.14), { lines, width } = textStrokes(text, { h });
const polys = [], ox = -width / 2, oy = -h / 2;
for (const l of lines) for (let i = 0; i + 1 < l.length; i++) {
const p = [l[i][0] + ox, l[i][1] + oy], q = [l[i + 1][0] + ox, l[i + 1][1] + oy];
let dx = q[0] - p[0], dy = q[1] - p[1];
const len = Math.hypot(dx, dy);
if (len < 1e-9) { dx = 1; dy = 0; } else { dx /= len; dy /= len; }
const r = sw / 2, nx = -dy * r, ny = dx * r, ex = dx * r, ey = dy * r;
polys.push([[p[0] - ex - nx, p[1] - ey - ny], [q[0] + ex - nx, q[1] + ey - ny], [q[0] + ex + nx, q[1] + ey + ny], [p[0] - ex + nx, p[1] - ey + ny]]);
}
if (!polys.length) return null;
const cs = M.CrossSection.ofPolygons(polys, 'NonZero'), clean = cs.simplify(0.01);
const m = M.Manifold.extrude(clean, z1 - z0).translate([0, 0, z0]);
cs.delete?.(); clean.delete?.();
return m;
}
Corners back onto slanting cut planes
32-bit in part
Moves each corner that lies within 2e-4 mm of a slanting cut plane, or of a connector or number depth parallel to it, exactly onto that plane.
s = n·p − d; if 0 < |s| < 2 × 10⁻⁴: p ← p − n·s
This corrects corners the library returned in 32-bit, which the code's comment says come back a few micrometers out of flat on a slanting plane. Only planes whose normal is not along an axis are snapped. The corrected corner is 64-bit, but its position within the plane is still the 32-bit one.
print-split.js
lines 562 to 578
ModelCraft 0.8.1
const offs = new Set([0]);
if (o.numbers) { offs.add(-o.textDepth); offs.add(o.textDepth); }
if (spec) {
const c = o.clearance;
for (const v of [spec.depthB, -spec.depthA, maleExtra, o.pegL - Math.min(0.5, o.pegD / 6), o.dovetailH + c]) if (v) offs.add(v);
}
const snaps = [];
for (const p of lp) if (Math.max(Math.abs(p.n[0]), Math.abs(p.n[1]), Math.abs(p.n[2])) < 1 - 1e-12) for (const v of offs) snaps.push({ n: p.n, d: p.d + v });
const snapMesh = (lm) => {
if (!snaps.length) return lm;
const P = lm.positions;
for (let i = 0; i < P.length; i += 3) for (const q of snaps) {
const s = q.n[0] * P[i] + q.n[1] * P[i + 1] + q.n[2] * P[i + 2] - q.d;
if (s && Math.abs(s) < SNAP) { P[i] -= q.n[0] * s; P[i + 1] -= q.n[1] * s; P[i + 2] -= q.n[2] * s; }
}
return lm;
};
Building and measuring each piece
32-bit in part
Adds the male connectors, cuts the holes and numbers, and returns each piece with its size, volume and print check.
piece = (cut part ∪ male parts) − holes − numbers; size = box of its corners; volume from the library
The piece's corners come back from the library in 32-bit and its size is the box of those corners. After connectors or numbers are added the library simplifies the piece with a 1e-3 mm tolerance. The volume is the library's own figure, taken before the snap onto the planes.
print-split.js
lines 579 to 591
ModelCraft 0.8.1
const outPieces = [];
for (const p of pieces) {
let m = p.m;
if (p.adds.length) m = keep(M.Manifold.union([m, ...p.adds]));
if (p.cuts.length) { const u = keep(M.Manifold.union(p.cuts)); m = keep(m.subtract(u)); }
if (p.adds.length || p.cuts.length) m = keep(m.simplify(SIMPLIFY)); // slivers from the number strokes' union go (≤ 1 µm moves)
const wt = worldTris(snapMesh(localMesh(m)), org);
const mesh = weld(meshFromTriangles(wt.map((q) => ({ p: q }))));
const check = printCheck(mesh, { bed: o.bed || null });
const bb = bbox(wt);
const fits = o.bed ? !!orientPerm(bb, o.bed) : true;
outPieces.push({ number: p.number, tris: wt, mesh, check, lo: bb.lo, hi: bb.hi, size: bb.hi.map((v, k) => v - bb.lo[k]), volume: m.volume(), fits, joints: p.joints });
}
Laying a piece on the bed
64-bit
Turns a piece in 90° steps so it fits the bed and moves it to stand on z = 0 at x, y ≥ 0.
new[k] = ±old[q(k)]; then new[k] −= min over the piece
The turn only swaps and negates coordinates, which is exact. Moving the piece subtracts its lowest coordinate on each axis, one rounding per number. The fit test allows 1e-6 mm, absolute.
print-split.js
lines 704 to 719
ModelCraft 0.8.1
/** The piece turned in 90° steps so it fits the bed (as it is when it already fits), moved to stand on z = 0 at x, y ≥ 0. */
export function orientForBed(mesh, bed) {
const P = mesh.positions, nv = P.length / 3;
const lo = [Infinity, Infinity, Infinity], hi = [-Infinity, -Infinity, -Infinity];
for (let i = 0; i < nv; i++) for (let k = 0; k < 3; k++) { const x = P[i * 3 + k]; if (x < lo[k]) lo[k] = x; if (x > hi[k]) hi[k] = x; }
const perm = bed ? orientPerm({ lo, hi }, bed) : null;
const p = perm || [0, 1, 2];
// old axis k becomes new axis p[k]: new[m] = sgn[m] · old[q[m]]; an odd order flips z so it stays a turn, not a mirror
const q = [0, 1, 2].map((m) => p.indexOf(m));
const sgn = [1, 1, parityOf(p)];
const out = new Float64Array(P.length);
const nlo = [Infinity, Infinity, Infinity];
for (let i = 0; i < nv; i++) for (let k = 0; k < 3; k++) { const v = sgn[k] * P[i * 3 + q[k]]; out[i * 3 + k] = v; if (v < nlo[k]) nlo[k] = v; }
for (let i = 0; i < nv; i++) for (let k = 0; k < 3; k++) out[i * 3 + k] -= nlo[k];
return { mesh: { positions: out, indices: mesh.indices, colors: mesh.colors || null }, perm: p, fits: !bed || !!perm };
}
Piece list on the assembly sheet
32-bit in part
One line per piece with its size and volume, then the joints and the pins.
The sizes and volumes listed come from the pieces read back in 32-bit and are rounded to one decimal for the list (0.1 mm and 0.1 cm³). The rounding is of the printed text only.
print-split.js
lines 781 to 791
ModelCraft 0.8.1
/** One line per piece, then per joint, then the pins — the list on the sheet and in assembly.txt. */
export function sheetRows(res) {
const rows = [];
for (const p of res.pieces) rows.push(t('sx.split.sheet.piece', { n: p.number, x: f1(p.size[0]), y: f1(p.size[1]), z: f1(p.size[2]), cm3: f1(p.volume / 1000) }));
for (const j of res.joints.slice().sort((a, b) => a.a - b.a || a.b - b.b)) {
const kinds = j.connectors.length ? t('sx.split.sheet.conn.' + j.connectors[0].kind, { n: j.connectors.length }) : t('sx.split.sheet.conn.none');
rows.push(t('sx.split.sheet.joint', { a: j.a, b: j.b, label: j.label, what: kinds }));
}
if (res.pins.length) rows.push(t('sx.split.sheet.pins', { n: res.pins.length, d: f1(res.opts.dowelD), l: f1(res.opts.dowelL - res.opts.clearance) }));
return rows;
}
The normal of a cut across one axis, turned by an angle about another axis.
n = axis·cos r + (about × axis)·sin r, r = degrees × π ÷ 180
The two vectors are unit axis vectors at right angles, so the result is a unit vector to within the rounding of cos and sin. At 0° the exact axis vector is returned without any trigonometry.
print-split-ui.js
lines 29 to 36
ModelCraft 0.8.1
/** The normal of a cut across `axis`, slanted `deg` degrees by turning it about the axis `about` (0 = straight across). */
export function slantNormal(axis, about, deg) {
const n = [0, 0, 0]; n[axis] = 1;
if (!deg || about === axis) return n;
const e = [0, 0, 0]; e[about] = 1;
const x = [e[1] * n[2] - e[2] * n[1], e[2] * n[0] - e[0] * n[2], e[0] * n[1] - e[1] * n[0]], r = (deg * Math.PI) / 180; // about × axis
return n.map((v, i) => v * Math.cos(r) + x[i] * Math.sin(r));
}
Cut places typed by the person
64-bit
Turns the typed distances from the low end of the model into cut planes.
point = middle of the box, with axis coordinate lo + v; d = n·point
Each typed value is read with Number and must be greater than 0 and less than the model's size on that axis; there is no tolerance at the ends. A slanting cut passes through the box's middle line at the typed place.
print-split-ui.js
lines 57 to 65
ModelCraft 0.8.1
/** The cut places for "At places I give": numbers in mm from the low end, inside the model. 0.8 round 3 (model_PART_3, SU.929): a cut may
* slant — turned `tilt` degrees about another axis (`tiltAbout`), through the model's middle line at that place; the core takes any plane. */
function planesFromText(lo, hi) {
const max = hi[s.axis] - lo[s.axis];
const vals = String(s.at).split(/[,;\s]+/).filter(Boolean).map((x) => Number(x.replace(',', '.')));
if (!vals.length || vals.some((v) => !Number.isFinite(v) || v <= 0 || v >= max)) return { bad: true, max };
const n = slantNormal(s.axis, s.tiltAbout, s.tilt), mid = [0, 1, 2].map((k) => (lo[k] + hi[k]) / 2);
return { planes: [...new Set(vals)].sort((a, b) => a - b).map((v) => { const p = mid.slice(); p[s.axis] = lo[s.axis] + v; return { n, d: n[0] * p[0] + n[1] * p[1] + n[2] * p[2] }; }) };
}
Connector spot picked by hand
64-bit
Where the ray under a click crosses the cut plane between two pieces.
t = (d − n·o) ÷ (n·dir); p = o + dir × t
The ray origin is converted from meters to millimeters by × 1000. A ray with |n·dir| < 1e-9 (absolute) is treated as parallel and skipped. The point is accepted when it lies within 0.5 mm of both pieces' boxes on the axes tested. The core checks the spot again before a connector is made.
print-split-ui.js
lines 160 to 178
ModelCraft 0.8.1
/** Where the click's ray crosses a cut plane inside the touching pieces: {label, plane, p (mm)} for the nearest, or null.
* `only` = a joint label to stay on (moving a connector keeps it on its own joint). */
function cutPointAt(res, x, y, only = null) {
const ray = ctx.cam.ray(x, y), o = ray.o.map((v) => v * MM);
let best = null;
for (const j of res.joints) {
if (only && j.label !== only) continue;
const pl = res.planes[j.plane], den = ray.d[0] * pl.n[0] + ray.d[1] * pl.n[1] + ray.d[2] * pl.n[2];
if (Math.abs(den) < 1e-9) continue;
const tt = (pl.d - (o[0] * pl.n[0] + o[1] * pl.n[1] + o[2] * pl.n[2])) / den;
if (!(tt > 0)) continue;
const p = [0, 1, 2].map((k) => o[k] + ray.d[k] * tt);
const A = res.pieces.find((q) => q.number === j.a), B = res.pieces.find((q) => q.number === j.b);
if (!A || !B) continue;
const inside = (q) => [0, 1, 2].every((k) => k === pl.n.indexOf(1) || (p[k] >= q.lo[k] - 0.5 && p[k] <= q.hi[k] + 0.5));
if (inside(A) && inside(B) && (!best || tt < best.tt)) best = { tt, label: j.label, plane: j.plane, p };
}
return best;
}
In detail
Method
Cuts with manifold-3d splitByPlane; each cut section sampled on a grid in its plane (inside by ray crossings, distance to the outline by a raster search); connector spots checked in 3D against the original mesh.
Number types
64-bit numbers and Float64Array for planning; Float32Array into and out of manifold-3d; millimeters.
Tolerances
Bed fit: + 1e-9 mm in the grid search, + 1e-6 mm when laying a piece. Weld into the library: 1e-5 mm. Parts with volume ≤ 1e-9 mm³ after a cut are dropped. Section grid step: largest extent ÷ samples, at least 0.2 mm. Snap back onto a cut plane: 2e-4 mm. Simplify after adding connectors and numbers: 1e-3 mm. Text outline simplify: 0.01 mm.
Limits
Piece corners have 32-bit precision relative to the model's middle. The touching area of a joint is a count of grid samples. Automatic connector spots are chosen from the sample grid, not from the exact outline.
Approach: 32-bit step, flagged
One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.