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