Resources Numerical accuracy Calculation policy

AR on site: lining the model up with the building

Fits the model to the real site from two reference points pointed at with a phone, and checks that fit on a later visit.

How the precision is kept

The fit is a closed-form 64-bit calculation: a turn about the vertical axis and a move, with the scale left at 1. Its accuracy is set by how well the phone's hit test places the two site points, which the code reports as a residual and a scale mismatch. The alignment kept in the file stores yaw, the two shifts, the height shift and the residual as 64-bit numbers without rounding.

Calculations under this policy

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

xr/xr-core.js BimCraft 0.8.1 64-bit 3 calculations Reviewed

Tests bin/test-xr.mjs

Two-point fit

64-bit

The turn and move that carry two model points onto two site points, with the remaining error.

yaw = atan2(Δw) − atan2(Δm); t = mid(w) − R mid(m); residual = max(|R m₁ + t − w₁|, |R m₂ + t − w₂|); scale off = |Δw| ÷ |Δm| − 1

All 64-bit. The yaw is a difference of two angles, each from points at least 0.5 m apart. The scale mismatch subtracts 1 from a ratio near 1, so it carries fewer digits than the ratio; it is reported, not applied.

  • xr/xr-core.js
  • lines 188 to 208
  • BimCraft 0.8.1
// ---- AR on site (0.8 EG.21, apps-mount_PART_5; spike docs/spikes/EG.21_PART_2.md and _PART_4.md). Maths and decisions only: startAR in xr-session.js drives them. ----
// arSupport(env) → { ok, route, reason, init }  what this browser can do for AR (Android Chrome over https: 'webxr-ar'; iPhone/iPad: a 3D viewer link; an Android WebView:
//   open the page in Chrome; else the reason). alignPoints(model[2], world[2]) → { yaw, tx, tz, scaleOff, residual, apply } a rigid fit (a turn about the vertical axis and a
//   move, scale stays 1) from two reference points pointed at on site (world, metres, Y up) to the same two points in the model (Y up). saveAlignment/loadAlignment: the
//   small record kept in the file. recheck(saved, modelPoint, worldPoint) → the second visit's one-point check: keep (< 5 cm), move (< 50 cm), again.
export function alignPoints(model, world) {
  const [m1, m2] = model, [w1, w2] = world;
  const dm = [m2[0] - m1[0], m2[2] - m1[2]], dw = [w2[0] - w1[0], w2[2] - w1[2]];
  const lm = Math.hypot(dm[0], dm[1]), lw = Math.hypot(dw[0], dw[1]);
  if (lm < 0.5 || lw < 0.5) throw Object.assign(new Error('The two points are too close to give a direction (under 0.5 m).'), { code: 'close' });
  const yaw = Math.atan2(dw[1], dw[0]) - Math.atan2(dm[1], dm[0]);
  const c = Math.cos(yaw), s = Math.sin(yaw);
  const rot = (p) => [p[0] * c - p[2] * s, p[0] * s + p[2] * c];
  // translation so the midpoints agree (spreads the pointing error over both points)
  const mid = (a, b) => [(a[0] + b[0]) / 2, (a[2] + b[2]) / 2];
  const rm = rot([mid(m1, m2)[0], 0, mid(m1, m2)[1]]), wm = mid(w1, w2);
  const tx = wm[0] - rm[0], tz = wm[1] - rm[1];
  const to = (p) => { const r = rot(p); return [r[0] + tx, r[1] + tz]; };
  const e1 = Math.hypot(to(m1)[0] - w1[0], to(m1)[1] - w1[2]), e2 = Math.hypot(to(m2)[0] - w2[0], to(m2)[1] - w2[2]);
  return { yaw, tx, tz, scaleOff: lw / lm - 1, residual: Math.max(e1, e2), apply: (p) => { const r = to(p); return [r[0], p[1], r[1]]; } };
}

A saved alignment applied

64-bit

A model point moved to the site by the alignment kept in the file.

(x′, z′) = (x cos yaw − z sin yaw + t_x, x sin yaw + z cos yaw + t_z)

The sine and cosine are taken once from the stored yaw; the height passes through unchanged. A record whose yaw or shifts are not finite numbers is refused.

  • xr/xr-core.js
  • lines 227 to 231
  • BimCraft 0.8.1
export function loadAlignment(o) {
  if (!o || o.format !== 'cadcraft-ar-alignment' || !['yaw', 'tx', 'tz'].every((k) => Number.isFinite(o[k]))) return null;
  const c = Math.cos(o.yaw), s = Math.sin(o.yaw);
  return { ...o, ty: Number.isFinite(o.ty) ? o.ty : 0, apply: (p) => [p[0] * c - p[2] * s + o.tx, p[1], p[0] * s + p[2] * c + o.tz] };
}

Re-check on a later visit

64-bit

How far the first reference point is from where the saved alignment puts it, and what to do about it.

offset = √(Δx² + Δz²); keep < 0.05 m ≤ move < 0.5 m ≤ align again

A horizontal distance by Math.hypot in 64-bit, compared with two fixed thresholds in meters. Height is not part of the offset.

  • xr/xr-core.js
  • lines 232 to 239
  • BimCraft 0.8.1
export function recheck(saved, modelPoint, worldPoint) {
  const a = loadAlignment(saved);
  if (!a) return { ok: false, offset: null, adjust: null, advice: 'no-saved' };
  const p = a.apply(modelPoint), dx = worldPoint[0] - p[0], dz = worldPoint[2] - p[2], offset = Math.hypot(dx, dz);
  if (offset < 0.05) return { ok: true, offset, adjust: [0, 0], advice: 'keep' };
  if (offset < 0.5) return { ok: true, offset, adjust: [dx, dz], advice: 'move' };
  return { ok: false, offset, adjust: null, advice: 'again' };
}
xr/xr-session.js BimCraft 0.8.1 64-bit 1 calculation Reviewed

Tests tools/xr-test/browser-xr.mjs

Placing a reference point

64-bit

Takes the site point under the phone's center mark and runs the fit or the re-check with it.

t_y = (w₁_y + w₂_y) ÷ 2 − (m₁_y + m₂_y) ÷ 2

The site point is a copy of the hit-test pose position the browser reports that frame. The height shift is the difference of two means in 64-bit. After a re-check that says move, the stored shifts are increased by the measured offset and saved again without rounding.

  • xr/xr-session.js
  • lines 781 to 803
  • BimCraft 0.8.1
function place() {
  if (!hit) { say(text('nohit')); return; }
  const w = hit.slice();
  if (step === 'align1') { world1 = w; dot(w); step = 'align2'; say(); return; }
  if (step === 'align2') {
    try {
      const a = alignPoints([refs[0], refs[1]], [world1, w]);
      a.ty = (world1[1] + w[1]) / 2 - (refs[0][1] + refs[1][1]) / 2;
      align = a; setWorld(a); worldG.visible = true; step = 'aligned'; dot(w);
      o.saveAlignment?.(saveAlignment(a));
      say(text('lined', { m: o.format(a.residual) }));
    } catch (e) { world1 = null; step = 'align1'; marks.clear(); say(e && e.code === 'close' ? text('close') : String(e?.message || e)); }
    return;
  }
  if (step === 'recheck') {
    const r = recheck(saveAlignment({ ...align, ty: align.ty || 0 }), refs[0], w);
    advice = r.advice;
    if (r.advice === 'move') { align = { ...align, tx: align.tx + r.adjust[0], tz: align.tz + r.adjust[1] }; setWorld(align); o.saveAlignment?.(saveAlignment(align)); }
    if (r.advice === 'again') { step = 'align1'; world1 = null; marks.clear(); worldG.visible = false; say(text('re.again', { m: o.format(r.offset) })); return; }
    step = 'aligned';
    say(text('re.' + r.advice, { m: o.format(r.offset) }));
  }
}

In detail

Method
Yaw from the difference of two atan2 directions; the translation makes the midpoints of the two pairs agree; the residual is the larger of the two end errors. A re-check moves one model point through the saved fit and compares it with the site point.
Number types
64-bit numbers; site points read from the WebXR hit-test pose position.
Tolerances
The two points must be at least 0.5 m apart in the model and on site. Re-check: under 0.05 m keep, under 0.5 m move by the offset, else align again. All absolute, in meters.
Limits
Two points fix only a turn about the vertical and a horizontal move; height is the mean difference of the two points and tilt is not fitted. Because scale stays 1, a difference in the two distances is split between the points and shows as the residual.

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