Resources Numerical accuracy Calculation policy

Sketch editor: typed sizes and dimension values

Takes the numbers a person types in the sketch editor (a dimension's value, the sizes of a shape being drawn) and turns them into stored values and point coordinates.

How the precision is kept

Typed text is converted with Number, which gives the nearest 64-bit number to the decimal typed, and the points of a shape drawn from typed sizes are one 64-bit expression each. The input box is filled with the current value shown to at most 4 decimals, and Enter or leaving the box applies whatever text it holds, so a value accepted without retyping is stored as that 4-decimal text.

Open item. Fill the input box with the full value, or leave the stored value untouched when the text was not edited, so that accepting a dimension does not round it to 4 decimals.

Calculations under this policy

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

sketchview.js PartCraft 0.8.1 64-bit 8 calculations Reviewed

Tests bin/test-partcraft.mjs

Typed text to a number

64-bit

Reads a typed size such as 12.5, ⌀20, R5, 30° or 12 mm as a number.

After the spaces, a leading ⌀, Ø or R and a trailing mm or ° are removed, the text must match a plain decimal or exponent pattern and is converted with Number, which rounds correctly to the nearest 64-bit value. Anything else gives null and nothing is stored.

  • sketchview.js
  • lines 1271 to 1277
  • PartCraft 0.8.1
export function parseNumber(s) {
  if (typeof s !== 'string') return null;
  const t = s.replace(/\s+/g, '').replace(/^[⌀ØR]/i, '').replace(/(mm|°)$/i, '');
  if (!/^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(t)) return null;
  const v = Number(t);
  return Number.isFinite(v) ? v : null;
}

Value shown in the input box

64-bit

Opens the input box at a value for the person to accept or change.

box text = fmt(value) (at most 4 decimals)

The box is filled with fmt(value), which vec.js rounds to at most 4 decimals. The number that comes back is parsed from this text, so an unchanged box returns the rounded value, not the one passed in.

  • sketchview.js
  • lines 747 to 764
  • PartCraft 0.8.1
askValue([x, y], value, label, done, typed = null) {
  this.inputRaw = null;
  this.inputTab = null; this.inputBlur = null;
  const inp = this.input;
  inp.hidden = false;
  inp.value = fmt(value);
  inp.setAttribute('aria-label', label);
  const { w } = this.size();
  inp.style.left = Math.max(4, Math.min(x - 45, w - 100)) + 'px';
  inp.style.top = Math.max(4, y - 14) + 'px';
  this.inputCb = done;
  this.inputClosing = false;
  // a key already typed (0.6 PU.1): the box takes the focus now, so the next keys land in it, after that one;
  // `true` = focus now with the value selected (Tab to the next size box)
  if (typed === true) { inp.focus(); inp.select(); return; }
  if (typed !== null) { inp.value = typed; inp.focus(); inp.setSelectionRange(typed.length, typed.length); return; }
  setTimeout(() => { inp.focus(); inp.select(); }, 0);
}

Applying the typed value

64-bit

Takes the text of the input box when it closes and hands the number to whatever asked for it.

The text is applied on Enter and also when the box loses focus; only Escape leaves the old value. A dimension driven by an equation is handed to the host as raw text first.

  • sketchview.js
  • lines 766 to 782
  • PartCraft 0.8.1
inputDone(apply) {
  const cb = this.inputCb;
  if (!cb) return;
  this.inputCb = null;
  this.inputClosing = true;
  const raw = this.input.value.trim();
  const rawCb = this.inputRaw;
  this.inputRaw = null;
  this.hideInput();
  if (apply && rawCb && rawCb(raw)) { /* handled as an equation */ }
  else if (apply) {
    const v = parseNumber(raw);
    if (v === null) this.host.status(t('ui.sketch.not_number'));
    else cb(v);
  }
  this.host.refocus?.();
}

Changing a dimension's value

64-bit

Stores the typed number as the dimension's value.

0 ≤ value; angle ≤ 180; radius and diameter > 0

The stored value is exactly the number parsed from the box. Because the box opens on a 4-decimal text of the current value and a new dimension opens it at once, a measured value such as 12.34567891 becomes 12.3457 unless the box is closed with Escape; the solver then moves the geometry to the rounded value.

  • sketchview.js
  • lines 731 to 745
  • PartCraft 0.8.1
/** Type a dimension's value. `fresh`: the dimension was just made, so the value joins that undo step. */
editDimension(id, fresh = false) {
  const c = this.sk.constraints.find((k) => k.id === id);
  if (!c) return;
  const box = this.dimBoxes.find((b) => b.id === id);
  const at = box ? [box.x + box.w / 2, box.y + box.h / 2] : this.toScreen(dimGeometry(this.sk, c)?.at || [0, 0]);
  this.askValue(at, c.value, S.CONSTRAINTS[c.type].label, (v) => {
    try {
      if (!(v >= 0) || (c.type === 'angle' && v > 180) || ((c.type === 'radius' || c.type === 'diameter') && v <= 0)) throw new Error(c.type === 'angle' ? t('ui.sketch.dim.bad_angle') : t('ui.sketch.dim.positive'));
      this.host.commit(t('ui.sketch.op.change_dimension'), (sk) => { const k = sk.constraints.find((q) => q.id === id); if (k) k.value = v; }, { squash: fresh });
    } catch (err) { this.host.status(err.message); }
  });
  // "=width / 2", or a number for a dimension an equation drives: the parameters handle it (params-panel.js)
  this.inputRaw = this.host.dimTyped ? (raw) => this.host.dimTyped(id, raw) : null;
}

Keeping a typed size

64-bit

Checks a size typed while drawing and keeps it for the shape.

angle ← ((v mod 360) + 360) mod 360; sweep must be in (0, 360); other sizes > 0

Lengths are kept as typed. An angle is brought into [0, 360) with two remainders, which is exact for whole degrees and can change the last bit of other values.

  • sketchview.js
  • lines 842 to 852
  • PartCraft 0.8.1
/** Keep a typed size for the shape being drawn. → false (and why, in the status bar) when it can't be one. */
ddSet(k, v) {
  const st = this.step;
  if (!st) return false;
  if (k === 'ang') { st.dd.ang = ((v % 360) + 360) % 360; this.draw(); return true; }
  if (k === 'sweep' && !(v > 0 && v < 360)) { this.host.status(t('ui.sketch.dd.bad_sweep')); return false; }
  if (!(v > 0)) { this.host.status(t('ui.sketch.dim.positive')); return false; }
  st.dd[k] = v;
  this.draw();
  return true;
}

Point from typed sizes

64-bit

Where the typed length and angle, width and height, diameter, radius or sweep put the next point of the shape being drawn.

line: a + (cos r, sin r) × len, r = angle × π ÷ 180; rectangle: a ± (w, h); arc end: c + R(sweep) · (s − c)

Each coordinate is one 64-bit expression. A typed angle of exactly 0 or 90 modulo 180 also adds a horizontal or vertical relation, which the solver then holds; cos and sin of other angles carry the rounding of the conversion with Math.PI. The click handler also adds the typed length, width, height, diameter or radius as a driving dimension.

  • sketchview.js
  • lines 853 to 882
  • PartCraft 0.8.1
/** Where the typed sizes (and the pointer, for the rest) put the shape's next point, in sketch coordinates. */
ddPoint() {
  const st = this.step, tl = this.tool, dd = st.dd || {}, live = this.ddLive();
  const cur = this.snapAt?.xy || this.cursor || [0, 0];
  const dirFrom = (o, q = cur) => { const dx = q[0] - o[0], dy = q[1] - o[1], l = Math.hypot(dx, dy); return l > 1e-12 ? [dx / l, dy / l] : [1, 0]; };
  if (tl === 'line' || tl === 'centerline') {
    const a = st.a.xy;
    let dir, hv = null;
    if (dd.ang !== undefined) {
      const r = dd.ang * Math.PI / 180, m = dd.ang % 180;
      dir = [Math.cos(r), Math.sin(r)];
      if (this.autoRelations) hv = m === 0 ? 'horizontal' : m === 90 ? 'vertical' : null;
    } else { const q = this.hvSnap(st.a, { xy: cur }); hv = q.hv; dir = dirFrom(a, q.b.xy); }
    st.ddHv = hv;
    const len = dd.len ?? live.len ?? 0;
    return [a[0] + dir[0] * len, a[1] + dir[1] * len];
  }
  if (tl === 'rect') {
    const a = st.a.xy, sx = cur[0] >= a[0] ? 1 : -1, sy = cur[1] >= a[1] ? 1 : -1;
    return [a[0] + sx * (dd.w ?? live.w ?? 0), a[1] + sy * (dd.h ?? live.h ?? 0)];
  }
  if (tl === 'circle') { const c = st.c.xy, r = (dd.d ?? live.d ?? 0) / 2, u = dirFrom(c); return [c[0] + u[0] * r, c[1] + u[1] * r]; }
  if (tl === 'arc' && !st.s) { const c = st.c.xy, r = dd.r ?? live.r ?? 0, u = dirFrom(c); return [c[0] + u[0] * r, c[1] + u[1] * r]; }
  if (tl === 'arc') {
    const c = st.c.xy, s = st.s.xy, sw = (dd.sweep ?? live.sweep ?? 0) * Math.PI / 180 * ((st.turn || 0) < 0 ? -1 : 1);
    const vx = s[0] - c[0], vy = s[1] - c[1];
    return [c[0] + vx * Math.cos(sw) - vy * Math.sin(sw), c[1] + vx * Math.sin(sw) + vy * Math.cos(sw)];
  }
  return cur;
}

Snap to horizontal or vertical

64-bit

Makes a line drawn within 3° of horizontal or vertical exactly so.

angle° = |atan2(Δy, Δx)| × 180 ÷ π; within 3° of 0, 180 or 90: copy the start's y or x to the end

The end point takes the start point's coordinate unchanged, so the two are bit-for-bit equal, and a horizontal or vertical relation is added. Not applied when the end snapped to an existing point or when automatic relations are off.

  • sketchview.js
  • lines 621 to 629
  • PartCraft 0.8.1
/** Lines within 3° of horizontal or vertical snap to it (unless the end snapped to a point). */
hvSnap(a, b) {
  if (b.ref || !this.autoRelations) return { b, hv: null };
  const dx = b.xy[0] - a.xy[0], dy = b.xy[1] - a.xy[1];
  const ang = Math.abs(Math.atan2(dy, dx) * 180 / Math.PI);
  if (Math.min(ang, 180 - ang) < 3) return { b: { ...b, xy: [b.xy[0], a.xy[1]], on: null }, hv: 'horizontal' };
  if (Math.abs(ang - 90) < 3) return { b: { ...b, xy: [a.xy[0], b.xy[1]], on: null }, hv: 'vertical' };
  return { b, hv: null };
}

Point snapped onto a curve

64-bit

The nearest point on a line, circle, arc or spline to the pointer, used when a new point snaps onto a curve.

line: a + t·d, t = ((p − a) · d) ÷ (d · d); circle: c + (p − c) × r ÷ |p − c|

One projection in 64-bit. The line is treated as unbounded; a zero-length line divides by 1e-300 in place of zero and a point at a circle's center divides by 1.

  • sketchview.js
  • lines 1313 to 1324
  • PartCraft 0.8.1
function project(sk, e, p) {
  if (e.type === 'spline') return splineNearest(sk, e, p) || p;
  if (e.type === 'line') {
    const a = S.pointXY(sk, e.p1), b = S.pointXY(sk, e.p2);
    const dx = b[0] - a[0], dy = b[1] - a[1], l2 = dx * dx + dy * dy || 1e-300;
    const t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / l2;
    return [a[0] + t * dx, a[1] + t * dy];
  }
  const c = S.pointXY(sk, e.c), r = S.radiusOf(sk, e);
  const d = Math.hypot(p[0] - c[0], p[1] - c[1]) || 1;
  return [c[0] + (p[0] - c[0]) / d * r, c[1] + (p[1] - c[1]) / d * r];
}

In detail

Method
Text checked against a number pattern, then Number(text); a point at a typed length and angle is a + (cos, sin) × length; an arc's end is the start vector rotated by the typed sweep.
Number types
64-bit numbers.
Tolerances
Lines within 3° of horizontal or vertical are snapped to it. A direction shorter than 1e-12 falls back to the x axis.
Limits
The box shows fmt(value) from vec.js: at most 4 decimals, and a size under 1e-9 as 0. A new dimension opens this box at once with its measured value.

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