Resources Numerical accuracy Calculation policy

Arcs from bulges and three points

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.

geom.js TakeoffCraft 0.8.1 64-bit 4 calculations Reviewed

Tests bin/test-takeoffcraft.mjs, bin/test-takeoffcraft-cad.mjs

Center, radius and sweep of a bulge arc

64-bit

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 };
}
takeoffcraft.js TakeoffCraft 0.8.1 64-bit 5 calculations Reviewed

Tests bin/test-takeoffcraft.mjs

Circle as two half-circle arcs

64-bit

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.

  • takeoffcraft.js
  • lines 1533 to 1534
  • TakeoffCraft 0.8.1
const b = G.bulgeThrough(a, p, z);
ring[g.i] = Math.abs(b) > 1e-6 && Math.abs(b) < 1e3 ? { x: a.x, y: a.y, b } : { x: a.x, y: a.y };

Length and area shown while drawing

64-bit

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.

  • takeoffcraft.js
  • lines 1361 to 1363
  • TakeoffCraft 0.8.1
const ft = G.dist(pts[0], pts[1]) * sc.ftPerUnit;
app.check = { a: pts[0], b: pts[1], text: ftIn(ft) };
ui.measure.textContent = t('check.result', { len: ftIn(ft), ft: U.fixed(ft, 3), m: U.fixed(ft * U.M_PER_FT, 3) });

Dropping repeated points when a mark is finished

64-bit

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.

Every policy that takes this approach