Resources Numerical accuracy Calculation policy

Sheet scale, typed lengths and measured values

Turns a sheet's scale into real lengths and areas: the calibration from a preset or from two picked points, the reading of a typed length, and the length, area, perimeter and count shown for a measurement.

How the precision is kept

A calibration is one 64-bit number, real millimeters per PDF point. Lengths are points × mmPerPt and areas are points² × mmPerPt², in 64-bit from the picked points to the value. The unit factors are the exact definitions (25.4 mm per inch, 304.8 mm per foot, 92903.04 mm² per square foot, 645.16 mm² per square inch). Values are rounded only when they are turned into text; the points and mmPerPt are not rounded by this code.

Open item. Take the shoelace cross products relative to the first vertex, so a small outline far from the page origin does not subtract nearly equal numbers.

Calculations under this policy

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

units.js MarkupCraft 0.8.1 64-bit 11 calculations Reviewed

Tests bin/test-markupcraft.mjs

Millimeters per PDF point

64-bit

The size of one PDF point in millimeters, the base of every preset scale.

MM_PER_PT = 25.4 ÷ 72

25.4 mm per inch is the exact definition and 72 points per inch is the PDF unit. The quotient is not a finite binary fraction, so it is rounded once to 64 bits.

  • units.js
  • line 16
  • MarkupCraft 0.8.1
export const MM_PER_PT = MM_PER_IN / 72;

Calibration from a preset scale

64-bit

Real millimeters per point for a scale chosen from the list, such as 1/4 inch = 1 foot or 1:100.

mmPerPt = (25.4 ÷ 72) × ratio

One 64-bit multiplication of the constant by the preset's ratio, a whole number from the table in this file. The table's own comment says the presets assume the PDF prints at full size; nothing here checks that. Switching the unit from mm to m at ratios of 100 and above changes the text only.

  • units.js
  • lines 41 to 45
  • MarkupCraft 0.8.1
/** A preset ratio → calibration. Metric ratios above 1:100 read better in meters. */
export function presetCalibration(label, ratio, unit) {
  const u = unit === 'mm' && ratio >= 100 ? 'm' : unit;
  return { mmPerPt: MM_PER_PT * ratio, unit: u, label };
}

Calibration from two picked points

64-bit

Real millimeters per point from a distance picked on the sheet and the real length typed for it.

mmPerPt = real length (mm) ÷ picked distance (pt)

One 64-bit division; a zero, negative or non-numeric length or distance returns null. The scale rests on a single pair of picked points, with no averaging. The toFixed(2) is in the label text only.

  • units.js
  • lines 47 to 52
  • MarkupCraft 0.8.1
/** Two points picked on the sheet + the real distance typed → calibration (null if unusable). */
export function calibrationFrom(ptLength, realMm, unit) {
  if (!(ptLength > 0) || !(realMm > 0) || !UNIT_MM[unit]) return null;
  const mmPerPt = realMm / ptLength;
  return { mmPerPt, unit, label: 'Calibrated: ' + formatLength(realMm, unit) + ' over ' + (ptLength / 72).toFixed(2) + ' in' };
}

Fractions and mixed numbers

64-bit

Reads 1/2, 6 1/2 or 12.5 as a number.

a/b → a ÷ b; n a/b → n + a ÷ b

A fraction is one 64-bit division of two whole numbers; a zero denominator gives NaN, which the caller refuses. Decimal text goes through parseFloat after a pattern that allows digits and one decimal point only.

  • units.js
  • lines 55 to 63
  • MarkupCraft 0.8.1
function num(s) {
  s = s.trim();
  if (!s) return NaN;
  const f = s.match(FRACTION);
  if (f) return +f[2] ? +f[1] / +f[2] : NaN;
  const mixed = s.match(/^(\d+(?:\.\d+)?)\s+(\d+)\s*\/\s*(\d+)$/);
  if (mixed) return +mixed[3] ? +mixed[1] + +mixed[2] / +mixed[3] : NaN;
  return /^\d*\.?\d+$/.test(s) ? parseFloat(s) : NaN;
}

A typed length in millimeters

64-bit

Reads what a person types for a length (24'-0", 12' 6 1/2", 3.5 m, 900 mm, or a bare number in the chosen unit) as millimeters.

mm = feet × 304.8 + inches × 25.4; metric: value × 1, 10 or 1000

The factors 304.8 and 25.4 are the exact definitions, applied in 64-bit. Every comma is replaced by a period before parsing, so 1,200 is read as 1.2. Units the patterns do not cover go to the shared unit table (parseTo). A result that is not finite or not above zero is refused.

  • units.js
  • lines 92 to 123
  • MarkupCraft 0.8.1
function one(text, unit) {
  let s = String(text ?? '').trim().toLowerCase().replace(/[’′]/g, "'").replace(/[”″]/g, '"').replace(/''/g, '"').replace(/,/g, '.');
  if (!s) return null;
  // metric with a suffix
  let m = s.match(/^(\d*\.?\d+)\s*(mm|cm|m|meters?|metres?|millimeters?|millimetres?|centimeters?|centimetres?)$/);
  if (m) {
    const u = m[2].startsWith('mm') || m[2].startsWith('milli') ? 'mm' : m[2].startsWith('c') ? 'cm' : 'm';
    return pos(parseFloat(m[1]) * UNIT_MM[u]);
  }
  // feet and inches: 24'-0"  12' 6 1/2"  3 ft 6 in  12.5'  6"  1/2"
  s = s.replace(/\s*(feet|foot|ft)\b\.?/g, "'").replace(/\s*(inches|inch|in)\b\.?/g, '"');
  m = s.match(/^(?:(\d*\.?\d+)\s*')?\s*-?\s*(?:([\d\s./]+?)\s*")?$/);
  if (m && (m[1] !== undefined || m[2] !== undefined) && /['"]/.test(s)) {
    const ft = m[1] !== undefined ? parseFloat(m[1]) : 0;
    const inch = m[2] !== undefined ? num(m[2]) : 0;
    if (!Number.isFinite(ft) || !Number.isFinite(inch)) return null;
    return pos(ft * 304.8 + inch * 25.4);
  }
  // feet with inches but no inch mark: 12' 6
  m = s.match(/^(\d*\.?\d+)\s*'\s*-?\s*([\d\s./]+)$/);
  if (m) {
    const inch = num(m[2]);
    return Number.isFinite(inch) ? pos(parseFloat(m[1]) * 304.8 + inch * 25.4) : null;
  }
  const n = num(s);
  if (!Number.isFinite(n)) {
    // every other length mark of the shared table (0.7 UNITS): 3 yd, 2 ch, 10 US ft, 1.5 km, 12 pt — as typed (case matters)
    const r = parseTo(String(text ?? '').replace(/(\d),(\d)/g, '$1.$2'), 'length', 'mm');
    return r.ok ? pos(r.value) : null;
  }
  return pos(n * (UNIT_MM[unit] || 1));
}

A typed length with arithmetic

64-bit

Reads entries such as 12'6" + 3/4" or 2*450mm as one length in millimeters.

When the text holds an operator the arithmetic is done by calc() in lencalc.js, with each length inside read by one(). A bare number inside is passed as a plain number, and calc is given the unit's millimeters (the plain option) for where it stands as a length. A result of zero or less is refused with a reason.

  • units.js
  • lines 73 to 90
  • MarkupCraft 0.8.1
/**
 * A typed length with arithmetic (0.6 DF.3: 4+7/16, 12'6" + 3/4", (24'-0")/3, 2*450mm) → {value (mm) | null, error, pos}.
 * lencalc.js is a byte-identical copy of CadCraft's (tools/lencalc/sync.mjs): its own parser, never eval. A bare number
 * stays a factor in 2*450mm and becomes `unit` when it is the length itself.
 */
export function parseLengthCalc(text, unit = 'ft-in') {
  const s = String(text ?? '').trim().toLowerCase().replace(/[’′]/g, "'").replace(/[”″]/g, '"').replace(/''/g, '"').replace(/[−–—]/g, '-');
  if (hasArithmetic(s)) {
    const r = calc(s, (x) => {
      const bare = /^[\d.,\s/-]+$/.test(x);
      const v = one(x, bare ? 'mm' : unit);
      return v === null ? null : { value: v, unit: !bare };
    }, { plain: UNIT_MM[unit] || 1 });
    if (r.ok && r.value > 0) return { value: r.value, error: null, pos: -1 };
    return { value: null, error: r.ok ? 'calc.err.not_positive' : 'calc.err.' + r.code, pos: r.ok ? -1 : r.pos, text: r.text || '' };
  }
  return { value: one(s, unit), error: null, pos: -1 };
}

Length as text

64-bit

Writes a length in millimeters in the sheet's unit: feet and inches, feet, inches, meters or millimeters.

ft-in: round(mm ÷ 25.4 × 16) sixteenths; ft: mm ÷ 304.8; in: mm ÷ 25.4; m: mm ÷ 1000

Feet and inches are rounded to the nearest 1/16 inch first and then split with whole-number arithmetic, so a value that rounds up to 12 inches carries into the feet. Feet show 2 decimals, inches 2, meters 3 and millimeters none. This rounding is in the text; the function takes no precision argument.

  • units.js
  • lines 126 to 147
  • MarkupCraft 0.8.1
/** Millimeters → a display string in a unit. Feet and inches round to 1/16". */
export function formatLength(mm, unit = 'ft-in') {
  if (!Number.isFinite(mm)) return '';
  switch (unit) {
    case 'ft-in': {
      let sixteenths = Math.round((mm / 25.4) * 16);
      const neg = sixteenths < 0; sixteenths = Math.abs(sixteenths);
      const ft = Math.floor(sixteenths / 192);
      let rest = sixteenths - ft * 192;
      const inch = Math.floor(rest / 16);
      rest -= inch * 16;
      let frac = '';
      if (rest) { let n = rest, d = 16; while (n % 2 === 0) { n /= 2; d /= 2; } frac = ' ' + n + '/' + d; }
      return (neg ? '-' : '') + ft + "'-" + inch + frac + '"';
    }
    case 'ft': return (mm / 304.8).toFixed(2) + "'";
    case 'in': return trim(mm / 25.4, 2) + '"';
    case 'm': return t('len.m', { v: (mm / 1000).toFixed(3) });
    case 'mm': return t('len.mm', { v: Math.round(mm) });
    default: return t('len.mm', { v: trim(mm, 2) });
  }
}

Area as text

64-bit

Writes an area in square millimeters in the area unit that goes with the sheet's length unit.

sq ft = mm² ÷ 92903.04; sq in = mm² ÷ 645.16; m² = mm² ÷ 1e6

92903.04 is 304.8² and 645.16 is 25.4², both exact. The text shows 2 decimals for square feet, 1 for square inches, 3 for square meters and whole square millimeters.

  • units.js
  • lines 156 to 166
  • MarkupCraft 0.8.1
/** Square millimeters → a display string in the area unit that goes with a length unit. */
export function formatArea(mm2, unit = 'ft-in') {
  if (!Number.isFinite(mm2)) return '';
  switch (unit) {
    case 'ft-in': case 'ft': return t('area.sq_ft', { v: (mm2 / 92903.04).toFixed(2) });
    case 'in': return t('area.sq_in', { v: (mm2 / 645.16).toFixed(1) });
    case 'm': return t('area.sq_m', { v: (mm2 / 1e6).toFixed(3) });
    case 'mm': return t('area.sq_mm', { v: Math.round(mm2) });
    default: return t('area.sq_m', { v: (mm2 / 1e6).toFixed(3) });
  }
}

Area and perimeter of an outline

64-bit

The area and the perimeter of a closed outline, in points² and points.

area = |Σ (xᵢ·yᵢ₊₁ − xᵢ₊₁·yᵢ)| ÷ 2; perimeter = Σ hypot(Δx, Δy)

The shoelace terms use the absolute page coordinates, not coordinates relative to a vertex, so each term can be much larger than the area and the sum subtracts nearly equal numbers for a small outline far from the page origin. The perimeter is a running sum of Math.hypot edge lengths in vertex order. An outline that crosses itself gives the absolute value of the net signed area.

  • units.js
  • lines 168 to 177
  • MarkupCraft 0.8.1
/** Area (absolute) and perimeter of a closed outline, in PDF points² / points. */
export function polygonAreaPerimeter(pts) {
  let a = 0, p = 0;
  for (let i = 0; i < pts.length; i++) {
    const q = pts[i], r = pts[(i + 1) % pts.length];
    a += q[0] * r[1] - r[0] * q[1];
    p += Math.hypot(r[0] - q[0], r[1] - q[1]);
  }
  return { area: Math.abs(a) / 2, perimeter: p };
}

Area measurement with cut-outs

64-bit

The area and perimeter text of an area markup: the outline less its cut-outs, in real units.

area = (A_outline − Σ A_cut-out) × mmPerPt²; perimeter = (P_outline + Σ P_cut-out) × mmPerPt

Each cut-out's absolute area is subtracted and its edge length added, and the difference is clamped at 0. This function does not test that a cut-out lies inside the outline or apart from the others; that test is made when a cut-out is drawn (holeProblem in holes.js), while cut-outs read from a file are only checked to be finite numbers. The scale is applied once, after the subtraction.

  • units.js
  • lines 179 to 187
  • MarkupCraft 0.8.1
/** An area markup's values under a calibration: { area, perimeter } strings ('' with no calibration); `holes` are cut-outs. */
export function measureArea(pts, cal, holes = []) {
  if (!cal || !(cal.mmPerPt > 0) || !pts || pts.length < 3) return { area: '', perimeter: '' };
  let { area, perimeter } = polygonAreaPerimeter(pts);
  // cut-outs (0.4 K8): their area comes off, their edges count in the perimeter
  for (const h of holes || []) { const c = polygonAreaPerimeter(h); area -= c.area; perimeter += c.perimeter; }
  area = Math.max(0, area);
  return { area: formatArea(area * cal.mmPerPt * cal.mmPerPt, cal.unit), perimeter: formatLength(perimeter * cal.mmPerPt, cal.unit) };
}

Length under a calibration

64-bit

A distance in points as a real length in the sheet's unit.

length = points × mmPerPt

One 64-bit multiplication, then formatLength. Returns an empty string when the sheet has no calibration or mmPerPt is not above zero.

  • units.js
  • lines 150 to 154
  • MarkupCraft 0.8.1
/** A length in PDF points under a calibration → display string ('' with no calibration). */
export function measure(ptLength, cal) {
  if (!cal || !(cal.mmPerPt > 0)) return '';
  return formatLength(ptLength * cal.mmPerPt, cal.unit);
}
markupcraft.js MarkupCraft 0.8.1 64-bit 4 calculations Reviewed

Tests None found by the review

Measured values of a markup

64-bit

The length, area and perimeter, count, angle or arc length listed for a markup in the Properties panel and, through valueOf, in lists, CSV and BCF.

length = hypot(Δx, Δy) × mmPerPt; arc = r × |sweep| × mmPerPt; count = number of marks

Each value is recomputed from the markup's stored points every time it is asked for; nothing is cached or stored rounded. The distance is one Math.hypot of the two end points. The values leave this function as formatted text, so lists and exports carry the rounding of formatLength, formatArea and formatAngle.

  • markupcraft.js
  • lines 3312 to 3320
  • MarkupCraft 0.8.1
function valueRows(m) {
  const cal = envOf(m).cal;
  if (m.type === 'measure') return [[t('value.length'), cal ? measure(Math.hypot(m.pts[1][0] - m.pts[0][0], m.pts[1][1] - m.pts[0][1]), cal) : t('value.no_scale')]];
  if (m.type === 'area') { const v = measureArea(m.pts, cal, m.holes); return cal ? [[t('value.area'), v.area], [t('value.perimeter'), v.perimeter], ...(m.holes?.length ? [[t('value.cutouts'), String(m.holes.length)]] : [])] : [[t('value.area'), t('value.no_scale')]]; }
  if (m.type === 'count') return [[t('value.count'), String(m.pts.length)], ...spaceRow(m)];
  if (m.type === 'angle' && m.pts.length === 3) return [[t('value.angle'), formatAngle(angleAt(...m.pts))]];
  if (m.type === 'arc' && m.pts.length === 3) { const a = arcThrough(...m.pts); return [[t('value.length'), cal && a ? measure(arcLength(a), cal) : t('value.no_scale')]]; }
  return spaceRow(m);
}

Pointer position on the paper

64-bit

The pointer's position on the sheet in the status line, in millimeters or inches of paper.

mm = pt ÷ 72 × 25.4; in = pt ÷ 72

The user-space point is mapped by the view's 64-bit matrix, then converted with the exact 25.4. The text shows 1 decimal in millimeters and 2 in inches. This is the paper position; the sheet's calibration is read but not used.

  • markupcraft.js
  • lines 2354 to 2363
  • MarkupCraft 0.8.1
function showCoords(p) {
  const s = curSheet();
  if (!s) { ui.coords.textContent = ''; return; }
  const v = applyT(view.T1, p);
  const cal = store.calibrations.get(s.page.key);
  ui.coords.textContent = settings.posUnit === 'mm'
    ? t('coords.mm', { x: (v[0] / 72 * 25.4).toFixed(1), y: (v[1] / 72 * 25.4).toFixed(1) })
    : t('coords.in', { x: (v[0] / 72).toFixed(2), y: (v[1] / 72).toFixed(2) });
  void cal;
}

Scale carried to a replacement sheet

64-bit

When markups are carried to a new revision of a sheet, the old sheet's calibration is given to the new one if it has none.

|w_old − w_new| ≤ 1 pt and |h_old − h_new| ≤ 1 pt

The calibration object is passed on unchanged. The test is an absolute tolerance of 1 point on each page side; it compares page size only, not the drawing on the page.

  • markupcraft.js
  • lines 760 to 761
  • MarkupCraft 0.8.1
const cal = store.calibrations.get(p.oldKey);
if (cal && !store.calibrations.get(p.newKey) && Math.abs(from.width - to.width) <= 1 && Math.abs(from.height - to.height) <= 1) tx.setCalibration(p.newKey, cal);

Size of a pasted area

64-bit

The size on the sheet of a copied area pasted as a picture: the same size on paper, or the same real size when both sheets have a scale.

k = mmPerPx ÷ mmPerPt (both scaled), else ptPerPx; w = px_w × k; h = px_h × k

One 64-bit division and two multiplications. mmPerPx and ptPerPx come with the copied area (areaclip.js); the width and height are the copy's pixel counts times k.

  • markupcraft.js
  • lines 1772 to 1799
  • MarkupCraft 0.8.1
/**
 * Paste a copied area (0.8 D8.15) as a picture markup at its size: the same size on paper; on a sheet with a scale, when
 * the area came from one too, the same size in the drawing (a 1:50 area on a 1:100 sheet comes in at half). An overlay
 * starts at its strength. how as paste(): 'place' = where it was copied, 'pointer', 'offset' = beside / the view's middle.
 */
async function pasteArea(ac, how = 'offset', pt = pointer) {
  const s = curSheet();
  if (!s) { status(t('need.pdf')); return false; }
  commitEdit(); cancelOps();
  if (!author && !askedAuthor) await askAuthor();
  if (tool !== 'select') setTool('select');
  const cal = store.calibrations.get(s.page.key);
  const k = ac.real && cal ? ac.real.mmPerPx / cal.mmPerPt : ac.ptPerPx;   // points on this sheet per pixel
  const [w, h] = [ac.px[0] * k, ac.px[1] * k];
  const key = 'area|' + ac.at + '|' + s.page.key;
  pasteRun = pasteRun.key === key ? { key, n: pasteRun.n + 1 } : { key, n: 1 };
  let center;
  if (how === 'pointer' && pt) center = view.toUser(pt[0], pt[1]);
  else {
    const d = screenStep(PASTE_STEP * (pasteRun.n - 1));
    const m = view.toUser(view.w / 2, view.h / 2);
    center = [m[0] + d[0], m[1] + d[1]];
  }
  const extra = ac.overlay ? { style: { ...styleFor('image'), opacity: ac.overlay.opacity } } : {};
  commitNew(newMarkup('image', { rect: placeRect(center, w, h, view.seenRotate), img: ac.src, label: ac.label || t('area.label'), subject: TYPES.image.subject }, extra), t(ac.overlay ? 'area.paste.undo.overlay' : 'area.paste.undo'));
  status(t(ac.overlay ? 'area.paste.done.overlay' : 'area.paste.done') + ' ' + t(ac.real && cal ? 'area.paste.real' : 'area.paste.paper') + ' ' + t('area.paste.lineup'));
  return true;
}

In detail

Method
Preset scale: mmPerPt = (25.4 ÷ 72) × ratio. Two-point calibration: mmPerPt = typed real length ÷ picked distance. Length = points × mmPerPt. Area = shoelace area in points² × mmPerPt², less the cut-outs. Typed lengths are read by regular expressions (feet and inches, fractions, metric suffixes) and, for other units, by the shared unit table.
Number types
64-bit numbers throughout. A calibration is { mmPerPt, unit, label }.
Tolerances
No geometric tolerance. A calibration is accepted when 0 < mmPerPt < 1e7. Text rounding: feet and inches to 1/16 inch; feet 2 decimals; inches 2 decimals; meters 3 decimals; millimeters whole. Areas: square feet 2 decimals, square inches 1, square meters 3, square millimeters whole. A calibration is carried to a replacement sheet when both page sides differ by at most 1 point.
Limits
formatLength and formatArea take no precision argument, so the shown value is always rounded as listed. Every comma in a typed length is read as a decimal point. measureArea subtracts cut-outs without testing that they lie inside the outline; that test is made when a cut-out is drawn, not when one is read from a file. The shoelace sum uses absolute page coordinates.

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