Resources Numerical accuracy Calculation policy

Typed lengths, angles and pitches

Turns what a person types (feet and inches, fractions, metric, a point or a polar step, a roof pitch) into millimeters and degrees, and formats areas, angles and pitches for display.

How the precision is kept

Typed text is parsed into 64-bit numbers and multiplied by exact factors: 25.4 mm per inch and 12 inches per foot. Feet, inches and a fraction are added as inches first and multiplied by 25.4 once. A pitch typed as a rise over a run goes through one arctangent. Display functions round only the text they return.

Open item. Take the sign of a feet-and-inches entry from the typed minus sign so that a negative value with 0 feet stays negative.

Calculations under this policy

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

model-units.js BimCraft 0.8.1 64-bit 6 calculations Reviewed

Tests tools/bimcraft-test/model-test.mjs, bin/test-units.mjs

Typed length to millimeters

Exact

Reads a typed length in feet and inches, inches with a fraction, or any unit mark, as millimeters.

mm = sign × (|feet| × 12 + inches + n ÷ d) × 25.4

Feet, inches and the fraction are summed in inches and multiplied by 25.4 once. The sign is Math.sign of the feet number with 0 replaced by 1, so an entry of minus zero feet and some inches comes out positive. A fraction with a zero denominator is not guarded in the feet-and-inches and whole-plus-fraction forms and gives Infinity.

  • model-units.js
  • lines 61 to 77
  • BimCraft 0.8.1
/** Text → millimetres (NaN when it is not a length). Bare numbers: inches ('ftin') or millimetres ('mm'). */
export function parseLen(text, units = 'ftin') {
  const s = String(text ?? '').trim();
  if (!s) return NaN;
  // 12' 6" without the dash, 12'6, 6 1/2 (inches), 1/2"
  let v = parseLength(s, FACTOR[units] || INCH);
  if (Number.isFinite(v)) return v;
  let m = s.match(/^(-?\d+(?:\.\d+)?)\s*'\s*-?\s*(\d+(?:\.\d+)?)?(?:\s+(\d+)\/(\d+))?\s*"?$/);
  if (m) return Math.sign(Number(m[1]) || 1) * (Math.abs(Number(m[1])) * 12 + Number(m[2] || 0) + (m[3] ? Number(m[3]) / Number(m[4]) : 0)) * INCH;
  m = s.match(/^(\d+)\s+(\d+)\/(\d+)$/);
  if (m && !isMetric(units)) return (Number(m[1]) + Number(m[2]) / Number(m[3])) * INCH;
  m = s.match(/^(-?)\s*(\d+)\/(\d+)$/);
  if (m && !isMetric(units) && Number(m[3])) return (m[1] ? -1 : 1) * Number(m[2]) / Number(m[3]) * INCH;
  // any other length mark in the shared table (0.7 UNITS): 2.5 dm, 3 yd, 1 ch, 10 US ft, 400 nm
  const r = parseTo(s, 'length', 'mm');
  return r.ok ? r.value : NaN;
}

Typed point, step or polar entry

Exact

Reads a relative step, an absolute point, a length at an angle, or a bare length from the keyboard.

Each number is parsed with parseLen; the angle of a polar entry is read with Number in degrees and is not converted here. A length must be finite and above 0.

  • model-units.js
  • lines 79 to 105
  • BimCraft 0.8.1
/**
 * What the wall tool (and anything placed by points) takes from the keyboard after the first point:
 *   12'6"            a length along the direction the pointer shows       → { kind: 'len', len }
 *   12'6"<30         a length at an angle (degrees, counter-clockwise from +X) → { kind: 'polar', len, angle }
 *   @1200,-300       a step from the last point                            → { kind: 'rel', dx, dy }
 *   1200,3400        a point                                               → { kind: 'abs', x, y }
 * → null when it is none of these.
 */
export function parseTyped(text, units = 'ftin') {
  const s = String(text ?? '').trim();
  if (!s) return null;
  const pair = (t) => {
    const parts = splitPair(t);
    if (!parts) return null;
    const a = parseLen(parts[0], units), b = parseLen(parts[1], units);
    return Number.isFinite(a) && Number.isFinite(b) ? [a, b] : null;
  };
  if (s.startsWith('@')) { const p = pair(s.slice(1)); return p ? { kind: 'rel', dx: p[0], dy: p[1] } : null; }
  const lt = s.indexOf('<');
  if (lt > 0) {
    const len = parseLen(s.slice(0, lt), units), angle = Number(s.slice(lt + 1).trim().replace(/°$/, ''));
    return Number.isFinite(len) && len > 0 && Number.isFinite(angle) ? { kind: 'polar', len, angle } : null;
  }
  if (/[,;]/.test(s)) { const p = pair(s); return p ? { kind: 'abs', x: p[0], y: p[1] } : null; }
  const len = parseLen(s, units);
  return Number.isFinite(len) && len > 0 ? { kind: 'len', len } : null;
}

Typed roof pitch to degrees

64-bit

Reads a pitch typed as a rise over a run, or in degrees.

degrees = atan(rise ÷ run) × 180 ÷ π

One division, one Math.atan and one conversion to degrees. A run of 0 returns NaN. A plain number is taken as degrees unchanged.

  • model-units.js
  • lines 123 to 130
  • BimCraft 0.8.1
/** A roof pitch typed as a rise over 12 ("6/12", "6:12", "6 in 12") or in degrees ("30", "30°") → degrees, or NaN. */
export function parsePitch(text) {
  const s = String(text ?? '').trim().replace(/°$/, '').trim();
  const m = s.match(/^(\d+(?:\.\d+)?)\s*(?:\/|:|in)\s*(\d+(?:\.\d+)?)$/i);
  if (m) { const run = Number(m[2]); return run > 0 ? Math.atan(Number(m[1]) / run) * 180 / Math.PI : NaN; }
  const d = Number(s);
  return Number.isFinite(d) && s !== '' ? d : NaN;
}

Area as text

64-bit

Formats square millimeters as m² or ft² for labels.

m² = round(mm² ÷ 10⁴) ÷ 100; ft² = round(mm² ÷ 304.8² × 10) ÷ 10

The factors 1e6 mm² per m² and 304.8² mm² per ft² are exact. The text is rounded to 0.01 m² or 0.1 ft²; only the returned text is rounded.

  • model-units.js
  • lines 117 to 121
  • BimCraft 0.8.1
/** Square millimetres → m² (to 0.01) or ft² (to 0.1), as text with the unit. */
export function fmtArea(mm2, units = 'ftin') {
  if (isMetric(units)) return (Math.round(mm2 / 1e4) / 100).toFixed(2) + ' m²';   // i18n-ignore: unit symbol
  return (Math.round(mm2 / (304.8 * 304.8) * 10) / 10).toFixed(1) + ' ft²';   // i18n-ignore: unit symbol
}

Pitch as text

64-bit

Formats a pitch in degrees as a rise in 12, or as degrees.

rise = round(tan(θ) × 12 × 4) ÷ 4

The rise is rounded to a quarter in 12 and degrees to 0.1°; only the returned text is rounded.

  • model-units.js
  • lines 131 to 135
  • BimCraft 0.8.1
/** A pitch as text: a rise over 12 to 1/4 in feet and inches, else degrees to 0.1°. */
export function fmtPitch(deg, units = 'ftin') {
  if (!isMetric(units)) { const r = Math.round(Math.tan(deg * Math.PI / 180) * 12 * 4) / 4; return r + '/12'; }
  return (Math.round(deg * 10) / 10) + '°';
}

Angle as text

64-bit

Formats an angle in degrees, wrapped to 0 to 360.

a = round(((deg mod 360) + 360) mod 360 × 10) ÷ 10

The angle is wrapped with two remainders and rounded to 0.1°; a result of 360 is shown as 0. Only the returned text is rounded.

  • model-units.js
  • lines 115 to 116
  • BimCraft 0.8.1
/** An angle in degrees as text, to 0.1°. */
export const fmtAngle = (deg) => { let a = Math.round(((deg % 360) + 360) % 360 * 10) / 10; if (a === 360) a = 0; return a + '°'; };

In detail

Method
Regular expressions pick out feet, inches and fractions; other unit marks go through the shared unit table (parseTo). A pitch typed as rise over run becomes atan(rise ÷ run) in degrees.
Number types
64-bit numbers. The inch factor 25.4 and the square foot factor 304.8 × 304.8 are exact.
Limits
A negative feet-and-inches value whose feet part is 0 (typed as -0 feet and some inches) is returned positive, because the sign is taken from the feet number. Area text is rounded to 0.01 m² or 0.1 ft², angle text to 0.1° and pitch text to a quarter in 12.

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