Works out the circle behind each arc segment of a run or an outline, the arc through three picked points, and the numbers shown while a mark is being drawn.
How the precision is kept
An arc is stored as its two end points and a bulge b = tan(θ ÷ 4). The center and radius are rebuilt from the chord every time they are needed; the radius itself is never stored. Everything is 64-bit. Because the radius comes from the chord, an arc that is nearly a full circle (a short chord) or a small arc far from the origin has fewer good digits than its end points.
Open item. Use the unrounded cosine and sine for the diagonal directions of the 45° snap, and set only the axis directions to exact 0 and ±1.
Calculations under this policy
9 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.
The circle an arc segment lies on, from its two end points and its bulge.
θ = 4·atan(b); k = (c ÷ 2) ÷ tan(θ ÷ 2); center = midpoint + k × left normal; r = |p − center|
The radius is measured from the computed center back to p, so it matches the center that is returned. With b = 1 (a half circle) tan(θ ÷ 2) is tan(π ÷ 2), which evaluates to about 1.6e16 and not to infinity; the offset k is then about 3e-17 of the chord and the center lands on the chord's midpoint. A zero-length chord returns null before any division.
geom.js
lines 38 to 49
TakeoffCraft 0.8.1
/** The arc's centre, radius, start angle and sweep (null for a straight segment). */
export function arcOf(p, q, b) {
if (!b) return null;
const c = dist(p, q);
if (!c) return null;
const th = 4 * Math.atan(b);
const mx = (p.x + q.x) / 2, my = (p.y + q.y) / 2;
const k = (c / 2) / Math.tan(th / 2); // centre's distance left of p → q
const cx = mx - (q.y - p.y) / c * k, cy = my + (q.x - p.x) / c * k;
const r = Math.hypot(p.x - cx, p.y - cy);
return { cx, cy, r, a0: Math.atan2(p.y - cy, p.x - cx), sweep: th };
}
Arc through three points
64-bit
The bulge of the arc that starts at one point, passes through a second and ends at a third.
center = circumcenter(p, m, q); sweep = atan2(q − center) − atan2(p − center) ± 2π; b = tan(sweep ÷ 4)
The straight-line test is scaled: |cross| ≤ 1e-9 × span². The circumcenter formula multiplies absolute coordinates squared by coordinate differences, so points that are close together but far from the origin lose digits in the center. The sweep is the difference of two atan2 angles, moved by whole turns until its sign matches the side the middle point is on; the bulge is tan(sweep ÷ 4).
geom.js
lines 51 to 66
TakeoffCraft 0.8.1
/** Bulge of the arc that starts at p, passes through m and ends at q (0 if they are in a line). */
export function bulgeThrough(p, m, q) {
const o = (m.x - p.x) * (q.y - p.y) - (m.y - p.y) * (q.x - p.x);
const span = Math.max(dist(p, m), dist(m, q), dist(p, q));
if (!span || Math.abs(o) <= 1e-9 * span * span) return 0;
// circumcentre
const ax = p.x, ay = p.y, bx = m.x, by = m.y, cx = q.x, cy = q.y;
const d = 2 * (ax * (by - cy) + bx * (cy - ay) + cx * (ay - by));
const ux = ((ax * ax + ay * ay) * (by - cy) + (bx * bx + by * by) * (cy - ay) + (cx * cx + cy * cy) * (ay - by)) / d;
const uy = ((ax * ax + ay * ay) * (cx - bx) + (bx * bx + by * by) * (ax - cx) + (cx * cx + cy * cy) * (bx - ax)) / d;
const a0 = Math.atan2(ay - uy, ax - ux), a2 = Math.atan2(cy - uy, cx - ux);
let sweep = a2 - a0;
// o = (m − p) × (q − p) > 0: the triangle p, m, q is counter-clockwise, and so is the arc p → m → q
if (o > 0) { while (sweep <= 0) sweep += 2 * Math.PI; } else { while (sweep >= 0) sweep -= 2 * Math.PI; }
return Math.tan(sweep / 4);
}
Segment lengths of a run and their sum
64-bit
The length of each segment of an open run, and the run's total.
L = Σ segLength(pᵢ₋₁, pᵢ, bᵢ₋₁)
Each segment goes through segLength (a straight distance by Math.hypot, or the arc formula). The total is a plain sum added in order from the first segment, one 64-bit addition per segment, with no compensation.
geom.js
lines 113 to 118
TakeoffCraft 0.8.1
export function segmentLengths(pts) {
const out = [];
for (let i = 1; i < pts.length; i++) out.push(segLength(pts[i - 1], pts[i], pts[i - 1].b));
return out;
}
export const polylineLength = (pts) => segmentLengths(pts).reduce((s, v) => s + v, 0);
Snap to the nearest 45° direction
64-bit
Moves a picked point onto the nearest of the eight 45° directions from the point before it, keeping its distance along that direction.
u = round(cos, sin of k·45° to 12 decimals); p′ = base + u × ((p − base)·u)
The direction's cosine and sine are rounded to 12 decimals so that 0 and ±1 come out exact. The diagonals then use 0.707106781187 instead of √½, and since the point is base + u·(d·u), a point snapped to a diagonal lands 1.3e-12 of its distance too far along it (2 × 0.707106781187² − 1 = 1.28e-12). The snapped point is what the mark stores.
geom.js
lines 216 to 223
TakeoffCraft 0.8.1
/** Snap p to the nearest 45° direction from base, keeping its distance along that direction. */
export function snap45(base, p) {
const dx = p.x - base.x, dy = p.y - base.y;
const ang = Math.round(Math.atan2(dy, dx) / (Math.PI / 4)) * (Math.PI / 4);
const ux = Math.round(Math.cos(ang) * 1e12) / 1e12, uy = Math.round(Math.sin(ang) * 1e12) / 1e12;
const t = dx * ux + dy * uy;
return { x: base.x + ux * t, y: base.y + uy * t };
}
Stores a drawn circle as two points on opposite sides of the center, each starting a half-circle arc.
r = |edge − c|; points (c.x + r, c.y) and (c.x − r, c.y), b = 1 each
The radius is the 64-bit distance from the center to the picked edge point. The two stored points are center.x ± r, each with bulge 1, so the perimeter and area later come from the arc formulas and not from chords. A circle smaller than 3 screen pixels at the current zoom is refused.
takeoffcraft.js
lines 1370 to 1389
TakeoffCraft 0.8.1
/** A circle as two half-circle arcs (exact area and perimeter): a new area, or a round cut-out. */
function finishCircle(c, edge) {
app.draft = null;
closeTypeIn();
ui.measure.textContent = '';
const r = G.dist(c, edge);
if (r * view.z < 3) { status(t('circle.err.small')); view.overlayOnly(); return; }
const pts = [{ x: c.x + r, y: c.y, b: 1 }, { x: c.x - r, y: c.y, b: 1 }];
if (app.tool === 'cutcircle') {
const m = model.mark(app.sel);
if (!m) return;
const why = G.holeProblem(m.pts, m.holes, pts);
if (why) { status(why, 9000); view.overlayOnly(); return; }
model.updateMark(m.id, { holes: [...m.holes, pts] }, t('undo.cutout.round'));
status(t('cutout.round.added'));
return;
}
app.sel = model.addMark({ item: app.active, sheet: cur.key, pts, kind: 'area', ...inArea() });
renderMarkProps();
}
Arc reshaped by dragging its middle
64-bit
Recomputes a segment's bulge when its arc handle is dragged.
The new bulge is bulgeThrough(start, pointer, end). It is kept only when 1e-6 < |b| < 1e3; outside that range the segment is stored as a straight line. The limits are absolute, which fits a bulge because it has no unit.
The length of the last segment, the run so far, the area and perimeter so far, or a circle's radius and area, shown in the status line as the pointer moves.
length = L × k; area = A × k²; circle preview = π·r²
All values are computed in 64-bit from the points and the sheet's scale k (feet per PDF unit): lengths × k, areas × k². The circle preview uses π·r² directly, while the stored circle's area later comes from its two arcs. The text is rounded for display only (2 decimals for areas, the chosen inch fraction for lengths).
takeoffcraft.js
lines 1430 to 1453
TakeoffCraft 0.8.1
function liveMeasure() {
const d = app.draft;
if (!d || !cur || !app.cursor) return;
const sc = model.scaleOf(cur.key);
const pts = previewPts();
if (app.tool === 'calibrate') { ui.measure.textContent = d.pts.length ? t('cal.second') : ''; return; }
if (!sc) { ui.measure.textContent = ''; return; }
const k = sc.ftPerUnit;
const lastSeg = pts.length >= 2 ? G.segLength(pts.at(-2), pts.at(-1), pts.at(-2).b) * k : 0;
const arcNote = d.mid ? ' ' + t('measure.arc') : '';
if (app.tool === 'circle' || app.tool === 'cutcircle') {
const r = G.dist(d.pts[0], app.cursor) * k;
ui.measure.textContent = t('measure.circle', { r: ftIn(r), area: U.fixed(Math.PI * r * r, 2) });
} else if (app.tool === 'linear' || app.tool === 'check') {
if (app.tool === 'linear') {
// deduct segments (T2) are left out of the run's length; the one under way follows the Deduct button
const segs = G.segmentLengths(pts);
const net = segs.reduce((n, len, i) => n + ((i === segs.length - 1 ? app.deduct : d.ded?.has(i)) ? 0 : len), 0);
ui.measure.textContent = t('measure.run', { len: ftIn(net * k), seg: app.deduct ? t('deduct.seg', { len: ftIn(lastSeg) }) : ftIn(lastSeg) }) + arcNote;
} else ui.measure.textContent = ftIn(lastSeg) + arcNote;
} else if (app.tool === 'area' || app.tool === 'cutout') {
ui.measure.textContent = (pts.length >= 3 || G.hasArcs(pts) ? t('measure.area', { area: U.fixed(G.polygonArea(pts) * k * k, 2), p: ftIn(G.perimeter(pts) * k) }) : t('measure.side', { len: ftIn(lastSeg) })) + arcNote;
}
}
Check distance between two points
64-bit
The real distance between two picked points, in feet-inches, decimal feet and meters.
ft = |b − a| × k; m = ft × 0.3048
One Math.hypot distance times the sheet's scale; meters are feet × 0.3048, the exact definition. The decimal values are rounded to 3 places for the status line only.
Removes a point that sits on the one before it, as a double-click adds.
drop pᵢ when |pᵢ − previous kept point| ≤ 2 ÷ zoom
The tolerance is 2 ÷ view.z sheet units, which is 2 screen pixels at the current zoom. It is tied to the screen and not to the drawing: the same two points are merged when zoomed out and kept when zoomed in. The points that remain are stored unchanged.
takeoffcraft.js
lines 1319 to 1325
TakeoffCraft 0.8.1
/** Drop points that sit on the one before (a double-click adds two). */
function dedupe(pts) {
const tol = 2 / view.z;
const out = [];
for (const p of pts) if (!out.length || G.dist(out.at(-1), p) > tol) out.push(p);
return out;
}
In detail
Method
Bulge arcs as in DXF: θ = 4·atan(b); the center sits (c ÷ 2) ÷ tan(θ ÷ 2) to the left of the chord's midpoint. The arc through three points uses the circumcenter and two atan2 angles.
Number types
64-bit numbers throughout. Points are {x, y} objects in PDF user space.
Tolerances
Three points count as a straight line when |cross product| ≤ 1e-9 × span² (scaled to the points' spread). A dragged arc keeps its bulge only when 1e-6 < |b| < 1e3 (the bulge has no unit). Points closer than 2 screen pixels to the one before are dropped when a mark is finished; in sheet units that is 2 ÷ zoom.
Limits
The diagonal directions of the 45° snap use √½ rounded to 12 decimals. The circumcenter is computed from absolute coordinates squared, so three close points far from the origin lose digits.
Approach: 64-bit, direct
Every value is a 64-bit number from input to result. Formulas are closed form or a direct solve, and every comparison uses a stated tolerance.