Resources Numerical accuracy Calculation policy

Flat views of 3D solids

Projects the 3D model onto a view plane, splits its edges into visible and hidden parts, and places the result as 2D lines at a drawing scale.

How the precision is kept

Vertices are projected with dot products in 64-bit. Whether a point of an edge is hidden is tested at up to 400 samples per edge against the model's triangles, and each change between visible and hidden is located by bisection to one millionth of the view's diagonal. Pieces on the same line are then merged with a key that rounds the line's angle to 1e-6 radians and its offset to 20 millionths of the diagonal, and the output segments are rebuilt from the merged line. The lines are a snapshot, not linked to the solids.

Open item. Write each merged segment from its own end points, or narrow the merge key, so output lines are not moved by up to 0.00002 of the view's diagonal.

Calculations under this policy

10 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.

flatview.js CadCraft 0.8.2 64-bit 10 calculations Reviewed

Tests bin/test-cadcraft.mjs

View frames

64-bit

The right, up and toward-the-viewer directions of the seven standard views.

iso: u = (1, 1, 0) ÷ √2; v = (−1, 1, 2) ÷ √6; w = (1, −1, 1) ÷ √3

The six flat views use exact 0 and ±1. The isometric frame uses Math.SQRT1_2, 1 ÷ Math.sqrt(3) and 1 ÷ Math.sqrt(6), so its vectors are unit length and perpendicular to 64-bit rounding.

  • flatview.js
  • lines 24 to 34
  • CadCraft 0.8.2
const S2 = Math.SQRT1_2, S3 = 1 / Math.sqrt(3), S6 = 1 / Math.sqrt(6);
/** View frames: u (to the right on paper), v (up on paper), w (toward the viewer); u × v = w. */
export const VIEWS = {
  top: { u: [1, 0, 0], v: [0, 1, 0], w: [0, 0, 1] },
  bottom: { u: [1, 0, 0], v: [0, -1, 0], w: [0, 0, -1] },
  front: { u: [1, 0, 0], v: [0, 0, 1], w: [0, -1, 0] },
  back: { u: [-1, 0, 0], v: [0, 0, 1], w: [0, 1, 0] },
  right: { u: [0, 1, 0], v: [0, 0, 1], w: [1, 0, 0] },
  left: { u: [0, -1, 0], v: [0, 0, 1], w: [-1, 0, 0] },
  iso: { u: [S2, S2, 0], v: [-S6, S6, 2 * S6], w: [S3, -S3, S3] },
};

Edges and triangles in the view's frame

64-bit

Every vertex of the model as (right, up, depth) in the view, and which soft edges are outlines from this side.

q = (p · u, p · v, p · w); soft edge kept when its two faces face opposite ways

Each vertex is placed by its instance matrix and then three dot products. A soft edge is dropped when both faces face the same way, or when both facing values are below 1e-9 in magnitude (absolute).

  • flatview.js
  • lines 38 to 69
  • CadCraft 0.8.2
/** Every edge and triangle of the model in the view's frame (u, v, depth toward the viewer). */
function collect(model, V) {
  const tris = [], edges = [];
  model.walk((def, m) => {
    if (def === model.root && !def.faces.size && !def.edges.size) return;
    const P = new Map();
    const at = (vid) => { let q = P.get(vid); if (!q) { const p = apply(m, def.verts.get(vid)); q = [dot(p, V.u), dot(p, V.v), dot(p, V.w)]; P.set(vid, q); } return q; };
    const facing = new Map();   // face id → its normal · w (toward the viewer > 0), in the world
    const byEdge = new Map();
    for (const [fid, f] of def.faces) {
      for (const tr of faceTris(def, f)) tris.push(tr.map(at));
      const n = faceNormal(def, f), o = apply(m, [0, 0, 0]), e = apply(m, n);
      facing.set(fid, dot([e[0] - o[0], e[1] - o[1], e[2] - o[2]], V.w));
      for (const l of f.loops) for (let i = 0; i < l.length; i++) {
        const k = edgeKey(l[i], l[(i + 1) % l.length]);
        if (!byEdge.has(k)) byEdge.set(k, []);
        byEdge.get(k).push(fid);
      }
    }
    for (const e of def.edges.values()) {
      if (e.soft) {
        const fs = byEdge.get(edgeKey(e.a, e.b)) || [];
        if (fs.length === 2) {
          const a = facing.get(fs[0]), b = facing.get(fs[1]);
          if (a * b > 0 || (Math.abs(a) < 1e-9 && Math.abs(b) < 1e-9)) continue;   // not the outline from here
        }
      }
      edges.push([at(e.a), at(e.b)]);
    }
  });
  return { tris, edges };
}

Working tolerance

64-bit

The tolerance every later step uses, from the size of the projected model.

diag = √(Δu² + Δv²); tol = diag × 1e-6

The tolerance scales with the view, so the result does not depend on the model's units or size. All lengths in the output are resolved to this one millionth of the diagonal at best.

  • flatview.js
  • lines 79 to 80
  • CadCraft 0.8.2
const diag = Math.hypot(box.u1 - box.u0, box.v1 - box.v0) || 1;
const tol = diag * 1e-6;

Is a point hidden

64-bit

Says whether a point of an edge lies behind some triangle of the model.

l₁, l₂ from signed areas ÷ triangle area; l₃ = 1 − l₁ − l₂; hidden when all ≥ 1e-9 and l₁ z_a + l₂ z_b + l₃ z_c > depth + tol

Barycentric coordinates are differences of products divided by the triangle's signed area; triangles with area below 1e-12 × diagonal² were left out, which bounds the division. The 1e-9 margin keeps a point on a triangle's own edge from being hidden by it.

  • flatview.js
  • lines 95 to 105
  • CadCraft 0.8.2
const behind = (u, v, d) => {
  for (const i of grid[cell(v, box.v0, ch, G) * G + cell(u, box.u0, cw, G)]) {
    const { a, b, c, area } = T[i];
    const l1 = ((b[0] - u) * (c[1] - v) - (c[0] - u) * (b[1] - v)) / area;
    const l2 = ((c[0] - u) * (a[1] - v) - (a[0] - u) * (c[1] - v)) / area;
    const l3 = 1 - l1 - l2;
    if (l1 < 1e-9 || l2 < 1e-9 || l3 < 1e-9) continue;
    if (l1 * a[2] + l2 * b[2] + l3 * c[2] > d + tol) return true;
  }
  return false;
};

Visible and hidden runs of an edge

64-bit

Splits an edge into stretches that are visible and stretches that are hidden.

n = clamp(⌈L ÷ (diag ÷ 300)⌉, 1, 400) samples; each change bisected until (hi − lo) × L ≤ tol

Visibility is tested at the middle of n equal intervals. Where two neighboring samples differ, the change is found by halving, so its position is good to tol. A stretch that begins and ends between two samples is not seen.

  • flatview.js
  • lines 112 to 123
  • CadCraft 0.8.2
const n = Math.max(1, Math.min(400, Math.ceil(L / (diag / 300))));
const runs = [];
let s0 = 0, prev = hid(0.5 / n);
for (let i = 1; i < n; i++) {
  const cur = hid((i + 0.5) / n);
  if (cur === prev) continue;
  let lo = (i - 0.5) / n, hi = (i + 0.5) / n;   // the change lies between the two samples
  while ((hi - lo) * L > tol) { const mid = (lo + hi) / 2; if (hid(mid) === prev) lo = mid; else hi = mid; }
  runs.push([s0, (lo + hi) / 2, prev]);
  s0 = (lo + hi) / 2; prev = cur;
}
runs.push([s0, 1, prev]);

Merging pieces on one line

64-bit

Groups segments that lie on the same line and joins their overlapping spans.

key = round(atan2(dy, dx) × 1e6) and round(offset ÷ (20 × tol)); spans joined when the gap ≤ tol

Two segments share a line when their angles round to the same millionth of a radian and their offsets from the origin fall in the same bucket of width 20 × tol. Segments just either side of a bucket boundary are not merged, and segments up to 20 × tol apart are. The group keeps the direction and offset of its first segment.

  • flatview.js
  • lines 138 to 161
  • CadCraft 0.8.2
/** Segments grouped by the line they lie on → Map key → {o, d, n, spans: [[t0, t1]…] merged}. */
function merge(segs, tol) {
  const lines = new Map();
  for (const [x1, y1, x2, y2] of segs) {
    let dx = x2 - x1, dy = y2 - y1;
    const L = Math.hypot(dx, dy);
    if (L < tol) continue;
    dx /= L; dy /= L;
    if (dx < -1e-12 || (Math.abs(dx) <= 1e-12 && dy < 0)) { dx = -dx; dy = -dy; }
    const off = x1 * -dy + y1 * dx;   // distance of the line from the origin
    const k = Math.round(Math.atan2(dy, dx) * 1e6) + ':' + Math.round(off / (tol * 20));
    let ln = lines.get(k);
    if (!ln) lines.set(k, (ln = { dx, dy, off, spans: [] }));
    const a = x1 * dx + y1 * dy, b = x2 * dx + y2 * dy;
    ln.spans.push([Math.min(a, b), Math.max(a, b)]);
  }
  for (const ln of lines.values()) {
    ln.spans.sort((p, q) => p[0] - q[0]);
    const m = [];
    for (const sp of ln.spans) { const last = m[m.length - 1]; if (last && sp[0] <= last[1] + tol) last[1] = Math.max(last[1], sp[1]); else m.push([...sp]); }
    ln.spans = m;
  }
  return lines;
}

Merged lines back to segments

64-bit

Writes each merged span as a segment, and removes from hidden lines what a visible line covers.

point = offset × (−dy, dx) + t × (dx, dy)

End points are rebuilt from the group's direction, offset and span, not copied from the input. A segment that joined a group through the key is therefore moved onto the first segment's line, by up to 20 × tol (0.00002 of the diagonal).

  • flatview.js
  • lines 162 to 176
  • CadCraft 0.8.2
/** Merged lines → segments; `minus` (merged visible lines) takes away what it covers on the same line. */
function flatten(lines, minus = null) {
  const out = [];
  for (const [k, ln] of lines) {
    let spans = ln.spans;
    const cut = minus?.get(k)?.spans;
    if (cut) spans = spans.flatMap(([a, b]) => { let parts = [[a, b]]; for (const [c, d] of cut) parts = parts.flatMap(([p, q]) => (d <= p || c >= q ? [[p, q]] : [...(c > p ? [[p, c]] : []), ...(d < q ? [[d, q]] : [])])); return parts; });
    for (const [a, b] of spans) {
      if (b - a <= 0) continue;
      const px = -ln.dy * ln.off, py = ln.dx * ln.off;
      out.push([px + ln.dx * a, py + ln.dy * a, px + ln.dx * b, py + ln.dy * b]);
    }
  }
  return out;
}

Scale from text

64-bit

Reads a scale typed as 1:50, 2:1, 1/20 or 0.5.

a:b → a ÷ b; accepted when 0 < scale < 1e6

One 64-bit division of the two parsed numbers; 1:50 is the nearest 64-bit number to 0.02.

  • flatview.js
  • lines 179 to 184
  • CadCraft 0.8.2
/** '1:50' → 0.02, '2:1' → 2, '1/20' → 0.05, '0.5' → 0.5; null when it is not a scale. */
export function parseScale(s) {
  const m = /^\s*(\d+(?:\.\d+)?)\s*[:/]\s*(\d+(?:\.\d+)?)\s*$/.exec(String(s ?? ''));
  const v = m ? +m[1] / +m[2] : Number(String(s ?? '').trim());
  return Number.isFinite(v) && v > 0 && v < 1e6 ? v : null;
}

Paper units per meter and automatic scale

64-bit

How many drawing or paper units one meter of the model is, and the largest standard scale that fits the sheet.

inch sheet: 1 ÷ 0.0254; mm sheet: 1000; model: 1 ÷ meters per unit; fit: size × units per meter × scale ≤ 0.45 × paper size

0.0254 is the exact inch in meters; its reciprocal rounds once. The automatic scale is the first of a fixed list of standard scales that fits within 45 percent of the paper's width and height.

  • flatview.js
  • lines 205 to 212
  • CadCraft 0.8.2
const L = doc.layout, k = mPerUnit(doc.meta.units);
const paperPerM = L ? (L.paper?.unit === 'in' ? 1 / 0.0254 : 1000) : 1 / k;
const wM = pr.box.u1 - pr.box.u0, hM = pr.box.v1 - pr.box.v0;
let scale = 1;
if (L) {
  const pw = L.paper?.w || 420, ph = L.paper?.h || 297;
  scale = STD.find((s) => wM * paperPerM * s <= pw * 0.45 && hM * paperPerM * s <= ph * 0.45) || STD[STD.length - 1];
}

View lines in drawing units

64-bit

The 2D lines of the view, centered on the block's base point and scaled.

x = (u − u_center) × f; y = (v − v_center) × f; f = units per meter × scale

The center of the view's box is subtracted before the scale is applied, so the lines are small offsets about the block's origin. One subtraction and one multiplication per coordinate.

  • flatview.js
  • lines 221 to 222
  • CadCraft 0.8.2
const f = paperPerM * scale, cu = (pr.box.u0 + pr.box.u1) / 2, cv = (pr.box.v0 + pr.box.v1) / 2;
const line = (layer) => ([u1, v1, u2, v2]) => ({ type: 'line', layer, x1: (u1 - cu) * f, y1: (v1 - cv) * f, x2: (u2 - cu) * f, y2: (v2 - cv) * f });

In detail

Method
Orthographic projection; sampled visibility against triangles through a 96 × 96 grid; bisection of each change; collinear merge.
Number types
64-bit numbers throughout.
Tolerances
tol = 1e-6 × the view's diagonal. Triangles with projected area below 1e-12 × diagonal² are ignored. A point is inside a triangle when each barycentric coordinate is at least 1e-9. Pieces shorter than 10 × tol are dropped. Merge key: angle to 1e-6 radians, offset to 20 × tol.
Limits
A hidden or visible stretch shorter than the sampling interval (at most 1/300 of the diagonal) can be missed. Merged segments can be moved by up to 20 × tol onto a neighboring line.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach