<!-- PLAYA · part 9 -->
 {kind: 'xf', obj: hit, x0: e.clientX, y0: e.clientY, before: snap(hit), off: gp ? hit.position.clone().sub(gp) : new THREE.Vector3(), rot0: hit.rotation.y, s0: hit.scale.clone(), moved: false, t0: Date.now()};
  } else {
    gesture = {kind: (e.button === 2 || e.shiftKey) ? 'pan' : 'orbit', x0: e.clientX, y0: e.clientY, lx: e.clientX, ly: e.clientY, moved: false, t0: Date.now()};
  }
});
canvas.addEventListener('pointermove', function(e){
  if(!pointers[e.pointerId] || !gesture) return;
  pointers[e.pointerId] = {x: e.clientX, y: e.clientY};
  if(gesture.kind === 'pinch'){
    var ids = Object.keys(pointers); if(ids.length < 2) return;
    var a = pointers[ids[0]], b = pointers[ids[1]];
    var d = Math.hypot(a.x-b.x, a.y-b.y), mx = (a.x+b.x)/2, my = (a.y+b.y)/2;
    orbit.dist = gesture.dist * gesture.d / Math.max(20, d);
    panBy(mx - gesture.mx, my - gesture.my); gesture.mx = mx; gesture.my = my;
    return;
  }
  var dx = e.clientX - gesture.x0, dy = e.clientY - gesture.y0;
  if(!gesture.moved && Math.hypot(dx, dy) > 7) gesture.moved = true;
  if(!gesture.moved) return;
  if(gesture.kind === 'orbit'){ orbit.yaw -= (e.clientX - gesture.lx) * 0.008; orbit.pitch += (e.clientY - gesture.ly) * 0.006; }
  else if(gesture.kind === 'pan'){ panBy(e.clientX - gesture.lx, e.clientY - gesture.ly); }
  else if(gesture.kind === 'xf'){
    var o = gesture.obj, prev = {p: o.position.clone(), r: o.rotation.y, s: o.scale.clone()};
    if(tool === 'move'){ var gp = groundAt(e.clientX, e.clientY, gesture.before.p.y); if(gp){ o.position.x = gp.x + gesture.off.x; o.position.z = gp.z + gesture.off.z; } }
    else if(tool === 'rotate'){ o.rotation.y = gesture.rot0 + dx * 0.012; }
    else if(tool === 'scale'){ var f = Math.exp(-dy * 0.006); o.scale.copy(gesture.s0).multiplyScalar(f); applyMinimum(o); dropToGround(o); }
    if(tool !== 'rotate' && wouldOverlap(o)){ o.position.copy(prev.p); o.scale.copy(prev.s); }
    selBox.setFromObject(o);
  }
  gesture.lx = e.clientX; gesture.ly = e.clientY;
});
function commitXf(g){
  var o = g.obj;
  pushHist({label: ({move:'Move', rotate:'Rotate', scale:'Scale'})[tool] + ' ' + label(o), kind: 'state', obj: o, before: g.before, after: snap(o)});
  say(label(o) + ' — ' + fmtPos(o) + ', rot ' + Math.round(o.rotation.y * 180 / Math.PI) + '°, scale ' + o.scale.y.toFixed(2) + '.');
  refresh();
}
function endPointer(e){
  if(!pointers[e.pointerId]) return;
  delete pointers[e.pointerId];
  var g = gesture;
  if(!g) return;
  if(g.kind === 'pinch'){ if(!Object.keys(pointers).length) gesture = null; else gesture = {kind:'none'}; return; }
  gesture = null;
  if(e.type === 'pointercancel') return;
  if(g.kind === 'xf'){ if(g.moved) commitXf(g); else say('Selected ' + label(g.obj) + '.'); return; }
  if(g.kind === 'none' || g.moved || Date.now() - g.t0 > 600) return;
  /* a tap */
  if(tool === 'create'){
    var pt = groundAt(e.clientX, e.clientY, 0);
    if(pt) spawn(selectedShape, pt.x, pt.z, true);
    return;
  }
  var hit = hitBlock(e.clientX, e.clientY);
  if(tool === 'delete'){
    if(hit){ detach(hit); pushHist({label: 'Delete ' + label(hit), kind: 'remove', obj: hit}); say('Deleted ' + label(hit) + '. Undo brings it back.'); refresh(); }
    else say('Delete mode — tap a block.');
    return;
  }
  if(hit){ select(hit); say('Selected ' + label(hit) + '.'); }
  else if(selected){ select(null); say('Nothing selected.'); }
}
canvas.addEventListener('pointerup', endPointer);
canvas.addEventListener('pointercancel', endPointer);
canvas.addEventListener('wheel', function(e){ e.preventDefault(); orbit.dist *= Math.exp(e.deltaY * 0.0012); }, {passive: false});
function panBy(dx, dy){
  /* grab-the-ground pan: right = (cos yaw, 0, -sin yaw), forward = (-sin yaw, 0, -cos yaw) */
  var s = orbit.dist * 0.0018, cy = Math.cos(orbit.yaw), sy = Math.sin(orbit.yaw);
  orbit.target.x += (-dx * cy - dy * sy) * s;
  orbit.target.z += ( dx * sy - dy * cy) * s;
}
/* keyboard (desktop): Ctrl/Cmd+Z, Ctrl+Y / Shift+Ctrl+Z, Delete */
document.addEventListener('keydown', function(e){
  var t = e.target; if(t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA')) return;
  var mod = e.ctrlKey || e.metaKey;
  if(mod && (e.key === 'z' || e.key === 'Z')){ e.preventDefault(); if(e.shiftKey) ACT.redo(); else ACT.undo(); }
  else if(mod && (e.key === 'y' || e.key === 'Y')){ e.preventDefault(); ACT.redo(); }
  else if((e.key === 'Delete' || e.key === 'Backspace') && selected){ e.preventDefault(); ACT.delete(); }
});

/* ---------- UI REFRESH ---------- */
function fmtPos(o){ return 'X ' + o.position.x.toFixed(1) + ' Y ' + o.position.y.toFixed(1) + ' Z ' + o.position.z.toFixed(1); }
function polyCount(o){ var n = 0; meshesOf(o).forEach(function(m){ var g = m.geometry; n += g.index ? g.index.count / 3 : g.attributes.position.count / 3; }); return Math.round(n); }
function vertCount(o){ var n = 0; meshesOf(o).forEach(function(m){ n += m.geometry.attributes.position.count; }); return n; }
functi