<!-- SAGA2 · part 47 -->
.3,5.9],[0.7,-3.25,5.85],[1.5,-3.1,5.7]], iG); // lower lip (fuller)
    L([[-1.8,-2.7,5.8],[1.8,-2.7,5.8]], iD); // mouth line


    // STIPPLING — skin texture (dots where light hits, R5)
    function stipple(cx, cy, cz, r, n, mat){
      for(var sd=0;sd<n;sd++){
        var sa=(sd/n)*Math.PI*2 + 0.3, sr=r*(0.3+0.7*((sd*7)%5)/5);
        var sx=cx+Math.cos(sa)*sr, sy=cy+Math.sin(sa)*sr*0.7;
        // tiny dot as 3-point ribbon
        L([[sx-0.03,sy,cz],[sx,sy+0.03,cz],[sx+0.03,sy,cz]], mat);
      }
    }
    stipple(2.5, 0.5, 5.2, 1.2, 12, iC); // right cheek
    stipple(-2.5, 0.5, 5.2, 1.2, 12, iC); // left cheek
    stipple(0, 4.5, 5.5, 0.8, 8, iC); // forehead
    stipple(0, 1.5, 5.8, 0.5, 6, iC); // nose bridge

    // PHILTRUM — two lines nose to lip
    L([[-0.25,-1.5,6.1],[-0.25,-2.2,6.05]], iD);
    L([[0.25,-1.5,6.1],[0.25,-2.2,6.05]], iD);
    // LIPS — more defined (Cupid's bow, full lower)
    L([[-1.8,-2.7,5.8],[-0.9,-2.6,6.0],[-0.3,-2.45,6.1],[0,-2.5,6.12],[0.3,-2.45,6.1],[0.9,-2.6,6.0],[1.8,-2.7,5.8]], iG);
    // CHIN — mental crease + form
    L([[-0.8,-3.8,5.7],[0,-3.9,5.75],[0.8,-3.8,5.7]], iD); // crease under lip
    L([[-1.2,-4.5,5.5],[-0.5,-4.8,5.6],[0,-4.9,5.65],[0.5,-4.8,5.6],[1.2,-4.5,5.5]], iG); // chin curve
    ig.position.set(0, 14, 120);
    ig.rotation.y = Math.PI; // face toward camera
    for(var gh=0;gh<5;gh++){var ghy=-4.2-gh*0.25;L([[-0.6,ghy,5.6],[0,ghy-0.1,5.7],[0.6,ghy,5.6]],iD);}
    window.__builtHead = ig;
  })();

===== FILE: public/saga2/head-map.html =====
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Head Map — 100 v160 Faces</title>
<style>
  body { margin:0; background:#000; overflow:hidden; font-family:sans-serif; }
  #ui { position:fixed; top:10px; left:10px; color:#0cc; z-index:10; }
  button { background:#ffcc44; border:none; padding:12px 20px; font-size:16px; margin:5px; border-radius:8px; min-height:48px; }
</style>
<script src="/saga2/three.min.js"></script>
</head>
<body>
<div id="ui">
  <div>Head Map — <span id="count">0</span> v160 heads</div>
  <button onclick="toggleCarousel()" id="carouselBtn">Slide View</button>
</div>
<div id="carousel" style="position:fixed; bottom:20px; left:0; right:0; text-align:center; z-index:20; display:none;">
  <button onclick="prevFace()" style="font-size:32px; padding:20px 30px;">◀</button>
  <span id="faceNum" style="color:#ffcc44; font-size:24px; margin:0 20px;">1 / 0</span>
  <button onclick="nextFace()" style="font-size:32px; padding:20px 30px;">▶</button>
  <div><input type="range" id="faceSlider" min="0" max="0" value="0" oninput="jumpTo(this.value)" style="width:80%; margin-top:10px;"></div>
</div>
<script>
// v160 head builder (inlined below)
</script>
<script src="/saga2/buildHead.js"></script>
<script>
var scene = new THREE.Scene();
var camera = new THREE.PerspectiveCamera(50, innerWidth/innerHeight, 0.1, 2000);
var renderer = new THREE.WebGLRenderer();
renderer.setSize(innerWidth, innerHeight);
document.body.appendChild(renderer.domElement);

var heads = [];
function generate(n) {
  for(var i=0;i<n;i++) {
    var h = buildHead();
    var idx = heads.length;
    var cols = 10;
    var x = (idx % cols - cols/2) * 30;
    var z = -Math.floor(idx / cols) * 35;
    h.position.set(x, 0, z);
    // v160 head is at (0,14,120) facing camera, scale to fit grid
    // The head is large, keep as is for detail
    scene.add(h);
    heads.push(h);
  }
  document.getElementById('count').textContent = heads.length;
  var rows = Math.ceil(heads.length / 10);
  var gridW = 10*30, gridH = rows*35;
  var dist = Math.max(gridW, gridH) * 0.75;
  camera.position.set(0, 14, dist);
  camera.lookAt(0, 14, -gridH/2);
}

// Carousel
var carouselMode = false, currentFace = 0;
function toggleCarousel() {
  carouselMode = !carouselMode;
  document.getElementById('carousel').style.display = carouselMode ? 'block' : 'none';
  document.getElementById('carouselBtn').textContent = carouselMode ? 'Grid View' : 'Slide View';
  if(carouselMode && heads.length>0) showFace(0);
  else {
    var rows=Math.ceil(heads.length/10);
    var gridW=10*30, gridH=rows*35;
    var dist=Math.max(gridW,gridH)*0.75;
    camera.position.set(0,14,dist);
    camera.lookAt(0,14,-gridH/2);
  }
}
function showFace(i) {
  if(heads.length==0) return;
  currentFace = ((i % heads.length) + heads.length) % heads.length;
  var h = heads[currentFace];
  camera.position.set(h.position.x, 14, h.position.z + 45);
  camera.lookAt(h.position.x, 14, h.position.z);
  document.getElementById('faceNum').textContent = (currentFace+1) + ' / ' + heads.length;
  document.getElementById('faceSlider').max = heads.length-1;
  document.getElementById('faceSlider').value = currentFace;
}
function nextFace(){ showFace(currentFace+1); }
function prevFace(){ showFace(currentFace-1); }
function jumpTo(v){ showFace(parseInt(v)); }
var touchX=0;
document.addEventListener('touchstart'