← back to NationalPaperHangers
feat: add WebXR AR + gyro-clinometer wall measurement to /book room-capture tool
d08c8465b391a999301fb5725743830317f33608 · 2026-05-19 12:15:29 -0700 · SteveStudio2
Files touched
M public/js/room-capture.jsA public/js/room-measure.jsM views/public/book.ejs
Diff
commit d08c8465b391a999301fb5725743830317f33608
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Tue May 19 12:15:29 2026 -0700
feat: add WebXR AR + gyro-clinometer wall measurement to /book room-capture tool
---
public/js/room-capture.js | 46 +++++++
public/js/room-measure.js | 310 ++++++++++++++++++++++++++++++++++++++++++++++
views/public/book.ejs | 27 ++++
3 files changed, 383 insertions(+)
diff --git a/public/js/room-capture.js b/public/js/room-capture.js
index 0033cee..cc6ae6a 100644
--- a/public/js/room-capture.js
+++ b/public/js/room-capture.js
@@ -279,6 +279,52 @@
});
wireBox(r);
+
+ // Sensor measurement — WebXR AR or a gyro clinometer (see room-measure.js).
+ // Pure progressive enhancement: the button stays hidden unless the device
+ // actually has a usable sensor, and any failure just leaves the manual
+ // dimension inputs exactly as they were.
+ var measureBtn = el.querySelector('.rc-measure-btn');
+ var measureNote = el.querySelector('.rc-measure-note');
+ if (measureBtn && window.RoomMeasure) {
+ window.RoomMeasure.ready.then(function () {
+ var lbl = window.RoomMeasure.label();
+ if (!lbl) return; // no sensors on this device — stay hidden
+ measureBtn.textContent = lbl;
+ measureBtn.hidden = false;
+ });
+ measureBtn.addEventListener('click', function () {
+ measureNote.className = 'rc-measure-note';
+ measureNote.textContent = 'Starting measurement…';
+ measureBtn.disabled = true;
+ window.RoomMeasure.measure().then(function (m) {
+ var hEl = el.querySelector('.rc-height'), wEl = el.querySelector('.rc-width');
+ // Set height first: its 'input' fires recompute(), which box-derives
+ // a width — then a real measured width (WebXR) overwrites that.
+ if (m.height_ft) {
+ hEl.value = m.height_ft;
+ hEl.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ if (m.width_ft) {
+ wEl.value = m.width_ft;
+ wEl.dispatchEvent(new Event('input', { bubbles: true }));
+ }
+ measureNote.className = 'rc-measure-note is-ok';
+ measureNote.textContent = (m.method === 'webxr')
+ ? '✓ Measured in AR: ' + (m.width_ft || '?') + ' ft wide × '
+ + (m.height_ft || '?') + ' ft tall — adjust above if needed.'
+ : '✓ Measured height: ' + m.height_ft + ' ft — confirm the width above.';
+ }).catch(function (err) {
+ var msg = (err && err.message) || 'Measurement failed.';
+ if (msg === 'cancelled') {
+ measureNote.textContent = '';
+ } else {
+ measureNote.className = 'rc-measure-note is-error';
+ measureNote.textContent = msg;
+ }
+ }).then(function () { measureBtn.disabled = false; });
+ });
+ }
}
function addRoom() {
diff --git a/public/js/room-measure.js b/public/js/room-measure.js
new file mode 100644
index 0000000..a26aa58
--- /dev/null
+++ b/public/js/room-measure.js
@@ -0,0 +1,310 @@
+/* room-measure.js — phone-sensor wall measurement for the /book room-capture tool.
+ *
+ * Layered, progressive enhancement. Exposes window.RoomMeasure:
+ * RoomMeasure.ready Promise<void> resolves once the capability probe is done
+ * RoomMeasure.method() 'webxr' | 'gyro' | null best available method
+ * RoomMeasure.label() button label for the active method (or null)
+ * RoomMeasure.measure() Promise<{ width_ft, height_ft, method }>
+ * width_ft/height_ft may be null when a method
+ * can only resolve one of them; rejects on
+ * cancel or error.
+ *
+ * webxr — Android Chrome & other WebXR browsers: an immersive-ar session with
+ * hit-test. Three placed points (two bottom wall corners + one ceiling
+ * point) yield TRUE-scale width and height from the AR world tracking.
+ *
+ * gyro — iPhone Safari (Apple ships no WebXR): a DeviceOrientation clinometer.
+ * With a known standing distance, aiming the phone at the floor then
+ * the ceiling gives two elevation angles that trig into wall HEIGHT.
+ * Width is left to the existing photo-box estimate.
+ *
+ * Nothing here runs unless the customer taps the measure button, and every
+ * failure path rejects cleanly + tears down its overlay — the existing
+ * photo-box capture flow is never touched.
+ */
+(function () {
+ 'use strict';
+
+ var M_TO_FT = 3.280839895;
+ var DEG = Math.PI / 180;
+
+ var caps = { webxr: false, gyro: false };
+
+ // gyro: the DeviceOrientation API must at least exist. iOS 13+ additionally
+ // gate-keeps it behind a permission prompt we can only fire on a user tap,
+ // so we treat "the type exists" as available and ask for permission later.
+ caps.gyro = (typeof window.DeviceOrientationEvent !== 'undefined');
+
+ // webxr: probe immersive-ar support; this is async, hence RoomMeasure.ready.
+ var ready = (function () {
+ if (!(navigator.xr && navigator.xr.isSessionSupported)) return Promise.resolve();
+ return navigator.xr.isSessionSupported('immersive-ar')
+ .then(function (ok) { caps.webxr = !!ok; })
+ .catch(function () { caps.webxr = false; });
+ })();
+
+ function method() {
+ if (caps.webxr) return 'webxr';
+ if (caps.gyro) return 'gyro';
+ return null;
+ }
+ function label() {
+ if (caps.webxr) return '📐 Measure this wall in AR';
+ if (caps.gyro) return '📐 Measure wall height with your phone';
+ return null;
+ }
+
+ // ── shared overlay scaffold ───────────────────────────────────────────────
+ // A fixed full-screen panel. For the gyro flow it is an ordinary modal; for
+ // the WebXR flow the same element is handed to the session as the dom-overlay
+ // root so it floats over the live camera feed.
+ function makeOverlay() {
+ var o = document.createElement('div');
+ o.className = 'rmx-overlay';
+ o.innerHTML =
+ '<div class="rmx-card">' +
+ '<div class="rmx-title"></div>' +
+ '<div class="rmx-body"></div>' +
+ '<div class="rmx-actions">' +
+ '<button type="button" class="rmx-cancel">Cancel</button>' +
+ '<button type="button" class="rmx-primary"></button>' +
+ '</div>' +
+ '</div>';
+ document.body.appendChild(o);
+ return {
+ root: o,
+ title: o.querySelector('.rmx-title'),
+ body: o.querySelector('.rmx-body'),
+ cancel: o.querySelector('.rmx-cancel'),
+ primary: o.querySelector('.rmx-primary'),
+ destroy: function () { if (o.parentNode) o.parentNode.removeChild(o); }
+ };
+ }
+
+ // ── WebXR AR measurement ──────────────────────────────────────────────────
+ function measureXR() {
+ return new Promise(function (resolve, reject) {
+ var ui = makeOverlay();
+ ui.title.textContent = 'AR wall measure';
+
+ var canvas = document.createElement('canvas');
+ var gl = canvas.getContext('webgl', { xrCompatible: true })
+ || canvas.getContext('experimental-webgl', { xrCompatible: true });
+ if (!gl) { ui.destroy(); return reject(new Error('WebGL unavailable.')); }
+
+ var session = null, localSpace = null, hitSource = null;
+ var lastHit = null; // {x,y,z} of the current reticle position
+ var points = []; // up to 3 placed points
+ var done = false;
+
+ var STEPS = [
+ 'Aim the centre crosshair at one BOTTOM corner of the wall, then tap Place.',
+ 'Now aim at the OTHER bottom corner and tap Place.',
+ 'Finally aim where the wall meets the CEILING above a corner, then tap Place.'
+ ];
+
+ function finish(err, result) {
+ if (done) return;
+ done = true;
+ if (hitSource && hitSource.cancel) { try { hitSource.cancel(); } catch (e) {} }
+ if (session) { try { session.end(); } catch (e) {} }
+ ui.destroy();
+ if (err) reject(err); else resolve(result);
+ }
+
+ function refresh() {
+ ui.body.innerHTML =
+ '<div class="rmx-step">Point ' + (points.length + 1) + ' of 3</div>' +
+ '<div class="rmx-instr">' + STEPS[points.length] + '</div>' +
+ '<div class="rmx-status rmx-hit-' + (lastHit ? 'ok' : 'none') + '">' +
+ (lastHit ? '✓ Surface locked — ready to place' : 'Move your phone slowly to find the surface…') +
+ '</div>' +
+ '<div class="rmx-crosshair">+</div>';
+ ui.primary.textContent = 'Place point';
+ ui.primary.disabled = !lastHit;
+ }
+
+ function placePoint() {
+ if (!lastHit || done) return;
+ points.push({ x: lastHit.x, y: lastHit.y, z: lastHit.z });
+ if (points.length >= 3) {
+ var p0 = points[0], p1 = points[1], p2 = points[2];
+ // width: horizontal distance between the two bottom corners.
+ var width_m = Math.hypot(p1.x - p0.x, p1.z - p0.z);
+ // height: pure vertical rise from a bottom corner to the ceiling tap.
+ var height_m = Math.abs(p2.y - p0.y);
+ finish(null, {
+ width_ft: Math.round(width_m * M_TO_FT * 2) / 2,
+ height_ft: Math.round(height_m * M_TO_FT * 2) / 2,
+ method: 'webxr'
+ });
+ return;
+ }
+ refresh();
+ }
+
+ ui.cancel.addEventListener('click', function () { finish(new Error('cancelled')); });
+ ui.primary.addEventListener('click', placePoint);
+
+ navigator.xr.requestSession('immersive-ar', {
+ requiredFeatures: ['hit-test'],
+ optionalFeatures: ['dom-overlay'],
+ domOverlay: { root: ui.root }
+ }).then(function (s) {
+ session = s;
+ session.addEventListener('end', function () { finish(new Error('cancelled')); });
+ return Promise.all([
+ session.requestReferenceSpace('local'),
+ session.requestReferenceSpace('viewer')
+ ]).then(function (spaces) {
+ localSpace = spaces[0];
+ var viewerSpace = spaces[1];
+ session.updateRenderState({ baseLayer: new XRWebGLLayer(session, gl) });
+ return session.requestHitTestSource({ space: viewerSpace });
+ }).then(function (src) {
+ hitSource = src;
+ refresh();
+ session.requestAnimationFrame(onFrame);
+ });
+ }).catch(function (e) { finish(new Error('Could not start AR: ' + (e && e.message || e))); });
+
+ function onFrame(t, frame) {
+ if (done || !session) return;
+ session.requestAnimationFrame(onFrame);
+ var layer = session.renderState.baseLayer;
+ gl.bindFramebuffer(gl.FRAMEBUFFER, layer.framebuffer);
+ gl.clearColor(0, 0, 0, 0);
+ gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
+
+ var results = frame.getHitTestResults(hitSource);
+ var had = !!lastHit;
+ if (results.length) {
+ var pose = results[0].getPose(localSpace);
+ lastHit = pose ? {
+ x: pose.transform.position.x,
+ y: pose.transform.position.y,
+ z: pose.transform.position.z
+ } : null;
+ } else {
+ lastHit = null;
+ }
+ if (!!lastHit !== had) refresh(); // surface lock state changed
+ }
+ });
+ }
+
+ // ── gyro clinometer (height only) ─────────────────────────────────────────
+ function measureGyro() {
+ return new Promise(function (resolve, reject) {
+ var ui = makeOverlay();
+ ui.title.textContent = 'Phone wall-height measure';
+
+ function destroyReject(msg) { ui.destroy(); reject(new Error(msg)); }
+ ui.cancel.addEventListener('click', function () { destroyReject('cancelled'); });
+
+ // step 1 — standing distance from the wall
+ ui.body.innerHTML =
+ '<div class="rmx-instr">Stand facing the wall, then tell us roughly how far back you are. ' +
+ 'Pace it heel-to-toe if you can — each step ≈ 2.5 ft.</div>' +
+ '<label class="rmx-field">Distance from the wall (ft)' +
+ '<input type="number" class="rmx-dist" min="2" max="40" step="0.5" value="6"></label>';
+ ui.primary.textContent = 'Start measuring';
+
+ ui.primary.onclick = function () {
+ var dist = parseFloat(ui.body.querySelector('.rmx-dist').value);
+ if (!(dist >= 2 && dist <= 40)) { ui.body.querySelector('.rmx-dist').focus(); return; }
+ startTilt(dist);
+ };
+
+ function startTilt(dist) {
+ // iOS 13+ gates DeviceOrientation behind an explicit permission grant,
+ // which must be requested from inside a user gesture (this click).
+ var grant = (typeof DeviceOrientationEvent.requestPermission === 'function')
+ ? DeviceOrientationEvent.requestPermission()
+ : Promise.resolve('granted');
+
+ grant.then(function (state) {
+ if (state !== 'granted') return destroyReject('Motion access was denied — you can type the height instead.');
+ runTilt(dist);
+ }).catch(function () {
+ destroyReject('Motion access was denied — you can type the height instead.');
+ });
+ }
+
+ function runTilt(dist) {
+ var elevation = null; // live camera elevation angle, degrees (+ = up)
+ var floorAngle = null, ceilAngle = null;
+ var gotReading = false;
+
+ function onOrient(e) {
+ if (e.beta == null) return;
+ gotReading = true;
+ // Phone upright (beta 90) -> rear camera level. Tilt the top toward
+ // you (beta < 90) and the camera aims up; elevation = 90 - beta.
+ elevation = 90 - e.beta;
+ paint();
+ }
+ window.addEventListener('deviceorientation', onOrient);
+
+ function teardown() { window.removeEventListener('deviceorientation', onOrient); }
+
+ function paint() {
+ var phase = (floorAngle == null) ? 'floor' : 'ceiling';
+ ui.body.innerHTML =
+ '<div class="rmx-instr">' +
+ (phase === 'floor'
+ ? 'Aim the back of your phone at the spot where the wall meets the FLOOR. Hold steady and tap Capture floor.'
+ : 'Now tilt up and aim where the wall meets the CEILING. Tap Capture ceiling.') +
+ '</div>' +
+ '<div class="rmx-readout">' +
+ (elevation == null ? 'Waiting for motion sensor…'
+ : 'Camera angle: ' + elevation.toFixed(0) + '°') +
+ '</div>' +
+ (floorAngle != null ? '<div class="rmx-status rmx-hit-ok">✓ Floor captured</div>' : '');
+ ui.primary.textContent = (phase === 'floor') ? 'Capture floor' : 'Capture ceiling';
+ ui.primary.disabled = (elevation == null);
+ }
+
+ ui.primary.onclick = function () {
+ if (elevation == null) return;
+ if (floorAngle == null) { floorAngle = elevation; paint(); return; }
+ ceilAngle = elevation;
+ teardown();
+ // height = D * (tan(ceilElev) - tan(floorElev)); floorElev is
+ // negative (aimed down), so the subtraction adds the two spans.
+ var span = Math.tan(ceilAngle * DEG) - Math.tan(floorAngle * DEG);
+ if (!(span > 0)) {
+ ui.destroy();
+ return reject(new Error('Aim LOW at the floor first, then HIGH at the ceiling — the two angles came out reversed.'));
+ }
+ var height_ft = Math.round(dist * span * 2) / 2;
+ if (!(height_ft >= 5 && height_ft <= 40)) {
+ ui.destroy();
+ return reject(new Error('That came out to ' + height_ft + ' ft — check your standing distance and try again.'));
+ }
+ ui.destroy();
+ resolve({ width_ft: null, height_ft: height_ft, method: 'gyro' });
+ };
+
+ paint();
+ // if no reading lands within 3s the sensor is dead/blocked
+ setTimeout(function () {
+ if (!gotReading) { teardown(); destroyReject('No motion-sensor reading — you can type the height instead.'); }
+ }, 3000);
+ }
+ });
+ }
+
+ function measure() {
+ if (caps.webxr) return measureXR();
+ if (caps.gyro) return measureGyro();
+ return Promise.reject(new Error('No measurement sensors on this device.'));
+ }
+
+ window.RoomMeasure = {
+ ready: ready,
+ method: method,
+ label: label,
+ measure: measure
+ };
+})();
diff --git a/views/public/book.ejs b/views/public/book.ejs
index bf0b7b9..3dd5feb 100644
--- a/views/public/book.ejs
+++ b/views/public/book.ejs
@@ -74,6 +74,30 @@
.rc-wp-specs label{flex:1 1 100%}
.rc-file-btn>span{display:block;text-align:center}
}
+
+ /* ── Sensor measurement (room-measure.js: WebXR AR / gyro clinometer) ─── */
+ .rc-measure-btn{margin:2px 0 6px;width:100%}
+ .rc-measure-note{font-size:11px;color:var(--muted,#888);margin:0 0 8px;min-height:1em}
+ .rc-measure-note.is-error{color:#dc2626}
+ .rc-measure-note.is-ok{color:#16a34a}
+ .rmx-overlay{position:fixed;inset:0;z-index:99999;display:flex;flex-direction:column;justify-content:flex-end;pointer-events:none}
+ .rmx-card{pointer-events:auto;background:rgba(12,12,12,0.94);color:#fff;border-radius:16px 16px 0 0;padding:18px 20px 22px;box-shadow:0 -8px 30px rgba(0,0,0,0.4);max-width:560px;width:100%;margin:0 auto}
+ .rmx-title{font-size:15px;font-weight:700;margin:0 0 8px}
+ .rmx-body{font-size:13px;line-height:1.5}
+ .rmx-instr{margin:0 0 10px}
+ .rmx-step{font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:#f5c451;font-weight:700;margin:0 0 4px}
+ .rmx-status{margin:8px 0;font-size:12px;font-weight:600}
+ .rmx-hit-ok{color:#4ade80}
+ .rmx-hit-none{color:#fbbf24}
+ .rmx-readout{font-size:20px;font-weight:700;margin:8px 0;font-variant-numeric:tabular-nums}
+ .rmx-field{display:block;margin:8px 0}
+ .rmx-field input{display:block;width:100%;margin-top:4px;padding:10px;border-radius:8px;border:1px solid #555;background:#1a1a1a;color:#fff;font-size:16px}
+ .rmx-crosshair{position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);font-size:54px;line-height:1;color:#f5c451;text-shadow:0 0 6px rgba(0,0,0,0.9);pointer-events:none}
+ .rmx-actions{display:flex;gap:10px;margin-top:16px}
+ .rmx-actions button{flex:1;padding:13px;border-radius:9px;font-size:14px;font-weight:700;cursor:pointer;border:0}
+ .rmx-cancel{background:transparent;color:#bbb;border:1px solid #555}
+ .rmx-primary{background:#f5c451;color:#0a0a0a}
+ .rmx-primary:disabled{opacity:0.45;cursor:not-allowed}
</style>
<section class="book-page">
@@ -237,6 +261,8 @@
<label>Ceiling height (ft)<input type="number" class="rc-height" min="6" max="40" step="0.5" value="9"></label>
<label>Wall width (ft)<input type="number" class="rc-width" min="1" max="120" step="0.5"></label>
</div>
+ <button type="button" class="btn btn-ghost rc-measure-btn" hidden></button>
+ <p class="rc-measure-note"></p>
<p class="rc-estimate"></p>
<label class="rc-file-btn rc-secondary">
<span class="rc-video-cta">🎬 Add a room walkthrough video (optional)</span>
@@ -432,6 +458,7 @@
<script>window.NPH_STRIPE_PK = <%- JSON.stringify(stripePublishableKey) %>;</script>
<% } %>
<script src="/js/calendar-consumer.js" defer></script>
+<script src="/js/room-measure.js" defer></script>
<script src="/js/room-capture.js" defer></script>
<script>
// 5-step intake wizard navigation. The form itself still posts to the same
← 098e20c JSON-serialise booking-page analytics values so a stray quot
·
back to NationalPaperHangers
·
Add "Call this installer" Butlr integration to installer pag 9f104a2 →