<!-- RUBY-BACKUP · part 386 -->
e to rotate, release to see menu */
              _spinArmed = true;
              _spinMoved = false;
              _spinLastX = px; _spinLastY = py;
              try{ codeStatus('Block held — swipe to spin.'); }catch(e){}
              showBlockPicker();
            }
          }
        }
      }catch(e){}
      _lpTimer = null;
    }, 600);
  })(e.clientX, e.clientY);
  if(downInfo.n===2){
    var ks = Object.keys(pointers);
    downInfo.pinch = Math.hypot(pointers[ks[0]].x-pointers[ks[1]].x, pointers[ks[0]].y-pointers[ks[1]].y);
    downInfo.r0 = camGoal.r;
    downInfo.walkD0 = camDist;
    downInfo.crownF0 = crownFov;
  }
});
canvas.addEventListener('pointermove', function(e){
  if(!pointers[e.pointerId]) return;
  if(downInfo && (Math.abs(e.clientX-downInfo.x)+Math.abs(e.clientY-downInfo.y)) > 10) cancelLongPress();
  /* spin mode: long-press then swipe rotates the block */
  if(_spinArmed && window._selectedBlock){
    var dx = e.clientX - _spinLastX;
    var dy = e.clientY - _spinLastY;
    if(Math.abs(dx)+Math.abs(dy) > 3){
      _spinMoved = true;
      try{
        var blk = window._selectedBlock;
        blk.rotation.y += dx * 0.02;
        blk.rotation.x += dy * 0.02;
      }catch(e){}
      _spinLastX = e.clientX; _spinLastY = e.clientY;
    }
    return;
  }
  var p = ptrPos(e), prev = pointers[e.pointerId];
  var dx = p.x-prev.x, dy = p.y-prev.y;
  pointers[e.pointerId] = p;
  if(downInfo) downInfo.moved += Math.abs(dx)+Math.abs(dy);
  var n = Object.keys(pointers).length;
  if(n===1){
    if(WALK){
      /* drag aims the sword in 360 spherical while flying, orbits the body otherwise */
      if(FLYM){ aimYaw -= dx*0.005; aimPitch = Math.max(-1.55, Math.min(1.55, aimPitch - dy*0.005)); }
      else { camYaw -= dx*0.005; camPitch = Math.max(0.08, Math.min(1.5, camPitch - dy*0.005)); }
    }else{
      camGoal.th -= dx*0.005;
      camGoal.ph = Math.max(0.15, Math.min(1.45, camGoal.ph - dy*0.005));
    }
  }else if(n===2 && downInfo && downInfo.pinch){
    var ks = Object.keys(pointers);
    var d = Math.hypot(pointers[ks[0]].x-pointers[ks[1]].x, pointers[ks[0]].y-pointers[ks[1]].y);
    if(d>0){
      if(CROWN){ crownFov = Math.max(25, Math.min(90, downInfo.crownF0 * downInfo.pinch/d)); camera.fov = crownFov; camera.updateProjectionMatrix(); }
      else if(WALK) camDist = Math.max(0.3, Math.min(80, downInfo.walkD0 * downInfo.pinch/d));
      else camGoal.r = Math.max(6, Math.min(70, downInfo.r0 * downInfo.pinch/d));
    }
  }
  /* ghost follows the finger, in create mode only — not while walking */
  var cell = (mode==='create' && !WALK) ? groundCell(e.clientX, e.clientY) : null;
  if(cell && !stoneAt(cell.x, cell.z)){ ghost.visible = true; ghost.position.set(cell.x, (ghost.userData.k||1)*window.MINIMUM/2, cell.z); }
  else ghost.visible = false;
});
function endPointer(e){
  delete pointers[e.pointerId];
  /* spin mode release */
  if(_spinArmed){
    var wasSpin = _spinMoved;
    var blk = window._selectedBlock;
    _spinArmed = false; _spinMoved = false;
    cancelLongPress();
    if(!wasSpin && blk){
      /* held without swiping — show the build menu */
      showBuildOptions(blk, e.clientX, e.clientY);
    } else if(wasSpin){
      try{ codeStatus('Block spun.'); }catch(e){}
    }
    downInfo = null;
    return;
  }
  cancelLongPress();
  if(downInfo && Object.keys(pointers).length===0){
    var quick = performance.now()-downInfo.t < 450 && downInfo.moved < 12;
    if(quick){
      /* create mode: tap the ground births a 1-ruby shape, tap a shape grows it one ruby.
         shrink mode: tap a shape makes it one ruby smaller, until nothing. */
      ndc.set((e.clientX/window.innerWidth)*2-1, -(e.clientY/window.innerHeight)*2+1);
      rayc.setFromCamera(ndc, camera);
      var hits = rayc.intersectObjects(stones.children, true);
      var grp = hits.length ? stoneGroupOf(hits[0].object) : null;
      if(awaitingLock){
        if(grp) tapLock(grp);
        else codeStatus('tap the anchor stone to lock it in');
        downInfo = null;
        return;
      }
      if(awaitingUnlock){
        if(grp) tapUnlock(grp);
        else codeStatus('tap a locked anchor to release it');
        downInfo = null;
        return;
      }
      if(awaitingPortal){
        if(grp) tapPortal(grp);
        else codeStatus('tap the portal stone');
        downInfo = null;
        return;
      }
      if(awaitingUnportal){
        if(grp) tapUnportal(grp);
        else codeStatus('tap a portal to close it');
        downInfo = null;
        return;
      }
      if(awaitingDidAnchor){
        if(grp) didAnchorTo(grp);
        else codeStatus('tap the anchor stone for: '+didAnchorLine);
        downInfo = null;
        return;
      }
      if(awaitingAnchor){
        if(grp){ awaitingAnchor=false; selectConnected(grp); }
        else codeStatus('tap the anchor stone');
        downInfo = null;
        return;
      }
      var pe = grp ? portalAt(grp) : null;
      if(