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.
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.
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.jsBimCraft 0.8.1 32-bit in part3 calculationsReviewed
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.
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.