Resources Numerical accuracy Calculation policy

Markup outlines and measurement labels

Builds what each markup draws, on screen and in the saved PDF: its outline as lines and Bézier curves, and the label text of a length, area, angle or count.

How the precision is kept

One display list per markup is computed in 64-bit from the stored points and serves both the screen and the PDF appearance. Label values are computed from the stored points and the sheet's calibration each time the list is built. Curved outlines (ellipses, clouds, arcs) are cubic Béziers, which approximate the true curve; no measurement is taken from them.

Calculations under this policy

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

shapes.js MarkupCraft 0.8.1 64-bit 9 calculations Reviewed

Tests bin/test-markupcraft.mjs

Length measurement label

64-bit

The dimension line, its end ticks and the length text of a measure markup.

len = hypot(bₓ − aₓ, b_y − a_y); label = len × mmPerPt, formatted

The length is one Math.hypot of the two stored end points, recomputed whenever the list is built; the label is not stored with the markup. On a sheet with no calibration the label is the text kept with the markup, or a no-scale notice.

  • shapes.js
  • lines 366 to 376
  • MarkupCraft 0.8.1
case 'measure': {
  if (pts.length < 2) break;
  const [a, b] = pts;
  const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
  const ux = len ? (b[0] - a[0]) / len : 1, uy = len ? (b[1] - a[1]) / len : 0;
  const tk = Math.max(4, s.width * 3 + 3);
  const d = [['M', a[0], a[1]], ['L', b[0], b[1]]];
  for (const p of [a, b]) d.push(['M', p[0] - uy * tk, p[1] + ux * tk], ['L', p[0] + uy * tk, p[1] - ux * tk]);
  for (const p of [a, b]) { const dx = (ux - uy) * tk * 0.7, dy = (uy + ux) * tk * 0.7; d.push(['M', p[0] - dx, p[1] - dy], ['L', p[0] + dx, p[1] + dy]); }
  out.push(stroke(d, s, { cap: 'butt' }));
  const label = env.cal ? measure(len, env.cal) : (m.text || t('status.scale.none'));

Area measurement label

64-bit

The area and perimeter text of an area markup, placed at the outline's centroid.

The two values come from measureArea in units.js with the markup's cut-outs. The label is anchored at the centroid of the outer outline only; cut-outs do not move it.

  • shapes.js
  • lines 342 to 345
  • MarkupCraft 0.8.1
const v = measureArea(pts, env.cal, m.holes);
const lines = env.cal ? [v.area, t('area.perimeter', { v: v.perimeter })] : [m.text || t('status.scale.none')];
const c = centroid(pts);
out.push(...labelItems(c, lines, s.fontSize, m.rot || 0, s.color, s.opacity ?? 1));

Angle measurement label

64-bit

The angle text of an angle markup and where it sits.

label = angle at the vertex, 1 decimal; position = vertex + (r + 1.2 × font size) along the bisector

The text is formatAngle(angleAt(...)) with the default of 1 decimal place. The position uses cos and sin of the indicator arc's middle angle and affects placement only.

  • shapes.js
  • lines 234 to 236
  • MarkupCraft 0.8.1
const label = formatAngle(angleAt(a, v, b));
const mid = ind.a0 + ind.sweep / 2, rr = r + s.fontSize * 1.2;
const cx = v[0] + Math.cos(mid) * rr, cy = v[1] + Math.sin(mid) * rr;

Count total

Exact

The running total shown beside the first mark of a count group.

n = number of marks

The total is the length of the group's point list, a whole number; each mark is numbered by its index + 1.

  • shapes.js
  • lines 357 to 359
  • MarkupCraft 0.8.1
// the group's running total, tagged beside its first mark
const f = frame(m.rot || 0, [0, 0, 1, 1]);
const tag = t('count.tag', { group: m.label || t('count.default'), n: pts.length });

Centroid of an outline

64-bit

The area centroid of a closed outline, used to place the label of an area or a named zone.

k = xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ; cₓ = Σ (xᵢ + xᵢ₊₁)·k ÷ (3·Σ k)

Shoelace sums in absolute page coordinates. When |Σ k| < 1e-9 (absolute, in points²) the vertex average is returned instead. The result positions a label; it is not shown as a number.

  • shapes.js
  • lines 404 to 414
  • MarkupCraft 0.8.1
/** Area centroid of a closed outline (falls back to the vertex average for degenerate ones). */
export function centroid(pts) {
  let a = 0, cx = 0, cy = 0;
  for (let i = 0; i < pts.length; i++) {
    const p = pts[i], q = pts[(i + 1) % pts.length];
    const k = p[0] * q[1] - q[0] * p[1];
    a += k; cx += (p[0] + q[0]) * k; cy += (p[1] + q[1]) * k;
  }
  if (Math.abs(a) < 1e-9) return [pts.reduce((t, p) => t + p[0], 0) / pts.length, pts.reduce((t, p) => t + p[1], 0) / pts.length];
  return [cx / (3 * a), cy / (3 * a)];
}

Circle-to-Bézier constant

64-bit

The control-point factor for drawing a quarter circle as one cubic Bézier.

K = 4·(√2 − 1) ÷ 3 ≈ 0.5522847498

Written as a ten-digit literal; the 64-bit value of 4·(√2 − 1) ÷ 3 is 0.5522847498307936. It shapes the drawn curve only, and the Bézier it defines is itself an approximation of the circle.

  • shapes.js
  • line 21
  • MarkupCraft 0.8.1
const K = 0.5522847498;   // circle → cubic Bézier

Ellipse outline

64-bit

An ellipse markup's outline as four cubic Bézier curves inside its box.

c = (p₀ + p₁) ÷ 2; r = (p₁ − p₀) ÷ 2; control offset = r × K

The four end points sit at the box's edge midpoints, computed as center ± radius. The curve between them is the standard Bézier approximation; the markup stores its box, not this curve.

  • shapes.js
  • lines 69 to 74
  • MarkupCraft 0.8.1
ellipse([x0, y0, x1, y1]) {
  const cx = (x0 + x1) / 2, cy = (y0 + y1) / 2, rx = (x1 - x0) / 2, ry = (y1 - y0) / 2;
  const kx = rx * K, ky = ry * K;
  return [['M', cx + rx, cy], ['C', cx + rx, cy + ky, cx + kx, cy + ry, cx, cy + ry], ['C', cx - kx, cy + ry, cx - rx, cy + ky, cx - rx, cy],
    ['C', cx - rx, cy - ky, cx - kx, cy - ry, cx, cy - ry], ['C', cx + kx, cy - ry, cx + rx, cy - ky, cx + rx, cy], ['Z']];
},

Revision cloud outline

64-bit

The scalloped outline of a cloud markup round its corner points.

n = max(1, round(edge ÷ arc)); step = edge ÷ n; bulge height = 0.6667 × step

Each edge is divided into n equal scallops, so they always end exactly at the corners. The sign of the outline's shoelace area decides which side is outward. Points closer than 1e-6 points (absolute) to the one before are dropped first. The factors 0.6667 and 0.1 set the scallop's look.

  • shapes.js
  • lines 86 to 113
  • MarkupCraft 0.8.1
/**
 * A revision cloud round a closed outline: each edge split into scallops of about `arc` points,
 * each a bump bulging outward (one cubic Bézier, close to a half circle).
 */
export function cloudPath(pts, arc) {
  const ring = pts.filter((p, i) => i === 0 || Math.hypot(p[0] - pts[i - 1][0], p[1] - pts[i - 1][1]) > 1e-6);
  if (ring.length > 2 && Math.hypot(ring[0][0] - ring.at(-1)[0], ring[0][1] - ring.at(-1)[1]) < 1e-6) ring.pop();
  if (ring.length < 2) return [];
  const ccw = area(ring) >= 0;
  const d = [['M', ring[0][0], ring[0][1]]];
  for (let i = 0; i < ring.length; i++) {
    const a = ring[i], b = ring[(i + 1) % ring.length];
    const len = Math.hypot(b[0] - a[0], b[1] - a[1]);
    if (len < 1e-6) continue;
    const n = Math.max(1, Math.round(len / arc));
    const ux = (b[0] - a[0]) / len, uy = (b[1] - a[1]) / len;
    // outward normal: right of the edge for a CCW ring, left for CW
    const nx = ccw ? uy : -uy, ny = ccw ? -ux : ux;
    const step = len / n, h = step * 0.6667;
    for (let j = 0; j < n; j++) {
      const sx = a[0] + ux * step * j, sy = a[1] + uy * step * j;
      const ex = sx + ux * step, ey = sy + uy * step;
      d.push(['C', sx + nx * h - ux * step * 0.1, sy + ny * h - uy * step * 0.1, ex + nx * h + ux * step * 0.1, ey + ny * h + uy * step * 0.1, ex, ey]);
    }
  }
  d.push(['Z']);
  return d;
}

Signed area of an outline

64-bit

The signed shoelace area used to tell which way an outline runs.

A = Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ) ÷ 2

Only the sign is used (cloudPath tests A ≥ 0). The sum uses absolute coordinates; for an outline with almost no area the sign can be decided by rounding.

  • shapes.js
  • lines 79 to 84
  • MarkupCraft 0.8.1
/** Signed area (y up: > 0 = counter-clockwise). */
export function area(pts) {
  let a = 0;
  for (let i = 0; i < pts.length; i++) { const p = pts[i], q = pts[(i + 1) % pts.length]; a += p[0] * q[1] - q[0] * p[1]; }
  return a / 2;
}

In detail

Method
Ellipse: four cubic Béziers with control factor K. Cloud: each edge split into equal scallops, each one cubic Bézier bulging outward. Label anchor of an area: the area centroid by shoelace sums. Length label: hypot of the two end points × mmPerPt.
Number types
64-bit numbers throughout. The display list holds plain arrays of numbers.
Tolerances
Cloud outline: points closer than 1e-6 points to the one before are dropped. Centroid: falls back to the vertex average when |Σ cross products| < 1e-9 points². Both are absolute.
Limits
K is written as 0.5522847498, ten digits of 4·(√2 − 1) ÷ 3. A cubic Bézier is not an exact circle or ellipse.

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