Resources Numerical accuracy Calculation policy

Arc through three points and angle measurement

Works out the arc markup's circle from its three points, its length, and the angle between two legs shown by the angle measurement.

How the precision is kept

An arc markup stores its three picked points; the center, radius, start angle and sweep are rebuilt from them in 64-bit each time. The length is r × |sweep|. The angle is the acos of a clamped, normalized dot product. The drawn arc is a chain of cubic Béziers, which approximates the circle; the length does not use it.

Open item. Compute the circle center from coordinates relative to the first point instead of from absolute x² + y².

Calculations under this policy

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

arcs.js MarkupCraft 0.8.1 64-bit 5 calculations Reviewed

Tests bin/test-markupcraft.mjs

Arc through three points

64-bit

The center, radius, start angle and signed sweep of the arc from the first point through the second to the third.

d = 2·(aₓ(m_y − b_y) + mₓ(b_y − a_y) + bₓ(a_y − m_y)); cₓ = Σ (x² + y²)·Δy ÷ d; r = hypot(a − c)

The center comes from sums of (x² + y²) × a coordinate difference using absolute page coordinates, so for three close points far from the origin the terms are large and nearly cancel. The straight-line test |d| < 1e-9 × span² is scaled to the points' spread; the radius cap of 1e7 points is absolute. The radius is measured from the first point only.

  • arcs.js
  • lines 14 to 30
  • MarkupCraft 0.8.1
export function arcThrough(a, m, b) {
  const d = 2 * (a[0] * (m[1] - b[1]) + m[0] * (b[1] - a[1]) + b[0] * (a[1] - m[1]));
  const span = Math.max(Math.hypot(b[0] - a[0], b[1] - a[1]), Math.hypot(m[0] - a[0], m[1] - a[1]), 1e-9);
  if (Math.abs(d) < 1e-9 * span * span) return null;
  const s = (p) => p[0] * p[0] + p[1] * p[1];
  const cx = (s(a) * (m[1] - b[1]) + s(m) * (b[1] - a[1]) + s(b) * (a[1] - m[1])) / d;
  const cy = (s(a) * (b[0] - m[0]) + s(m) * (a[0] - b[0]) + s(b) * (m[0] - a[0])) / d;
  const r = Math.hypot(a[0] - cx, a[1] - cy);
  if (!(r < 1e7)) return null;
  const ang = (p) => Math.atan2(p[1] - cy, p[0] - cx);
  const a0 = ang(a), am = ang(m), a1 = ang(b);
  const ccw = (x) => ((x % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI);
  // counterclockwise from a: does m come before b? then the arc runs counterclockwise
  const toM = ccw(am - a0), toB = ccw(a1 - a0);
  const sweep = toM <= toB ? toB : -(2 * Math.PI - toB);
  return { c: [cx, cy], r, a0, sweep };
}

Arc as Bézier pieces

64-bit

The arc as cubic Bézier segments of at most 90° each, for drawing and for the saved appearance.

n = ⌈|sweep| ÷ (π ÷ 2) − 1e-9⌉; k = (4 ÷ 3)·tan(step ÷ 4)

The end points of each piece are computed from the center, radius and angle, so they lie on the circle to 64-bit rounding; between them a cubic Bézier approximates the arc and does not equal it. The 1e-9 keeps an exact multiple of 90° from gaining an extra piece. Used for the picture only.

  • arcs.js
  • lines 32 to 44
  • MarkupCraft 0.8.1
export function arcBeziers({ c, r, a0, sweep }) {
  const n = Math.max(1, Math.ceil(Math.abs(sweep) / (Math.PI / 2) - 1e-9));
  const step = sweep / n;
  const k = (4 / 3) * Math.tan(step / 4);
  const out = [];
  for (let i = 0; i < n; i++) {
    const t0 = a0 + i * step, t1 = t0 + step;
    const p0 = [c[0] + r * Math.cos(t0), c[1] + r * Math.sin(t0)];
    const p3 = [c[0] + r * Math.cos(t1), c[1] + r * Math.sin(t1)];
    out.push([p0, [p0[0] - k * r * Math.sin(t0), p0[1] + k * r * Math.cos(t0)], [p3[0] + k * r * Math.sin(t1), p3[1] - k * r * Math.cos(t1)], p3]);
  }
  return out;
}

Arc length

64-bit

The length of an arc markup in points.

length = r × |sweep|

One 64-bit multiplication of the radius and sweep from arcThrough; it inherits their precision. Returns 0 when the three points are in a line and there is no arc.

  • arcs.js
  • line 46
  • MarkupCraft 0.8.1
export const arcLength = (arc) => (arc ? arc.r * Math.abs(arc.sweep) : 0);

Angle between two legs

64-bit

The angle at a vertex between the directions to two other points, from 0° to 180°.

θ = acos((u·w) ÷ (|u|·|w|)) × 180 ÷ π

The cosine is clamped to [−1, 1] before acos, so rounding cannot produce NaN. Near 0° and 180° the cosine changes very little with the angle, so acos returns fewer good digits there than an atan2 of cross and dot products would. A zero-length leg gives 0.

  • arcs.js
  • lines 48 to 54
  • MarkupCraft 0.8.1
export function angleAt(a, v, b) {
  const u = [a[0] - v[0], a[1] - v[1]], w = [b[0] - v[0], b[1] - v[1]];
  const lu = Math.hypot(...u), lw = Math.hypot(...w);
  if (!lu || !lw) return 0;
  const cos = Math.max(-1, Math.min(1, (u[0] * w[0] + u[1] * w[1]) / (lu * lw)));
  return (Math.acos(cos) * 180) / Math.PI;
}

Angle as text

64-bit

Writes the angle with a degree sign.

round(deg × 10ᵖ) ÷ 10ᵖ, p = 1 by default

Rounds to the given number of places, 1 when none is given, then prints with toFixed. This is the text on the sheet and in the Properties panel; the three points are stored unrounded.

  • arcs.js
  • lines 64 to 66
  • MarkupCraft 0.8.1
export function formatAngle(deg, places = 1) {
  return (Math.round(deg * 10 ** places) / 10 ** places).toFixed(places) + '°';
}

In detail

Method
Circumcenter from the determinant form with x² + y²; start, middle and end angles by atan2; the sweep runs counterclockwise when the middle point comes before the end point going counterclockwise. Bézier pieces of at most 90° with control length (4 ÷ 3)·tan(step ÷ 4). Angle = acos(u·w ÷ (|u|·|w|)).
Number types
64-bit numbers throughout. Angles in radians inside, degrees for the angle label.
Tolerances
Three points count as a straight line when |d| < 1e-9 × span², where span is the longer of the two legs from the first point (at least 1e-9); also when the radius is 1e7 points or more (absolute). The piece count subtracts 1e-9 before rounding up. The angle label rounds to 1 decimal place.
Limits
The circumcenter uses absolute page coordinates squared, so three close points far from the page origin lose digits. acos loses digits for angles very near 0° and 180°.

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