← back to NationalPaperHangers

public/js/room-measure.js

311 lines

/* 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
  };
})();