Resources Numerical accuracy Calculation policy

Properties panel: measures and geometry edits

Shows an object's length, angle, area, circumference, chord and sweep, and changes the object when one of them is typed.

How the precision is kept

Each property is a closed-form 64-bit expression of the object's stored numbers, and each edit solves that expression back for one stored number. Angles are shown in degrees and stored in radians. Several objects show one value only when their values agree within a relative 1e-9.

Calculations under this policy

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

propdefs.js CadCraft 0.8.2 64-bit 9 calculations Reviewed

Tests bin/test-cadcraft-props.mjs, bin/test-cadcraft.mjs

Length, area and perimeter of an object

64-bit

The measures the panel shows for one object.

Length comes from curveLength and area and perimeter from entityArea, both of geom.js; this function only takes the absolute value of the area. An open polyline has no area.

  • propdefs.js
  • lines 134 to 144
  • CadCraft 0.8.2
/** Length, area and perimeter of an object, where they mean something. */
export function measure(e) {
  const out = {};
  const L = curveLength(e);
  if (L !== null && L !== undefined) out.length = L;
  if (['circle', 'poly', 'ellipse', 'hatch'].includes(e.type) && (e.type !== 'poly' || e.closed)) {
    const a = entityArea(e);
    if (a) { out.area = Math.abs(a.area); out.perimeter = a.perimeter; }
  }
  return out;
}

Centroid of a closed polyline

64-bit

The point a closed polyline is scaled about when its area is typed.

a = Σ (xᵢyⱼ − xⱼyᵢ); c = Σ (pᵢ + pⱼ)(xᵢyⱼ − xⱼyᵢ) ÷ 3a

Shoelace sums over absolute coordinates, so a small polygon far from the origin subtracts large nearly equal products. Bulges are ignored, as the comment says. When |a| < 1e-12, an absolute limit, the mean of the corners is used instead.

  • propdefs.js
  • lines 146 to 153
  • CadCraft 0.8.2
/** The centroid of a closed polyline's corners (bulges ignored: close enough to scale about). */
function polyCentre(e) {
  let a = 0, cx = 0, cy = 0;
  const p = e.pts, n = p.length;
  for (let i = 0; i < n; i++) { const u = p[i], v = p[(i + 1) % n], c = u.x * v.y - v.x * u.y; a += c; cx += (u.x + v.x) * c; cy += (u.y + v.y) * c; }
  if (Math.abs(a) < 1e-12) return { x: p.reduce((s, q) => s + q.x, 0) / n, y: p.reduce((s, q) => s + q.y, 0) / n };
  return { x: cx / (3 * a), y: cy / (3 * a) };
}

Scaling a polyline to a typed area

64-bit

Resizes a closed polyline about its centroid so its area becomes the value typed.

k = √(A ÷ A₀); p′ = c + (p − c)·k

One division and one square root for k, then each vertex is moved in 64-bit; segment widths are multiplied by k. A polyline with no area is refused.

  • propdefs.js
  • lines 154 to 160
  • CadCraft 0.8.2
/** A closed polyline scaled about its centre so its area becomes A (P.8). */
function polyToArea(e, A) {
  const A0 = Math.abs(entityArea(e)?.area || 0);
  if (!(A0 > 0)) throw new PropError('props.err.noarea');
  const k = Math.sqrt(A / A0), c = polyCentre(e);
  return { ...e, pts: e.pts.map((q) => ({ ...q, x: c.x + (q.x - c.x) * k, y: c.y + (q.y - c.y) * k, ...(q.sw ? { sw: q.sw * k } : {}), ...(q.ew ? { ew: q.ew * k } : {}) })) };
}

Line: length and angle

64-bit

Shows a line's length, angle and deltas, and moves its end point when length or angle is typed.

L = |p₂ − p₁|; new end = p₁ + L·(cos a, sin a)

Typing a length recomputes the end point from atan2 of the current direction and then its cosine and sine, so the direction passes through an angle and back instead of being scaled; the start point stays.

  • propdefs.js
  • lines 174 to 183
  • CadCraft 0.8.2
line: () => [
  ...point('start', ['x1', 'y1', 'z1']), ...point('end', ['x2', 'y2', 'z2']),
  { key: 'dx', sec: 'geometry', kind: 'ro-len', get: (e) => e.x2 - e.x1 },
  { key: 'dy', sec: 'geometry', kind: 'ro-len', get: (e) => e.y2 - e.y1 },
  { key: 'dz', sec: 'geometry', kind: 'ro-len', get: (e) => num0(e.z2) - num0(e.z1) },
  { key: 'len', sec: 'geometry', kind: 'len', gt0: true, get: (e) => Math.hypot(e.x2 - e.x1, e.y2 - e.y1),
    set: (e, v) => { const a = Math.atan2(e.y2 - e.y1, e.x2 - e.x1); return { ...e, x2: e.x1 + Math.cos(a) * v, y2: e.y1 + Math.sin(a) * v }; } },
  { key: 'ang', sec: 'geometry', kind: 'ang', get: (e) => deg(normAngle(Math.atan2(e.y2 - e.y1, e.x2 - e.x1))),
    set: (e, v) => { const L = Math.hypot(e.x2 - e.x1, e.y2 - e.y1); return { ...e, x2: e.x1 + Math.cos(rad(v)) * L, y2: e.y1 + Math.sin(rad(v)) * L }; } },
],

Circle: diameter, circumference and area

64-bit

Shows a circle's diameter, circumference and area, and sets the radius when one of them is typed.

d = 2r; C = 2πr; A = πr²; r = d ÷ 2, C ÷ 2π, √(A ÷ π)

One or two 64-bit operations each, with Math.PI and the constant TAU of geom.js. The area edit takes one division and one square root.

  • propdefs.js
  • lines 184 to 189
  • CadCraft 0.8.2
circle: () => [
  ...point('center', ['cx', 'cy', 'z']),
  { key: 'r', sec: 'geometry', kind: 'len', gt0: true, get: (e) => e.r, set: (e, v) => ({ ...e, r: v }) },
  { key: 'd', sec: 'geometry', kind: 'len', gt0: true, get: (e) => e.r * 2, set: (e, v) => ({ ...e, r: v / 2 }) },
  { key: 'circ', sec: 'geometry', kind: 'len', gt0: true, get: (e) => TAU * e.r, set: (e, v) => ({ ...e, r: v / TAU }) },
  { key: 'area', sec: 'geometry', kind: 'area', gt0: true, get: (e) => Math.PI * e.r * e.r, set: (e, v) => ({ ...e, r: Math.sqrt(v / Math.PI) }) },

Arc: sweep, length, chord and segment area

64-bit

Shows an arc's angles, sweep, length, chord and segment area, and changes the arc when one is typed.

θ = (a₁ − a₀) mod 2π; length = rθ; chord = 2r·sin(θ ÷ 2); area = r²(θ − sin θ) ÷ 2

A sweep that wraps to exactly zero is read as a full turn. A typed sweep of 360 − 1e-9 degrees or more is refused, and a typed chord is refused when |sin(θ ÷ 2)| ≤ 1e-9. For a very shallow arc θ − sin θ subtracts two nearly equal numbers and the segment area loses digits.

  • propdefs.js
  • lines 191 to 201
  • CadCraft 0.8.2
arc: () => [
  ...point('center', ['cx', 'cy', 'z']),
  { key: 'r', sec: 'geometry', kind: 'len', gt0: true, get: (e) => e.r, set: (e, v) => ({ ...e, r: v }) },
  { key: 'a0', sec: 'geometry', kind: 'ang', get: (e) => deg(normAngle(e.a0)), set: (e, v) => ({ ...e, a0: normAngle(rad(v)) }) },
  { key: 'a1', sec: 'geometry', kind: 'ang', get: (e) => deg(normAngle(e.a1)), set: (e, v) => ({ ...e, a1: normAngle(rad(v)) }) },
  { key: 'sweep', sec: 'geometry', kind: 'ang', gt0: true, max: 360, get: (e) => deg(normAngle(e.a1 - e.a0) || TAU),
    set: (e, v) => { if (v >= 360 - 1e-9) throw new PropError('props.err.fullarc'); return { ...e, a1: normAngle(e.a0 + rad(v)) }; } },
  { key: 'arclen', sec: 'geometry', kind: 'ro-len', get: (e) => e.r * (normAngle(e.a1 - e.a0) || TAU) },
  { key: 'chord', sec: 'geometry', kind: 'len', gt0: true, get: (e) => 2 * e.r * Math.sin((normAngle(e.a1 - e.a0) || TAU) / 2),
    set: (e, v) => { const s = Math.sin((normAngle(e.a1 - e.a0) || TAU) / 2); if (!(Math.abs(s) > 1e-9)) throw new PropError('props.err.nochord'); return { ...e, r: Math.abs(v / (2 * s)) }; } },
  { key: 'area', sec: 'geometry', kind: 'ro-area', get: (e) => { const th = normAngle(e.a1 - e.a0) || TAU; return e.r * e.r * (th - Math.sin(th)) / 2; } },

Ellipse: axes and angles

64-bit

Shows an ellipse's major and minor axis, ratio, axis angle and start and end angles, and changes them when typed.

major = |m|; minor = |m| × ratio; ratio = minor ÷ major

The major axis is rescaled by dividing its vector by its own length and multiplying by the new one. A typed minor axis may exceed the major by at most 1e-12, an absolute allowance. A typed axis angle rebuilds the vector from cosine and sine.

  • propdefs.js
  • lines 203 to 214
  • CadCraft 0.8.2
ellipse: () => [
  ...point('center', ['cx', 'cy', 'z']),
  { key: 'major', sec: 'geometry', kind: 'len', gt0: true, get: (e) => Math.hypot(e.mx, e.my),
    set: (e, v) => { const a = Math.hypot(e.mx, e.my) || 1; return { ...e, mx: e.mx / a * v, my: e.my / a * v }; } },
  { key: 'minor', sec: 'geometry', kind: 'len', gt0: true, get: (e) => Math.hypot(e.mx, e.my) * e.ratio,
    set: (e, v) => { const a = Math.hypot(e.mx, e.my); if (v > a + 1e-12) throw new PropError('props.err.minor'); return { ...e, ratio: v / a }; } },
  { key: 'ratio', sec: 'geometry', kind: 'num', gt0: true, max: 1, get: (e) => e.ratio, set: (e, v) => ({ ...e, ratio: v }) },
  { key: 'axisang', sec: 'geometry', kind: 'ang', get: (e) => deg(normAngle(Math.atan2(e.my, e.mx))),
    set: (e, v) => { const a = Math.hypot(e.mx, e.my); return { ...e, mx: Math.cos(rad(v)) * a, my: Math.sin(rad(v)) * a }; } },
  { key: 'ea0', sec: 'geometry', kind: 'ang', get: (e) => deg(paramToAngle(e.t0 ?? 0, e.ratio)), set: (e, v) => ({ ...e, t0: angleToParam(rad(v), e.ratio) }) },
  { key: 'ea1', sec: 'geometry', kind: 'ang', get: (e) => deg(paramToAngle(e.t1 ?? TAU, e.ratio)) || ((e.t1 ?? TAU) - (e.t0 ?? 0) >= TAU - 1e-9 ? 360 : 0),
    set: (e, v) => { const tp = angleToParam(rad(v), e.ratio); return { ...e, t1: tp <= (e.t0 ?? 0) + 1e-12 ? tp + TAU : tp }; } },

Ellipse parameter and true angle

64-bit

Converts between an ellipse's stored parameter and the angle seen from its center.

angle = atan2(ratio·sin t, cos t); t = atan2(sin a ÷ ratio, cos a)

Both ways use atan2, so there is no division by a cosine near zero. A ratio of zero is replaced by 1 in the way back.

  • propdefs.js
  • lines 165 to 166
  • CadCraft 0.8.2
const paramToAngle = (tp, ratio) => normAngle(Math.atan2(ratio * Math.sin(tp), Math.cos(tp)));
const angleToParam = (a, ratio) => normAngle(Math.atan2(Math.sin(a) / (ratio || 1), Math.cos(a)));

Do several objects share a value

64-bit

Decides whether a field shows one value or 'varies' for a selection.

|a − b| ≤ 1e-9 × max(1, |a|, |b|)

The tolerance is relative above 1 and absolute (1e-9) below it. The value shown is the first object's; the others may differ from it by up to that amount.

  • propdefs.js
  • line 493
  • CadCraft 0.8.2
const near = (a, b) => (typeof a === 'number' && typeof b === 'number' ? Math.abs(a - b) <= 1e-9 * Math.max(1, Math.abs(a), Math.abs(b)) : a === b || (typeof a === 'string' && typeof b === 'string' && /^#/.test(a) && a.toLowerCase() === b.toLowerCase()));
props.js CadCraft 0.8.2 64-bit 1 calculation Reviewed

Tests None found by the review

Text held by a number field

64-bit

Chooses whether a field holds the formatted text (12'-6") or the plain number.

formatted text kept when |read(text) − value| ≤ 1e-9 × max(1, |value|)

The formatted text is used only when reading it back gives the value within a relative 1e-9; otherwise the field holds the number itself, so a value that the format would round is not replaced by its rounded text.

  • props.js
  • lines 213 to 220
  • CadCraft 0.8.2
/** The text a number field holds: the drawing's own format (12'-6", 1/2") when reading it back gives the same number, else the plain number. */
const numText = (def, value) => {
  if (value === VARIES || !Number.isFinite(value) || (def.kind !== 'len' && def.kind !== 'ang')) return value === VARIES ? null : value;
  const txt = show(def, value);
  let back = null;
  try { back = read(def, txt); } catch { back = null; }
  return back && back.ok && Math.abs(back.value - value) <= 1e-9 * Math.max(1, Math.abs(value)) ? txt : value;
};

In detail

Method
Closed forms: circle C = 2πr, A = πr²; arc chord 2r·sin(θ ÷ 2), segment area r²(θ − sin θ) ÷ 2; polygon centroid by the shoelace sums; scale to an area by √(A ÷ A₀).
Number types
64-bit numbers.
Tolerances
Values agree within 1e-9 × max(1, |a|, |b|). Full arc refused at sweep ≥ 360 − 1e-9 degrees. Chord edit refused when |sin(θ ÷ 2)| ≤ 1e-9. Minor axis may exceed major by 1e-12 (absolute). Polygon area sum under 1e-12 (absolute) falls back to the mean of the corners. Uniform scale when factors agree within 1e-12.
Limits
The centroid used to scale a polyline ignores bulges. The arc segment area loses digits for very shallow arcs.

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