Resources Numerical accuracy Calculation policy

Family editor: sketch points, handles and solved sizes

Changes a family's recipe when a point is moved or a handle is dragged, solving for the parameter that puts a face or a hotspot where it was dragged.

How the precision is kept

A dragged handle is turned into a parameter value by a numerical solve through the family's formulas: a secant search, then a bracket and bisection, stopping at 1e-7 in the dragged coordinate. The value found is then rounded to 0.001 before it is kept, so a solved size carries three decimals of a millimeter, not the solver's tolerance.

Open item. Store moved sketch points with full 64-bit precision (17 significant digits) instead of the 12 digits formatNumber keeps.

Calculations under this policy

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

family-edit.js BimCraft 0.8.1 64-bit 10 calculations Reviewed

Tests tools/bimcraft-test/families-test.mjs

Snap to a step

64-bit

A sketch coordinate moved to the nearest multiple of the grid step.

v → round(v ÷ step) × step

One division, a rounding to a whole number and one multiplication. With a step that binary cannot hold exactly, such as 0.1, the product can differ from the decimal multiple in the last digit.

  • family-edit.js
  • line 563
  • BimCraft 0.8.1
export const snap = (v, step) => (step > 0 ? Math.round(v / step) * step : v);

Quarter-arc bulge

64-bit

The bulge that makes a segment a quarter circle, and the cycle straight, left arc, right arc.

bulge = tan(90° ÷ 4) = tan(π ÷ 8) = √2 − 1

Computed with Math.tan and then rounded to 12 decimals before it is stored, so the stored bulge is 0.414213562373 and the arc is a quarter circle to about 12 digits, not 16.

  • family-edit.js
  • lines 565 to 567
  • BimCraft 0.8.1
export const QUARTER = Math.tan(Math.PI / 8);
export function cycleArc(b) { const x = Number(b) || 0; return x === 0 ? round12(QUARTER) : x > 0 ? round12(-QUARTER) : 0; }
const round12 = (x) => Math.round(x * 1e12) / 1e12;

Move a sketch point

64-bit

A profile or path point moved to a new place.

The new coordinates are written into the recipe as text by formatNumber from expr.js, which keeps 12 significant digits. A point whose coordinates are expressions is refused, so formulas are never overwritten.

  • family-edit.js
  • lines 578 to 587
  • BimCraft 0.8.1
/** A point moved to (u, v) (a path point keeps its z). A point bound to parameters is refused (change it in the table). */
export function movePoint(fam, id, i, [u, v]) {
  return editPoints(fam, id, (pts) => {
    const p = pts[i];
    if (!p) return err('fam.err.points', { id });
    if (literal(p[0]) === null || literal(p[1]) === null) return err('fam.err.bound');
    p[0] = formatNumber(u); p[1] = formatNumber(v);
    return null;
  });
}

Set a segment's bulge

64-bit

Makes a profile segment straight or an arc of a given bulge.

bulge = tan(angle ÷ 4), rounded to 12 decimals, |bulge| ≤ 10

The bulge is rounded to 12 decimals before it is stored. A bulge whose size is above 10 is refused; a bulge that rounds to 0 makes the segment straight.

  • family-edit.js
  • lines 605 to 614
  • BimCraft 0.8.1
/** The segment from point i to the next one: straight (0) or an arc (bulge = tan(angle / 4); + bulges to the left). */
export function setBulge(fam, id, i, b) {
  return editPoints(fam, id, (pts, s) => {
    if (s.op === 'sweep' || !pts[i]) return err('fam.err.points', { id });
    const x = round12(Number(b) || 0);
    if (Math.abs(x) > 10) return err('fam.err.points', { id });
    if (x) pts[i][2] = x; else pts[i].length = 2;
    return null;
  });
}

Bounds of a family

64-bit

The box round a family's visible solids.

Minimum and maximum over each solid's plan bounds and height range by exact comparison. The box is as good as those two: it inherits what planBounds and the height range leave out.

  • family-edit.js
  • lines 617 to 628
  • BimCraft 0.8.1
/** The family's visible solids' bounds (x0, y0, z0, x1, y1, z1), or null. */
export function boundsOf(ev) {
  const b = [Infinity, Infinity, Infinity, -Infinity, -Infinity, -Infinity];
  for (const s of ev.solids.values()) {
    if (!s.visible) continue;
    const z = solidZ(s), p = planBounds(s);
    if (!p) continue;
    b[0] = Math.min(b[0], p[0]); b[1] = Math.min(b[1], p[1]); b[3] = Math.max(b[3], p[2]); b[4] = Math.max(b[4], p[3]);
    b[2] = Math.min(b[2], z[0]); b[5] = Math.max(b[5], z[1]);
  }
  return Number.isFinite(b[0]) ? b : null;
}

Height range of a solid

64-bit

The lowest and highest point of one solid.

sweep: path z ± radius; revolve on the floor plane: at ± largest |profile point|

Exact for boxes and extrusions. A sweep takes its path's heights plus and minus the radius. A revolve on the horizontal plane takes the largest distance of a profile point from the plane's origin, not from the revolve axis, so its range is right only when the axis passes through that origin.

  • family-edit.js
  • lines 629 to 637
  • BimCraft 0.8.1
/** A solid's height range (z0, z1). */
function solidZ(s) {
  if (s.op === 'box') return [s.box[2], s.box[5]];
  if (s.op === 'sweep') { const zs = s.path.map((p) => p[2]); return [Math.min(...zs) - s.radius, Math.max(...zs) + s.radius]; }
  const vs = s.profile.map((p) => (s.plane === 'xy' ? s.at : p[1]));
  if (s.op === 'extrude' && s.plane === 'xy') return [s.at, s.at + s.depth];
  if (s.op === 'revolve' && s.plane === 'xy') { const r = Math.max(...s.profile.map((p) => Math.hypot(p[0], p[1]))); return [s.at - r, s.at + r]; }
  return [Math.min(...vs), Math.max(...vs)];
}

Which parameter moves which face

64-bit

Finds, for each input length, the face of the family it pushes out.

nudge the parameter by 10 mm; the face that moves most, and by more than 0.5 mm, is its handle

A finite difference of the family's bounds for a 10 mm change, compared with 0.5 mm. It decides where a handle is shown; the size itself comes from the solve when the handle is dragged.

  • family-edit.js
  • lines 640 to 666
  • BimCraft 0.8.1
/** Handles for the size: one per input length parameter that pushes a face of the family out, at the middle of that face
 *  (Width → +x, Depth → +y, Height → +z for a table; a door's Width at its right jamb, Height at its head). */
export function sizeHandles(fam, values, ctx = {}) {
  const ev = evaluateFamily(fam, values, ctx);
  if (!ev.ok) return [];
  const B = boundsOf(ev);
  if (!B) return [];
  const out = [], taken = new Set();
  const lens = inputParams(fam).filter((p) => p.kind === 'length');
  // the parameter that moves each face the most (a 10 mm nudge), named ones first
  const order = [...lens].sort((a, b) => rank(a.key) - rank(b.key));
  for (const p of order) {
    const v = sizeValues(fam, values), e2 = evaluateFamily(fam, { ...v, [p.key]: v[p.key] + 10 }, ctx);
    if (!e2.ok) continue;
    const B2 = boundsOf(e2);
    if (!B2) continue;
    let best = -1, bd = 0.5;
    for (let a = 0; a < 3; a++) { const d = B2[a + 3] - B[a + 3]; if (d > bd && !taken.has(a)) { bd = d; best = a; } }
    if (best < 0) continue;
    taken.add(best);
    const c = [(B[0] + B[3]) / 2, (B[1] + B[4]) / 2, (B[2] + B[5]) / 2];
    c[best] = B[best + 3];
    out.push({ p: c, axis: AX[best], label: p.label || p.key, target: { kind: 'size', key: p.key, axis: best } });
    if (taken.size === 3) break;
  }
  return out;
}

Solve for a parameter

64-bit

The parameter value that puts a face or a hotspot at the dragged position.

secant: c = b − f(b)·(b − a) ÷ (f(b) − f(a)); then bisection on a bracket; stop at |f| < 10⁻⁷

The secant loop runs 40 steps at most and stops when the two function values are equal, which guards its division. If it does not reach 1e-7, a sign change is searched at ±1, ±2, ±4 … up to 1e7 from the start and bisected for 200 steps at most, stopping at |f| < 1e-7 or an interval below 1e-9. Both limits are absolute, in the unit of the dragged coordinate.

  • family-edit.js
  • lines 719 to 744
  • BimCraft 0.8.1
/** Solve g(x) = target for x near x0 (secant, then a bracket and bisection). */
export function solve1(g, x0, target) {
  const f = (x) => { const y = g(x); return Number.isFinite(y) ? y - target : NaN; };
  let a = x0, fa = f(a);
  if (Number.isFinite(fa) && Math.abs(fa) < 1e-7) return a;
  let b = x0 + Math.max(1, Math.abs(x0) * 0.01), fb = f(b);
  for (let k = 0; k < 40 && Number.isFinite(fa) && Number.isFinite(fb) && fb !== fa; k++) {
    const c = b - fb * (b - a) / (fb - fa), fc = f(c);
    if (Number.isFinite(fc) && Math.abs(fc) < 1e-7) return c;
    a = b; fa = fb; b = c; fb = fc;
  }
  const g0 = f(x0);
  for (let step = 1; step < 1e7; step *= 2) for (const s of [x0 + step, x0 - step]) {
    const gs = f(s);
    if (Number.isFinite(g0) && Number.isFinite(gs) && Math.sign(gs) !== Math.sign(g0)) {
      let lo = x0, hi = s, glo = g0;
      for (let k = 0; k < 200; k++) {
        const mid = (lo + hi) / 2, gm = f(mid);
        if (!Number.isFinite(gm)) break;
        if (Math.abs(gm) < 1e-7 || Math.abs(hi - lo) < 1e-9) return mid;
        if (Math.sign(gm) === Math.sign(glo)) { lo = mid; glo = gm; } else hi = mid;
      }
    }
  }
  return null;
}

Drag a hotspot

64-bit

The size values after a hotspot is dragged along its axis.

x = solve(position(x) = dragged position); kept value = round(x × 1000) ÷ 1000

The solved value is rounded to 0.001 before it is returned, coarser than the solver's 1e-7. An integer parameter is rounded to a whole number and a length must come out above zero.

  • family-edit.js
  • lines 298 to 317
  • BimCraft 0.8.1
/**
 * Drag a hotspot to `to` ([x, y, z] in the family's axes): the number parameter it drives is solved so that the hotspot's position along its axis
 * is where it was dragged, through any formulas between them → { ok, values } (the size's values with that one changed, for the caller to place as
 * a size), or the error (a length that would not be positive, no value that gets there).
 */
export function dragHotspot(fam, values, id, to, ctx = {}) {
  const h = (fam.hotspots || []).find((x) => x.id === id);
  if (!h) return err('fam.err.hotspot', { id });
  const v = { ...sizeValues(fam, values) }, axis = 'xyz'.indexOf(h.axis), key = h.drives, p = fam.params.find((x) => x.key === key);
  if (!p) return err('fam.err.hotspot', { id });
  const src = h.at[axis], want = to[axis];
  if (!Number.isFinite(want)) return err('fam.err.handle');
  const g = (x) => fieldValue(fam, { ...v, [key]: x }, ctx, src);
  let x = solve1(g, v[key], want);
  if (x === null) return err('fam.err.handle');
  if (p.kind === 'integer') x = Math.round(x);
  if (p.kind === 'pen') x = Math.min(16, Math.max(1, Math.round(x)));
  if (p.kind === 'length' && !(x > 0)) return err('fam.err.handle');
  return { ok: true, values: { ...v, [key]: round3(x) }, key };
}

Drag a size or solid handle

64-bit

The recipe or the size values after a handle is dragged to a position.

literal field: value = round(position × 1000) ÷ 1000; bound field: the input parameter solved, then rounded to 0.001

A field that is a plain number is set to the dragged position rounded to 0.001. A field bound to parameters moves the input it depends on, through the solve, and that value is rounded to 0.001 too. An extrusion's depth is the dragged position minus its start.

  • family-edit.js
  • lines 755 to 790
  • BimCraft 0.8.1
export function dragHandle(fam, values, h, to, ctx = {}) {
  const v = sizeValues(fam, values), T = h.target;
  if (T.kind === 'hotspot') {   // `to` is where along the hotspot's own axis it was dragged
    const hs = (fam.hotspots || []).find((x) => x.id === T.id);
    if (!hs) return err('fam.err.hotspot', { id: T.id });
    const vec = [0, 0, 0];
    vec['xyz'.indexOf(hs.axis)] = to;
    const r = dragHotspot(fam, values, T.id, vec, ctx);
    return r.ok ? { ok: true, fam, values: { ...fullValues(fam, values), ...r.values } } : r;
  }
  // the parameter named for the handle's axis (Width for x, Depth for y, Height for z) when the field uses it, else the first
  const pick = (cands) => cands.find((k) => rank(k) === T.axis) || [...cands].sort((a, b) => rank(a) - rank(b))[0];
  if (T.kind === 'size') {
    const g = (x) => { const e = evaluateFamily(fam, { ...v, [T.key]: x }, ctx); const B = e.ok ? boundsOf(e) : null; return B ? B[T.axis + 3] : NaN; };
    const x = solve1(g, v[T.key], to);
    if (x === null || !(x > 0)) return err('fam.err.handle');
    return { ok: true, fam, values: { ...v, [T.key]: round3(x) } };
  }
  const raw = fam.solids.find((s) => s.id === T.id);
  if (!raw) return err('fam.err.solid', { id: T.id });
  const want = T.field === 'depth' ? to - (T.base ?? 0) : to;
  const src = raw[T.field];
  if (literal(src) !== null) {
    const r = setSolid(fam, T.id, { [T.field]: round3(want) });
    return r.ok ? { ok: true, fam: r.fam, values: v } : r;
  }
  const ins = inputsOf(fam, src);
  if (!ins.length) return err('fam.err.handle');
  const key = pick(ins);
  const g = (x) => fieldValue(fam, { ...v, [key]: x }, ctx, src);
  const x = solve1(g, v[key], want);
  if (x === null) return err('fam.err.handle');
  const p = inputParams(fam).find((q) => q.key === key);
  if (p.kind === 'length' && !(x > 0)) return err('fam.err.handle');
  return { ok: true, fam, values: { ...v, [key]: round3(x) } };
}
family-ui.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

Tests None found by the review

Position of a 3D grip along its axis

64-bit

How far along a hotspot's axis the pointer's ray comes closest, when a grip is dragged in the 3D view.

t = (b·e − c·d) ÷ (c − b²), b = axis · ray, c = ray · ray, d = axis · w, e = ray · w

The closest approach of two lines. The divisor c − b² goes toward zero as the ray lines up with the axis, where two nearly equal numbers are subtracted; the drag is ignored when its size is 1e-9 or less. That limit is absolute and is not scaled to the length of the ray's direction.

  • family-ui.js
  • lines 525 to 530
  • BimCraft 0.8.1
const dot = (u, v) => u[0] * v[0] + u[1] * v[1] + u[2] * v[2];
const w = [x.at[0] - ray.o[0], x.at[1] - ray.o[1], x.at[2] - ray.o[2]], b = dot(x.dir, ray.d), cc = dot(ray.d, ray.d), d = dot(x.dir, w), ee = dot(ray.d, w), den = cc - b * b;
if (!(Math.abs(den) > 1e-9)) return;   // looking along the axis: no place to read
const tt = (b * ee - cc * d) / den;
const to = [x.local[0], x.local[1], x.local[2]];
to['xyz'.indexOf(x.axis)] += tt;

Pointer in the family's own axes

64-bit

The plan pointer position turned into the axes of a placed family, for dragging a hotspot in the plan.

(dx, dy) = p − element position; local = (dx·cos a − dy·sin a, dx·sin a + dy·cos a), a = −rotation × π ÷ 180

One subtraction and one rotation in 64-bit. The rotation is the element's angle as read back from the file.

  • family-ui.js
  • lines 601 to 602
  • BimCraft 0.8.1
const { el: e, fam } = R.hot, a = -e.rot * Math.PI / 180, c = Math.cos(a), s = Math.sin(a);
const dx = p[0] - e.x, dy = p[1] - e.y, to = [dx * c - dy * s, dx * s + dy * c, 0];   // the pointer in the family's own axes

Rotation shown in Properties

64-bit

A placed family's rotation in the Properties panel, and the value taken when it is changed.

shown = round(rotation × 100) ÷ 100

The field shows the rotation rounded to 2 decimals of a degree. A typed value is written as typed, with a decimal comma read as a point; the rounded text is not written back unless the field is edited.

  • family-ui.js
  • lines 566 to 567
  • BimCraft 0.8.1
const r = el('input', { type: 'text', className: 'bc-input bc-num', value: String(Math.round(e.rot * 100) / 100), 'data-mp-famrot': '', size: 6 });
r.addEventListener('change', () => { const v = Number(r.value.replace(',', '.')); if (Number.isFinite(v)) A.commit(moveFamilyElement(e.guid, { rot: v }), meta); else r.value = String(e.rot); });

In detail

Method
Secant iteration from the current value (40 steps at most); if it does not settle, the sign change is bracketed by doubling steps from 1 up to 1e7 on both sides and bisected (200 steps at most).
Number types
64-bit numbers. Sketch points are stored in the recipe as text.
Tolerances
Solve: |g(x) − target| < 1e-7, or a bisection interval below 1e-9, both absolute. Solved values and dragged literal fields are rounded to 0.001. Bulges are rounded to 12 decimals and limited to ±10. A size handle is given to the parameter that moves a face by more than 0.5 mm for a 10 mm nudge.
Limits
A moved sketch point is stored by expr.js formatNumber, which keeps 12 significant digits. The solver returns null when no value within ±1e7 changes the sign.

Approach: 64-bit, stated limit

The arithmetic is 64-bit, but the method or the data sets the accuracy: a solver tolerance, a mesh, a step size, a datum shift, a table. The limit is stated where the result is shown.

Every policy that takes this approach