Resources Numerical accuracy Calculation policy

Splitting a model into printable pieces

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.js ModelCraft 0.8.1 32-bit in part 20 calculations Reviewed

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.

open end: (steps + ½) × step + wall + 1; stopped end: max(0, steps × step − lateral)

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.

size shown = round(mm × 10) ÷ 10; volume shown = round(mm³ ÷ 1000 × 10) ÷ 10 cm³; pin length = dowel length − clearance

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;
}
print-split-ui.js ModelCraft 0.8.1 64-bit 3 calculations Reviewed

Tests None found by the review

Normal of a slanting cut

64-bit

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.

Every policy that takes this approach