<!-- RUBY-BACKUP · part 80 -->
neToLocal2(frame,worldPt){
  var l=frame.group.worldToLocal(worldPt.clone());
  return [l.x,l.y];
}
function findHandle(t){
  if(!demo||!camera) return null;
  touchNDC(t);
  raycaster.setFromCamera(ndc,camera);
  var hits=raycaster.intersectObjects([demo.anchored.group,demo.free.group],true);
  for(var i=0;i<hits.length;i++){
    var o=hits[i].object;
    while(o){
      if(o.userData&&o.userData.deformHandle) return o.userData.deformHandle;
      o=o.parent;
    }
  }
  return null;
}
/* grab rules: opposite corners may pair; adjacent second grab refused */
function canGrab(frame,corner){
  var driven=Object.keys(frame.driven).filter(function(k){return frame.driven[k];});
  if(!driven.length) return true;
  if(driven.indexOf(corner)>=0) return true; /* re-grab same */
  if(driven.length>=2) return false;
  return OPP[driven[0]]===corner;
}
function onTouchStart(e){
  if(!demo||demo.entering) return;
  var changed=false;
  for(var i=0;i<e.changedTouches.length;i++){
    var t=e.changedTouches[i];
    if(activeTouches[t.identifier]) continue;
    var h=findHandle(t);
    if(!h) continue;
    if(!canGrab(h.frame,h.corner)){
      status('grab the OPPOSITE corner — the diagonal is the pull');
      continue;
    }
    /* plane point at grab */
    touchNDC(t); raycaster.setFromCamera(ndc,camera);
    var pt=new T.Vector3();
    if(!raycaster.ray.intersectPlane(framePlaneWorld(h.frame),pt)) continue;
    var local=planeToLocal2(h.frame,pt);
    activeTouches[t.identifier]={
      frame:h.frame, corner:h.corner,
      ropeVec:[h.frame.ropeVec[h.corner][0],h.frame.ropeVec[h.corner][1]],
      lastLocal:local
    };
    h.frame.driven[h.corner]=true;
    if(!h.frame.anchored){
      /* free frame: remember world grab for rigid translation */
      activeTouches[t.identifier].worldPt=pt.clone();
      activeTouches[t.identifier].groupPos=h.frame.group.position.clone();
    }
    changed=true;
  }
  if(changed){ e.preventDefault(); e.stopPropagation(); }
}
function onTouchMove(e){
  if(!demo) return;
  var changed=false;
  for(var i=0;i<e.changedTouches.length;i++){
    var t=e.changedTouches[i];
    var at=activeTouches[t.identifier];
    if(!at) continue;
    touchNDC(t); raycaster.setFromCamera(ndc,camera);
    var pt=new T.Vector3();
    if(!raycaster.ray.intersectPlane(framePlaneWorld(at.frame),pt)) continue;
    if(at.frame.anchored){
      var local=planeToLocal2(at.frame,pt);
      var dx=local[0]-at.lastLocal[0], dy=local[1]-at.lastLocal[1];
      at.lastLocal=local;
      /* rigid rope: corner follows the finger 1:1 */
      var P=at.frame.pos[at.corner];
      P[0]+=dx; P[1]+=dy;
      at.frame.ropeVec[at.corner]=[at.ropeVec[0],at.ropeVec[1]];
      at.frame.solve();
      at.frame.updateMeshes();
    }else{
      /* FREE: the whole block travels — rigid, no shape change */
      var wp=at.worldPt;
      var delta=new T.Vector3().subVectors(pt,wp);
      at.frame.group.position.copy(at.groupPos).add(delta);
      /* keep meshes fresh (nothing deforms, but rope follows) */
      at.frame.updateMeshes();
    }
    changed=true;
  }
  if(changed){ e.preventDefault(); e.stopPropagation(); }
}
function onTouchEnd(e){
  if(!demo) return;
  for(var i=0;i<e.changedTouches.length;i++){
    var t=e.changedTouches[i];
    var at=activeTouches[t.identifier];
    if(!at) continue;
    /* shape HOLDS — the King's default */
    at.frame.driven[at.corner]=false;
    delete activeTouches[t.identifier];
    e.preventDefault();
  }
}

/* mouse fallback for desktop testing */
var mouseGrab=null;
function onMouseDown(e){
  if(!demo||demo.entering||e.button!==0) return;
  var h=findHandle({clientX:e.clientX,clientY:e.clientY});
  if(!h||!canGrab(h.frame,h.corner)) return;
  mouseGrab={frame:h.frame,corner:h.corner,
    ropeVec:[h.frame.ropeVec[h.corner][0],h.frame.ropeVec[h.corner][1]],
    lastLocal:null};
  touchNDC({clientX:e.clientX,clientY:e.clientY});
  raycaster.setFromCamera(ndc,camera);
  var pt0=new T.Vector3();
  if(raycaster.ray.intersectPlane(framePlaneWorld(h.frame),pt0)){
    if(h.frame.anchored){ mouseGrab.lastLocal=planeToLocal2(h.frame,pt0); }
    else { mouseGrab.worldPt=pt0.clone(); mouseGrab.groupPos=h.frame.group.position.clone(); }
  }
  h.frame.driven[h.corner]=true;
  e.preventDefault(); e.stopPropagation();
}
function onMouseMove(e){
  if(!mouseGrab||!demo) return;
  var f=mouseGrab.frame;
  touchNDC({clientX:e.clientX,clientY:e.clientY});
  raycaster.setFromCamera(ndc,camera);
  var pt=new T.Vector3();
  if(!raycaster.ray.intersectPlane(framePlaneWorld(f),pt)) return;
  if(f.anchored){
    if(!mouseGrab.lastLocal){ mouseGrab.lastLocal=planeToLocal2(f,pt); return; }
    var local=planeToLocal2(f,pt);
    var P=f.pos[mouseGrab.corner];
    /* rigid rope: corner follows the finger 1:1 */
    P[0]+=local[0]-mouseGrab.lastLocal[0];
    P[1]+=local[1]-mouseGrab.lastLocal[1];
    mouseGrab.lastLocal=local;
    f.solve(); f.updateMeshes();
  }else{
    var delta=new T.Vect