Resources Numerical accuracy Calculation policy

3D text

Makes letters as flat faces or as solids of a given depth from the stroke font and a pen width.

How the precision is kept

Every stroke becomes a thin rectangle and every joint a 12-sided disc, computed in 64-bit and joined into outlines by manifold-3d. Extruded text is read back from the library's mesh, which the code's comment says is 32-bit, and every coordinate is then rounded to a 1 µm grid before the faces are built.

Open item. Build the extruded letters from the 2D outline in 64-bit, or read the library's mesh back in 64-bit, so the corners do not pass through 32-bit numbers.

Calculations under this policy

2 calculations in 1 source file. 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.

text3d.js ModelCraft 0.8.1 32-bit in part 2 calculations Reviewed

Tests bin/test-modelcraft.mjs

Letter outlines from strokes

64-bit

The outline of the text: each stroke segment as a rectangle of the pen's width, each stroke point as a disc.

r = height × weight ÷ 2; rectangle corners = a ∓ r·n̂, b ∓ r·n̂; disc corner = c + r·[cos(i·π ÷ 6), sin(i·π ÷ 6)]; line k at y − 1.6·k·height

The polygons are built in 64-bit and handed to the library, which unions them and simplifies the outline with a tolerance of height × 1e-4. The discs are 12-sided with their corners at r, so between corners a joint is narrower than the pen by the factor cos(π ÷ 12).

  • text3d.js
  • lines 17 to 38
  • ModelCraft 0.8.1
export function textOutlines(M, text, { height = 1, weight = 0.14 } = {}) {
  const r = (height * weight) / 2, polys = [];
  const disc = (c) => Array.from({ length: 12 }, (_, i) => [c[0] + r * Math.cos((i * Math.PI) / 6), c[1] + r * Math.sin((i * Math.PI) / 6)]);
  String(text).split(/\r?\n/).slice(0, 50).forEach((line, k) => {
    const dy = -k * height * 1.6;
    for (const pl of textStrokes(line, { h: height }).lines) {
      const P = pl.map(([x, y]) => [x, y + dy]);
      for (const p of P) polys.push(disc(p));
      for (let i = 0; i + 1 < P.length; i++) {
        const [a, b] = [P[i], P[i + 1]], dx = b[0] - a[0], dyy = b[1] - a[1], L = Math.hypot(dx, dyy);
        if (L < 1e-12) continue;
        const nx = (-dyy / L) * r, ny = (dx / L) * r;
        // counter-clockwise, so the Positive fill rule unions everything
        polys.push([[a[0] - nx, a[1] - ny], [b[0] - nx, b[1] - ny], [b[0] + nx, b[1] + ny], [a[0] + nx, a[1] + ny]]);
      }
    }
  });
  const cs = new M.CrossSection(polys, 'Positive');
  const clean = cs.simplify(height * 1e-4);
  cs.delete?.();
  return clean;
}

Letters as faces or solids

32-bit in part

The component the app places: flat faces, or a closed solid from z = 0 to the depth.

g6(v) = round(v × 10⁶) ÷ 10⁶ on every coordinate

For extruded text the corners are read from the library's vertProperties array (32-bit) and rounded to six decimals; the code's comment gives this as the reason for the rounding. For flat text the outline loops are rounded the same way. The stored geometry is therefore on a grid of 1e-6 model units.

  • text3d.js
  • lines 40 to 66
  • ModelCraft 0.8.1
export function textDef(M, model, text, { height = 1, depth = 0, weight = 0.14, name = '' } = {}) {
  const cs = textOutlines(M, text, { height, weight });
  const made = [cs];
  const g6 = (v) => Math.round(v * 1e6) / 1e6;   // back from manifold's 32-bit floats, on a 1 µm grid
  try {
    const polys = [];
    if (depth > 0) {
      const man = M.Manifold.extrude(cs, depth);
      made.push(man);
      const g = man.getMesh();
      const P = (i) => [g6(g.vertProperties[i * g.numProp]), g6(g.vertProperties[i * g.numProp + 1]), g6(g.vertProperties[i * g.numProp + 2])];
      for (let i = 0; i < g.triVerts.length; i += 3) polys.push([P(g.triVerts[i]), P(g.triVerts[i + 1]), P(g.triVerts[i + 2])]);
    } else {
      // one face per letter piece, with its holes
      for (const piece of cs.decompose()) {
        made.push(piece);
        const loops = piece.toPolygons().map((l) => l.map(([x, y]) => [g6(x), g6(y), 0]));
        if (loops.length) polys.push({ loops });
      }
    }
    const d = meshDef(model, polys, model.uniqueName(name || String(text).split(/\r?\n/)[0].slice(0, 40) || 'Text'), 'component');   // i18n-ignore: a fallback name
    if (depth > 0) mergeCoplanar(d, [...d.edges].filter(([, e]) => e.soft).map(([id]) => id));
    return d;
  } finally {
    for (const m of made) m.delete?.();
  }
}

In detail

Method
2D union of rectangles and discs (manifold-3d CrossSection), simplified, then extruded by the library when a depth is given.
Number types
64-bit polygons in; 32-bit mesh corners out for extruded text; results rounded to 1e-6 model units.
Tolerances
Outline simplify: height × 1e-4. A stroke shorter than 1e-12 is skipped. Result grid: 1e-6.
Limits
Joint discs are 12-sided with corners on the pen radius. Extruded letters have 32-bit precision relative to the text's own origin before the rounding.

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