Lays out plans, elevations, assembly drawings and anchor plans, and works out the dimension figures, scales and hidden lines printed on them.
How the precision is kept
Positions are 64-bit throughout. Every dimension figure is the difference of two model positions rounded to a whole millimeter with Math.round, each figure on its own, so the figures of a chain need not add up to the overall figure printed beside it. Positions within 0.5 mm of each other are dimensioned as one.
Open item. Print chain dimensions so that they add up to the overall figure, or say on the sheet that figures are rounded to the millimeter one by one.
Calculations under this policy
13 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 number printed on a dimension line, and the value recorded for the sheet.
figure = round(value)
The value comes in as a 64-bit difference of model positions and is rounded to a whole millimeter with Math.round, both for the text and for the recorded value. 12.5 and 12.4999 print as 13 and 12.
drawings.js
lines 83 to 97
DetailCraft 0.8.1
/** Horizontal dimension from paper x1 to x2, dimension line at paper y, extension lines from `from`. */
dimH(x1, x2, y, from, value, view, out = 0, avoid = null) {
const sg = y < from ? -1 : 1;
S.P([[x1, from], [x1, y + sg * 1.5]], STYLE.thin); S.P([[x2, from], [x2, y + sg * 1.5]], STYLE.thin);
S.P([[x1, y], [x2, y]], STYLE.thin);
for (const x of [x1, x2]) S.P([[x - 0.9, y - 0.9], [x + 0.9, y + 0.9]], { w: 0.35 });
const label = fmt(value), tw = textWidth(label, 2.2), fits = Math.abs(x2 - x1) >= tw + 1;
// too short for its figure: the figure goes outside the end nearer the chain's end
const tx = fits || !out ? (x1 + x2) / 2 : out < 0 ? Math.min(x1, x2) - tw / 2 - 1.2 : Math.max(x1, x2) + tw / 2 + 1.2;
let ty = y + (fits || out ? 0.8 : -3.2);
// in a chain, a figure that would sit on the one before steps away from the drawing (small sheets, close holes)
if (avoid) { for (let i = 0; i < 3 && avoid.some((b) => overlap(b, textBox(label, tx, ty, 2.2, { align: 'center' }))); i++) ty += sg * 2.8; avoid.push(textBox(label, tx, ty, 2.2, { align: 'center' })); }
S.T(label, tx, ty, 2.2, { align: 'center' });
dims.push({ dir: 'h', value: Math.round(value), view });
},
A chain of dimensions with its overall
64-bit
The run of dimensions between successive positions and the overall dimension beside it.
step_i = v_i − v_{i−1}; overall = v_n − v_0
Each step and the overall are separate subtractions, each rounded on its own when printed. Three steps of 333.4 print as 333, 333, 333 next to an overall of 1000.
drawings.js
lines 111 to 118
DetailCraft 0.8.1
/** A chain of horizontal dimensions through model positions `vals` (sorted), mapped by `X`, plus the overall. */
chainH(vals, X, y, from, step, view) {
if (vals.length < 2) return;
const mid = (vals[0] + vals[vals.length - 1]) / 2;
const avoid = [];
if (vals.length > 2) for (let i = 1; i < vals.length; i++) S.dimH(X(vals[i - 1]), X(vals[i]), y, from, vals[i] - vals[i - 1], view, (vals[i - 1] + vals[i]) / 2 < mid ? -1 : 1, avoid);
S.dimH(X(vals[0]), X(vals[vals.length - 1]), vals.length > 2 ? y + step : y, from, vals[vals.length - 1] - vals[0], view);
},
Positions to dimension
64-bit
The sorted list of distinct positions a chain is drawn through.
keep v when v − previous kept > 0.5 mm
An absolute 0.5 mm. A run of positions each 0.4 mm apart is reduced by comparing with the last one kept, not with the neighbor.
drawings.js
lines 162 to 167
DetailCraft 0.8.1
/** Unique sorted values (within tol). */
export function uniq(vals, tol = 0.5) {
const s = [...vals].filter(Number.isFinite).sort((a, b) => a - b), o = [];
for (const v of s) if (!o.length || v - o[o.length - 1] > tol) o.push(v);
return o;
}
Sheet scale
Exact
The largest standard scale at which a view fits its room on the paper.
first n in the list with size ÷ n ≤ room
The scale is one of the whole numbers in a fixed list (1, 2, 5, 10, 20, 25, 50 …); nothing is interpolated. A view too big for the last scale still gets the last scale.
drawings.js
lines 50 to 51
DetailCraft 0.8.1
/** The largest standard scale (smallest denominator) at which `size` model mm fits in `room` paper mm. */
export const pickScale = (size, room) => SCALES.find((n) => size / n <= room) || SCALES[SCALES.length - 1];
Model to paper
64-bit
The scale and offset that place a view on the sheet.
paper = origin + model ÷ s
One division by the whole-number scale and one addition per coordinate. The offset centers the view in its room.
drawings.js
lines 421 to 428
DetailCraft 0.8.1
/** Fit: the scale and the paper origin for a view bbox with paper margins m = {l, r, b, t}. */
function fit(bbox, m) {
const W = bbox.x1 - bbox.x0, H = bbox.y1 - bbox.y0;
const roomW = AREA.x1 - AREA.x0 - m.l - m.r, roomH = AREA.y1 - AREA.y0 - m.b - m.t;
const s = SCALES.find((n) => W / n <= roomW && H / n <= roomH) || SCALES[SCALES.length - 1];
const ox = AREA.x0 + m.l + (roomW - W / s) / 2 - bbox.x0 / s, oy = AREA.y0 + m.b + (roomH - H / s) / 2 - bbox.y0 / s;
return { s, M: ([x, y]) => [ox + x / s, oy + y / s] };
}
Part of a member inside a view's slab
64-bit
The stretch of a member's axis that lies inside the band a plan or elevation shows.
a = (lo − s0) ÷ k, b = (hi − s0) ÷ k; k = x̂·A
When the member runs along the slab (|k| < 1e-9) the division is skipped and the whole member is taken or left. The kept stretch must be longer than 1e-6 mm.
drawings.js
lines 292 to 300
DetailCraft 0.8.1
/** Parameter range [t0, t1] of a part's axis inside the slab lo ≤ p·A ≤ hi, or null. */
function clipAxis(part, A, lo, hi) {
const s0 = dot(part.frame.o, A), k = dot(part.frame.x, A), L = part.length;
if (Math.abs(k) < 1e-9) return s0 >= lo - 1e-6 && s0 <= hi + 1e-6 ? [0, L] : null;
let a = (lo - s0) / k, b = (hi - s0) / k;
if (a > b) [a, b] = [b, a];
const t0 = Math.max(0, a), t1 = Math.min(L, b);
return t1 - t0 > 1e-6 || (t1 >= t0 && L < 1e-6) ? [t0, t1] : null;
}
Corners of a part's box
64-bit
The eight corners of a part's section box, moved onto the cut faces at mitre ends.
Each corner is a frame-to-model conversion. A corner counts as being at an end when its station is within 1e-9 mm of 0 or of the length.
drawings.js
lines 246 to 252
DetailCraft 0.8.1
function boxCorners(part, t0 = 0, t1 = part.length) {
const w = part.profile.width / 2, d = part.profile.depth / 2, out = [];
// a plane-cut end (mitre) puts each corner on its cut face
const at = (t, u, v) => (part.endCuts?.start && Math.abs(t) < 1e-9 ? endX(part, 'start', u, v) : part.endCuts?.end && Math.abs(t - part.length) < 1e-9 ? endX(part, 'end', u, v) : t);
for (const t of [t0, t1]) for (const [u, v] of [[-w, -d], [w, -d], [w, d], [-w, d]]) out.push(toWorld(part.frame, at(t, u, v), u, v, part.voff));
return out;
}
Outline of a projected part
64-bit
The convex outline of a part's projected box corners.
pop while cross(o, a, b) ≤ 1e-9
Andrew's monotone chain. The turn test uses an absolute 1e-9 on a cross product in mm², so points closer to a line than that are dropped as collinear. The cross product subtracts two products of coordinate differences in ordinary 64-bit.
drawings.js
lines 169 to 178
DetailCraft 0.8.1
/** Convex hull (Andrew's monotone chain), counter-clockwise. */
export function hull(pts) {
const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
if (p.length < 3) return p;
const cr = (o, a, b) => (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
const lo = [], up = [];
for (const q of p) { while (lo.length >= 2 && cr(lo[lo.length - 2], lo[lo.length - 1], q) <= 1e-9) lo.pop(); lo.push(q); }
for (let i = p.length - 1; i >= 0; i--) { const q = p[i]; while (up.length >= 2 && cr(up[up.length - 2], up[up.length - 1], q) <= 1e-9) up.pop(); up.push(q); }
return lo.slice(0, -1).concat(up.slice(0, -1));
}
Hidden parts of a line
64-bit
Splits a drawn line into the runs that show and the runs behind a part in front.
The line is cut at every crossing with an outline in front; crossings within 1e-9 of the ends and pieces shorter than 1e-9 (as a share of the segment) are ignored. Each piece is judged at its middle: inside an outline and more than 0.05 mm from its edge means hidden.
drawings.js
lines 181 to 212
DetailCraft 0.8.1
/**
* Hidden lines (0.4 D12). runsBehind(pts, closed, occluders) splits a polyline (paper points) where it passes inside any
* occluder polygon (paper points; concave ones too): [{ pts, hidden }] in order. A point on an occluder's edge (within
* 0.05 mm) counts as visible, so outlines that meet or share an edge stay drawn.
*/
export function runsBehind(pts, closed, occluders) {
const segs = [];
const n = pts.length;
for (let i = 0; i < (closed ? n : n - 1); i++) segs.push([pts[i], pts[(i + 1) % n]]);
const out = [];
const push = (a, b, hidden) => {
const last = out[out.length - 1];
if (last && last.hidden === hidden && Math.hypot(last.pts[last.pts.length - 1][0] - a[0], last.pts[last.pts.length - 1][1] - a[1]) < 1e-9) last.pts.push(b);
else out.push({ pts: [a, b], hidden });
};
for (const [a, b] of segs) {
const ts = [0, 1];
for (const o of occluders) for (let k = 0; k < o.length; k++) {
const t = segX(a, b, o[k], o[(k + 1) % o.length]);
if (t !== null && t > 1e-9 && t < 1 - 1e-9) ts.push(t);
}
ts.sort((x, y) => x - y);
for (let k = 0; k + 1 < ts.length; k++) {
if (ts[k + 1] - ts[k] < 1e-9) continue;
const P = (t) => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t];
const m = P((ts[k] + ts[k + 1]) / 2);
const hidden = occluders.some((o) => inPoly(m, o) && edgeDist(m, o) > 0.05);
push(P(ts[k]), P(ts[k + 1]), hidden);
}
}
return out;
}
Where two paper lines cross
64-bit
The position along one segment where it crosses another.
t = ((c − a) × q) ÷ (r × q), u = ((c − a) × r) ÷ (r × q)
Lines with |r × q| under 1e-12 are treated as parallel; that limit is absolute, in paper mm². The other segment's parameter is accepted within 1e-9 of its ends.
drawings.js
lines 213 to 219
DetailCraft 0.8.1
function segX(a, b, c, d) {
const r = [b[0] - a[0], b[1] - a[1]], q = [d[0] - c[0], d[1] - c[1]];
const den = r[0] * q[1] - r[1] * q[0];
if (Math.abs(den) < 1e-12) return null;
const t = ((c[0] - a[0]) * q[1] - (c[1] - a[1]) * q[0]) / den, u = ((c[0] - a[0]) * r[1] - (c[1] - a[1]) * r[0]) / den;
return u >= -1e-9 && u <= 1 + 1e-9 ? t : null;
}
A hole's center and axis
64-bit
Where a hole is in the model and which way it goes through the part.
flange hole: v = ±(depth ÷ 2 − t ÷ 2)
The stored hole offsets are placed at mid thickness of the web, flange or plate and converted to the model by the part's frame. No rounding.
drawings.js
lines 254 to 262
DetailCraft 0.8.1
/** A hole's center (world), axis (world, unit) and the thickness it goes through. */
export function holeGeom(part, h) {
const p = part.profile, f = part.frame;
let u = 0, v = 0, axis;
if (h.face === 'web') { v = h.y; if (p.kind === 'C') u = -p.bf / 2 + p.tw / 2; if (p.kind === 'L') u = -p.b / 2 + p.t / 2; axis = f.u; } // a channel's web is at −u; an angle's leg along v too
else if (h.face === 'plate') { u = h.y; axis = f.v; }
else { u = h.y; const t = faceThickness(p, h.face); v = (h.face === 'top' ? 1 : -1) * (p.depth / 2 - t / 2); axis = f.v; }
return { c: toWorld(f, h.x, u, v, part.voff), axis, t: faceThickness(p, h.face === 'plate' ? 'web' : h.face), d: h.d };
}
An assembly in its main part's coordinates
64-bit
The welded-on parts, holes and overall extents of an assembly, measured along and across its main part.
Every point goes through the main part's model-to-local conversion (subtract the origin, three dot products). The extents are plain minimum and maximum over those points.
drawings.js
lines 507 to 528
DetailCraft 0.8.1
/**
* Everything in an assembly in the main part's own frame: { main, subs, boxes, holes, bolts, welds, ext }.
* Local coordinates [x, u, v]: x along the main part from its cut start, (u, v) from its section center.
*/
export function assemblyGeometry(built, marks, mainId) {
const main = built.byId.get(mainId);
const subs = built.parts.filter((p) => p.id !== mainId && marks.asmOf.get(p.id) === mainId);
const f = main.frame;
const loc = (w) => { const l = toLocal(f, w, main.voff); return [l.x, l.u, l.v]; };
const dir = (a) => [dot(a, f.x), dot(a, f.u), dot(a, f.v)];
const boxes = subs.map((p) => ({ part: p, pts: boxCorners(p).map(loc), mark: marks.partMark.get(p.id), center: loc(toWorld(p.frame, p.length / 2, 0, 0, p.voff)) }));
const holes = [];
for (const p of [main, ...subs]) for (const h of p.holes) { const g = holeGeom(p, h); holes.push({ part: p, c: loc(g.c), a: dir(g.axis), t: g.t, d: g.d }); }
const conns = new Set(subs.map((p) => p.conn).filter(Boolean));
const bolts = built.bolts.filter((b) => conns.has(b.conn));
const ids = new Set([mainId, ...subs.map((p) => p.id)]);
const welds = built.welds.filter((w) => w.between.every((x) => ids.has(x)));
const pd = main.profile;
const all = [[0, -pd.width / 2, -pd.depth / 2], [main.length, pd.width / 2, pd.depth / 2], ...boxes.flatMap((b) => b.pts)];
const ext = [0, 1, 2].map((k) => [Math.min(...all.map((p) => p[k])), Math.max(...all.map((p) => p[k]))]);
return { main, subs, boxes, holes, bolts, welds, ext };
}
Weights in an assembly's part list
64-bit
The weight of each part, of each line and of the whole assembly in the part list.
total = Σ n × weight
The total is summed from the unrounded weights and rounded once with toFixed(1), so it can differ by 0.1 kg from the sum of the rounded lines printed above it. Lengths print as whole millimeters.
drawings.js
lines 672 to 674
DetailCraft 0.8.1
let total = 0;
for (const g of groups.values()) { total += g.n * g.part.weight; rowsT.push([g.mark, String(g.n), g.part.profileName, fmt(g.part.length), g.part.material, g.part.weight.toFixed(1), (g.n * g.part.weight).toFixed(1)]); }
rowsT.push([t('sheet.asm.title', { mark: asmMark }), '', '', '', '', '', total.toFixed(1)]);
In detail
Method
Orthographic projection by dot products; convex hull (Andrew's monotone chain) for part outlines; hidden lines by splitting each line where it crosses the outlines in front.
Number types
64-bit numbers; dimension text in whole millimeters.
Tolerances
Dimension positions merged within 0.5 mm. Hull cross product ≤ 1e-9. Line crossing: 1e-12 on the denominator, 1e-9 on the parameters. A point within 0.05 mm (on paper) of an outline in front counts as visible. View slab test 1e-6 mm.
Limits
Figures are whole millimeters. Scales are picked from a fixed list. A part too thin to outline at the sheet scale is drawn as one 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.