[object Object]

← 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

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 →