Resources Numerical accuracy Calculation policy

Headset review: measuring, section cuts and issue cameras

Works out the distance between two points picked in a headset, the plane of a section cut made there, and the camera and place saved with an issue.

How the precision is kept

The arithmetic in xr-core.js is 64-bit on plain arrays. The points it is given are not: they are picked by a three.js ray cast against the meshes the headset draws, and a snapped corner is read from the mesh's vertex buffer, which in BimCraft is 32-bit (ifc-model.js fills each batch's positions with Float32Array.from). The measured length shown in the headset and the point saved with an issue therefore carry about 7 digits.

Open item. Take the picked points from the app's 64-bit model geometry instead of the 32-bit vertex buffers the headset draws.

Calculations under this policy

9 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 6 calculations Reviewed

Tests bin/test-xr.mjs

Distance between two points

64-bit

The length a headset measurement reports.

d = √(Δx² + Δy² + Δz²)

Math.hypot of the three differences in 64-bit, in model space, so the table-top scale does not enter. Its accuracy is that of the two points it is given, which come from the picking in xr-session.js.

  • xr/xr-core.js
  • line 119
  • BimCraft 0.8.1
export const measure = (a, b) => len(sub(b, a));

Snap to a corner

64-bit

The corner of the hit triangle nearest the hit, when it is within the snap radius.

Compares three 64-bit distances with the radius and returns a copy of the corner or of the hit; no coordinate is computed. With no corner in reach the raw ray hit is used.

  • xr/xr-core.js
  • lines 112 to 117
  • BimCraft 0.8.1
/** The corner of the triangle nearest the hit, when within `radius`; else the hit itself. tri = [a, b, c]. */
export function snapToVertex(hit, tri, radius) {
  let best = null, bd = radius;
  for (const v of tri || []) { const d = len(sub(v, hit)); if (d <= bd) { bd = d; best = v; } }
  return best ? best.slice() : hit.slice();
}

Model to headset transform

64-bit

The matrix that places the model in the headset's space.

M = T(anchor) · R_y(yaw) · S(scale) · T(−at)

Three 4 × 4 products in 64-bit on plain arrays; a missing yaw or scale defaults to 0 or 1.

  • xr/xr-core.js
  • lines 66 to 68
  • BimCraft 0.8.1
export function rigMatrix(rig) {
  return mul(T(rig.anchor || [0, 0, 0]), mul(Ry(rig.yaw || 0), mul(S(rig.scale || 1), T(rig.at.map((v) => -v)))));
}

Inverse of a rig or pose

64-bit

The inverse of a rigid motion with one uniform scale.

M⁻¹ = [Rᵀ ÷ s², −(Rᵀ ÷ s²) t], s² = squared length of the first column

Uses the transpose divided by the squared scale instead of a general inverse, which is correct only for orthogonal axes of equal length. A zero first column divides by 1 instead of by zero.

  • xr/xr-core.js
  • lines 51 to 58
  • BimCraft 0.8.1
/** The inverse of a rigid motion with a uniform scale (all the rigs and poses here are). */
export function invert(m) {
  const s2 = m[0] * m[0] + m[1] * m[1] + m[2] * m[2] || 1;
  const r = [m[0] / s2, m[4] / s2, m[8] / s2, 0, m[1] / s2, m[5] / s2, m[9] / s2, 0, m[2] / s2, m[6] / s2, m[10] / s2, 0, 0, 0, 0, 1];
  const t = applyDir(r, [m[12], m[13], m[14]]);
  r[12] = -t[0]; r[13] = -t[1]; r[14] = -t[2];
  return r;
}

Section plane

64-bit

The plane of a section cut through what was pointed at.

n = normal toward the eye; p = hit − n × depth; d = n·p

The normal is normalized in 64-bit (a zero normal is divided by 1) and flipped by the sign of one dot product. The hit and normal come from the picking, so the plane's place has the picked point's accuracy.

  • xr/xr-core.js
  • lines 127 to 136
  • BimCraft 0.8.1
/**
 * The section plane through what was pointed at, cutting away the side toward the eye (so you see in), `depth` metres
 * behind the surface. → {n, d} with the KEPT side n·p ≤ d … stored as the cut-away direction n (as BCF does) and d.
 */
export function sectionPlane(hitModel, normalModel, eyeModel, depth = 0.01) {
  let n = norm(normalModel);
  if (dot(n, sub(eyeModel, hitModel)) < 0) n = n.map((v) => -v);   // n points to the eye: the side cut away
  const p = [hitModel[0] - n[0] * depth, hitModel[1] - n[1] * depth, hitModel[2] - n[2] * depth];
  return { n, d: dot(n, p), at: p };   // a point p is cut away when n·p > d
}

Issue camera

64-bit

The viewpoint, direction and up vector saved with an issue, in the app's coordinates.

to app = M_up⁻¹ · M_rig⁻¹; eye = to app × head position

Two inverses and a product in 64-bit, then direction and up normalized. The head matrix is the one the headset reports through three.js.

  • xr/xr-core.js
  • lines 138 to 148
  • BimCraft 0.8.1
/**
 * The BCF camera of what the person sees: the head's pose (xr) back through the rig into model space, then app space.
 * headXr = the head's matrix in xr space (column-major; it looks along its −Z, up its +Y).
 */
export function bcfCamera(headXr, rig, upM, fovDeg = 90) {
  const toApp = mul(invert(upM), invert(rigMatrix(rig)));
  const eye = apply(toApp, [headXr[12], headXr[13], headXr[14]]);
  const dir = norm(applyDir(toApp, applyDir(headXr, [0, 0, -1])));
  const up = norm(applyDir(toApp, applyDir(headXr, [0, 1, 0])));
  return { kind: 'perspective', viewPoint: eye, direction: dir, up, fov: fovDeg };
}
xr/xr-session.js BimCraft 0.8.1 32-bit in part 3 calculations Reviewed

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

The point being pointed at

32-bit in part

The model point, surface normal and triangle under a controller's ray.

The hit is three.js's ray cast against the drawn meshes, and the triangle's corners are read with fromBufferAttribute from the mesh's position buffer; in BimCraft that buffer is a Float32Array. The corners are then moved by the object's matrix and the inverse rig in 64-bit, which cannot restore the digits the buffer dropped.

  • xr/xr-session.js
  • lines 327 to 354
  • BimCraft 0.8.1
function aim(h) {
  tmpM.identity().extractRotation(h.c.matrixWorld);
  ray.ray.origin.setFromMatrixPosition(h.c.matrixWorld);
  ray.ray.direction.set(0, 0, -1).applyMatrix4(tmpM);
  ray.far = 200;
  if (menu.parent && h !== menuOwner) {
    const mh = ray.intersectObject(menu, false)[0];
    if (mh) return { menu: true, point: mh.point, uv: mh.uv, distance: mh.distance };
  }
  const hits = ray.intersectObjects([...raycastables, ground], false);
  const x = hits.find((k) => {
    if (!section) return true;
    const pm = apply(toModel(), k.point.toArray());
    return section.n[0] * pm[0] + section.n[1] * pm[1] + section.n[2] * pm[2] <= section.d + 1e-6;   // not in the part cut away
  });
  if (!x) return null;
  const nW = x.face ? x.face.normal.clone().transformDirection(x.object.matrixWorld) : new THREE.Vector3(0, 1, 0);
  if (nW.dot(ray.ray.direction) > 0) nW.negate();
  const Mi = toModel();
  const pModel = apply(Mi, x.point.toArray());
  const nModel = new THREE.Vector3(...nW.toArray()).transformDirection(new THREE.Matrix4().fromArray(Mi)).toArray();
  let tri = null;
  if (x.face && x.object.geometry.attributes.position) {
    const pos = x.object.geometry.attributes.position, v = new THREE.Vector3();
    tri = [x.face.a, x.face.b, x.face.c].map((k) => apply(Mi, v.fromBufferAttribute(pos, k).applyMatrix4(x.object.matrixWorld).toArray()));
  }
  return { point: x.point, distance: x.distance, model: pModel, normal: nModel, tri, ground: x.object === ground };
}

A measurement in the headset

32-bit in part

The length shown on the label between two picked points.

d = |q − p|, shown through the app's length format

Both ends are picked points, snapped to a triangle corner within 0.05 m ÷ scale, so the length is a 64-bit distance between coordinates that came from a 32-bit vertex buffer. The number is formatted by the app and kept in the session's list; it is not rounded here.

  • xr/xr-session.js
  • lines 404 to 419
  • BimCraft 0.8.1
function doMeasure(a) {
  const p = snapToVertex(a.model, a.tri, 0.05 / (rig.scale || 1));
  if (!pending) { pending = { p, obj: addDot(p) }; say(t('xr.measure.first')); return; }
  const q = p, d = measure(pending.p, q);
  const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([new THREE.Vector3(...pending.p), new THREE.Vector3(...q)]), new THREE.LineBasicMaterial({ color: '#ffd400', depthTest: false }));
  line.renderOrder = 6;
  const txt = o.format(d);
  const lab = label(txt, { color: '#ffd400' });
  lab.position.set((pending.p[0] + q[0]) / 2, (pending.p[1] + q[1]) / 2 + 0.05 * markScale(), (pending.p[2] + q[2]) / 2);
  lab.scale.multiplyScalar(markScale());
  const dot2 = addDot(q);
  marks.add(line, lab);
  measures.push({ a: pending.p, b: q, d, text: txt, objs: [pending.obj, dot2, line, lab] });
  pending = null;
  say(t('xr.measured', { d: txt }));
}

Where an issue is placed

32-bit in part

The point and camera handed to the app with a new issue.

The point is the picked model point turned back to the app's axes by an exact axis swap, so it has the picked point's 32-bit origin. The camera uses a fixed 60° field of view and the head matrix three.js holds.

  • xr/xr-session.js
  • lines 441 to 442
  • BimCraft 0.8.1
const camera = bcfCamera(renderer.xr.getCamera().matrixWorld.toArray(), rig, upM, 60);   // BCF 2.1 allows 45–60°
const point = apply(invert(upM), a.model);

In detail

Method
Model to headset space by the rig T(anchor) · R_y(yaw) · S(scale) · T(−at), inverted as a rigid motion with uniform scale. A measurement is the straight distance between two picked points in model space, each snapped to the nearest corner of the hit triangle within 5 cm.
Number types
64-bit numbers in the maths; picked points from three.js Raycaster results and BufferAttribute positions.
Tolerances
Snap radius 0.05 m ÷ rig scale. Section cut 0.01 m ÷ rig scale behind the surface pointed at. A hit counts as visible when n·p ≤ d + 1e-6. A plan cut is 1.2 m above the floor pointed at. Teleport only onto surfaces within 35° of level.
Checked by
bin/test-xr.mjs calls measure on a 3-4-5 triangle and checks sectionPlane and bcfCamera against worked cases.
Limits
A point that is not snapped is where the controller's ray meets a triangle, so it also depends on the pose the headset reports. Measurements live in the session only; issues are handed to the app.

Approach: 32-bit step, flagged

One step stores or exchanges 32-bit values that reach a number a person reads. It is listed here with its open item, and the step is either moved to 64-bit or the app will warn where the number is read.

Every policy that takes this approach