← Natura·Docs/Modules/Source

creatures/render3d.js

614 lines · in natura-src-1.0.0.zip as natura/creatures/render3d.js · a module · its entry in the module reference

// creatures/render3d.js -- FIRST-CLASS 3D for the natura creatures framework.
//
// The 2D side (render.js) draws a creature in profile. This draws it as a real, solid, lit 3D FORM you can
// turn to any angle -- the same creature, consistent across mediums. A creature is assembled from parametric
// PRIMITIVES (ellipsoid, tube, sheet) into a triangle soup with per-face colour + normal; the renderer rotates
// by (azimuth, elevation), shades each face by a key/fill/ambient light, painter-sorts by depth, and draws.
// A pose object (trunkCurl, earFlare, headDown, gait...) deforms the build so behaviour.js can drive it.
//
// Correctness is verified the bkit way: montage() renders the canonical multi-view (front/back/sides/isos/top)
// so every angle can be checked at once, not just a turntable.
//
// UMD: window.Creatures3D (and Node for tests). No deps.
(function (root, factory) {
  if (typeof module !== 'undefined' && module.exports) module.exports = factory();
  else root.Creatures3D = factory();
})(typeof self !== 'undefined' ? self : this, function () {
  'use strict';

  // ---------- tiny 3d ----------
  const V = (x, y, z) => ({ x, y, z });
  const sub = (a, b) => V(a.x - b.x, a.y - b.y, a.z - b.z);
  const cross = (a, b) => V(a.y * b.z - a.z * b.y, a.z * b.x - a.x * b.z, a.x * b.y - a.y * b.x);
  const norm = (a) => { const l = Math.hypot(a.x, a.y, a.z) || 1; return V(a.x / l, a.y / l, a.z / l); };
  const dot = (a, b) => a.x * b.x + a.y * b.y + a.z * b.z;

  // ---------- primitives: each pushes faces {v:[3 or 4 verts], c:[r,g,b], dbl?} into `out` ----------
  // an ellipsoid centred at c, radii r, optionally squashed by a per-vertex shaper fn(x01,y01,z)->{}
  function ellipsoid(out, c, r, nu, nv, col, shape) {
    for (let i = 0; i < nv; i++) {
      const v0 = i / nv, v1 = (i + 1) / nv;
      const th0 = v0 * Math.PI, th1 = v1 * Math.PI;
      for (let j = 0; j < nu; j++) {
        const u0 = j / nu * 2 * Math.PI, u1 = (j + 1) / nu * 2 * Math.PI;
        const p = (th, u) => {
          let x = Math.sin(th) * Math.cos(u), y = Math.cos(th), z = Math.sin(th) * Math.sin(u);
          let P = V(c.x + x * r.x, c.y + y * r.y, c.z + z * r.z);
          if (shape) P = shape(P, th, u);
          return P;
        };
        const vv = [p(th0, u0), p(th0, u1), p(th1, u1), p(th1, u0)];
        const cc = (typeof col === 'function') ? col({ x:(vv[0].x+vv[2].x)/2, y:(vv[0].y+vv[2].y)/2, z:(vv[0].z+vv[2].z)/2 }) : col;
        out.push({ v: vv, c: cc });   // col may be a per-face fn(centre) -> markings (stripes, bars, spots)
      }
    }
  }
  // a tapered tube along a centreline (array of V), radius per station, `nseg` sides.
  // Uses a ROTATION-MINIMIZING frame (parallel transport): each ring's orientation is carried from the previous
  // by minimal rotation, never snapped to world-up. Without this, a centreline that kinks near-vertical (an
  // elephant knee) flips the frame 180 deg and the ring winds backwards -> an hourglass pinch.
  function tube(out, path, rad, nseg, col, capEnd) {
    const rings = [];
    let side = null;
    for (let k = 0; k < path.length; k++) {
      const p = path[k];
      const t = norm(sub(path[Math.min(path.length - 1, k + 1)], path[Math.max(0, k - 1)]));
      if (k === 0) {                                  // seed the first frame off world-up
        side = cross(t, V(0, 1, 0)); if (Math.hypot(side.x, side.y, side.z) < 1e-3) side = cross(t, V(1, 0, 0));
        side = norm(side);
      } else {                                        // transport: project prev side onto the plane perp to t
        const d = dot(side, t);
        let s = V(side.x - t.x * d, side.y - t.y * d, side.z - t.z * d);
        if (Math.hypot(s.x, s.y, s.z) < 1e-4) { s = cross(t, V(0, 1, 0)); if (Math.hypot(s.x, s.y, s.z) < 1e-3) s = cross(t, V(1, 0, 0)); }
        side = norm(s);
      }
      const up = norm(cross(side, t));
      const ring = [];
      for (let j = 0; j < nseg; j++) { const a = j / nseg * 2 * Math.PI, ca = Math.cos(a), sa = Math.sin(a), rr = rad[k];
        ring.push(V(p.x + (side.x * ca + up.x * sa) * rr, p.y + (side.y * ca + up.y * sa) * rr, p.z + (side.z * ca + up.z * sa) * rr)); }
      rings.push(ring);
    }
    const fc = (typeof col === 'function');   // col may be a per-face fn(centre) -> a marking/colour-wave that runs ALONG the tube (limb, tail, octopus arm)
    for (let k = 0; k < rings.length - 1; k++) for (let j = 0; j < nseg; j++) {
      const j2 = (j + 1) % nseg;
      const vv = [rings[k][j], rings[k][j2], rings[k + 1][j2], rings[k + 1][j]];
      out.push({ v: vv, c: fc ? col({ x:(vv[0].x+vv[2].x)/2, y:(vv[0].y+vv[2].y)/2, z:(vv[0].z+vv[2].z)/2 }) : col });
    }
    if (capEnd) { const e = rings[rings.length - 1]; for (let j = 1; j < nseg - 1; j++) { const vv=[e[0], e[j], e[j + 1]];
      out.push({ v: vv, c: fc ? col({ x:(vv[0].x+vv[1].x+vv[2].x)/3, y:(vv[0].y+vv[1].y+vv[2].y)/3, z:(vv[0].z+vv[1].z+vv[2].z)/3 }) : col }); } }
  }
  // a curved rectangular SHEET (an ear): grid u x v over a bilinear+bow surface, two-sided
  function sheet(out, corner, du, dv, bow, bowDir, nu, nv, col) {
    const P = (a, b) => {
      const bx = Math.sin(a * Math.PI) * Math.sin(b * Math.PI) * bow;   // bow out in the middle
      return V(corner.x + du.x * a + dv.x * b + bowDir.x * bx,
               corner.y + du.y * a + dv.y * b + bowDir.y * bx,
               corner.z + du.z * a + dv.z * b + bowDir.z * bx);
    };
    for (let i = 0; i < nv; i++) for (let j = 0; j < nu; j++) {
      const a0 = j / nu, a1 = (j + 1) / nu, b0 = i / nv, b1 = (i + 1) / nv;
      out.push({ v: [P(a0, b0), P(a1, b0), P(a1, b1), P(a0, b1)], c: col, dbl: true });
    }
  }

  // a proper elephant EAR: a rounded fan hinged high on the head-side, hanging down, fanning out+back, cupped.
  // (u: 0 top-hinge .. 1 bottom; v: 0 hinge/head-edge .. 1 outer free edge). size sets African vs Asian.
  function earFan(out, hx, ty, hz, s, size, col, colIn, res) {
    const rq = res == null ? 0.7 : res; const NA = Math.max(5, Math.round(6 + rq * 4)), NB = Math.max(4, Math.round(4 + rq * 4));
    const P = (u, v) => {
      const billow = Math.sin(Math.PI * (0.12 + u * 0.80));        // widest in the vertical middle, rounding top and bottom
      const outr = v * size * 0.95 * billow;                       // fan out to the side toward the free edge
      // ATTACHMENT line curves to match the head: forward at the TOP and BOTTOM (sin=0), back toward the neck in the
      // MIDDLE (sin=1) -- a C that hugs the round of the skull, so the flap never merges flat into the shoulder.
      const attX = hx - size * 0.20 * Math.sin(Math.PI * u);   // gentle back-bow (too much projects the far ear as a dark edge over the neck)
      // the pinna cups FORWARD (+x) and the curl GROWS toward the free edge, so the tip curls forward too (not back);
      // mid-height curls most. v (not sin(pi*v)) keeps it increasing all the way out to the free edge.
      const cupF = v * (0.4 + 0.6 * Math.sin(Math.PI * u)) * size * 0.36;
      return V(attX + cupF,                                        // forward-cupping flap
               ty - u * size * 0.86 - v * size * 0.05 * billow,    // hang down the head; free edge droops slightly
               hz + s * (0.10 + outr));                            // on the head side, fanning out
    };
    const G = (fn) => { const g = []; for (let i = 0; i <= NA; i++) { g.push([]); for (let j = 0; j <= NB; j++) g[i].push(fn(i / NA, j / NB)); }
      for (let i = 0; i < NA; i++) for (let j = 0; j < NB; j++) out.push({ v: [g[i][j], g[i + 1][j], g[i + 1][j + 1], g[i][j + 1]], c: (fn.__c), dbl: true }); };
    const outer = (u, v) => P(u, v); outer.__c = col; G(outer);
    const inner = (u, v) => { const p = P(u * 0.86 + 0.06, v * 0.80 + 0.06); return V(p.x + 0.03, p.y, p.z); }; inner.__c = colIn; G(inner);  // warmer inner ear on the forward face
  }

  const bez = (P0, P1, P2, P3, u) => { const m = 1 - u; return V(
    m*m*m*P0.x+3*m*m*u*P1.x+3*m*u*u*P2.x+u*u*u*P3.x,
    m*m*m*P0.y+3*m*m*u*P1.y+3*m*u*u*P2.y+u*u*u*P3.y,
    m*m*m*P0.z+3*m*m*u*P1.z+3*m*u*u*P2.z+u*u*u*P3.z); };

  // ---------- the ELEPHANT (African bush): highest at the shoulder, sloping back, huge ears, long trunk ----------
  function buildElephant(pose) {
    pose = pose || {};
    const out = [];
    const SKIN = [138, 133, 126], SKIN_D = [150, 145, 137], BELLY = [120, 116, 110];
    const IVORY = [232, 226, 208], DARK = [40, 37, 34], INNER = [150, 132, 128];
    const curl = pose.trunkCurl || 0, flare = pose.earFlare || 0, headDown = pose.headDown || 0;
    const asian = pose.species === 'asian';   // Asian = cuter (small ears, twin-domed head, convex back, 1 trunk-finger); African = grander
    const detail = pose.detail == null ? 0.7 : pose.detail;   // 0 = soft childlike toy .. 1 = refined (real eyes, lids, smooth mesh)
    const q = (lo, hi) => Math.max(4, Math.round(lo + (hi - lo) * detail));   // mesh resolution scales with the quality slider

    // BODY -- long and deep, a FLAT-ish top with a high shoulder/withers sloping down to a lower rounded
    // hindquarter (the African saddle back); widest at the belly, tapering toward the shoulders.
    ellipsoid(out, V(-0.05, 1.60, 0), V(1.28, 0.64, 0.63), q(16, 34), q(10, 20), SKIN, (P) => {
      const fx = (P.x + 1.33) / 2.61;                 // 0 tail .. 1 head-end
      if (P.y > 1.60) {                               // shape only the upper (back) surface
        if (asian) P.y += 0.17 * Math.sin(Math.PI * Math.min(1, Math.max(0, (fx - 0.03) / 0.92)));  // convex back, highest at the middle
        else       P.y += 0.24 * Math.max(0, fx - 0.08) - 0.02;                                       // African: high shoulder sloping down to the hind
        if (P.y > 1.80) P.y = 1.80 + (P.y - 1.80) * 0.5;                                              // gently flatten any peak
      }
      if (fx > 0.70) { const t = (fx - 0.70) / 0.30; P.z *= (1 - 0.30 * t); }   // shoulders narrow toward the neck
      if (fx < 0.12) { const t = (0.12 - fx) / 0.12; P.x -= 0.05 * t; }         // round the rump in
      return P;
    });
    // anatomical MASSES so the barrel is not a smooth dirigible: distinct shoulder + haunch + a blunt chest
    ellipsoid(out, V(0.60, 1.66, 0), V(0.50, 0.52, 0.65), q(10, 18), q(7, 13), SKIN);   // shoulder mass over the front legs
    ellipsoid(out, V(-0.74, 1.56, 0), V(0.56, 0.55, 0.63), q(10, 18), q(7, 13), SKIN);  // rounded haunch over the hind legs
    // (chest/brisket mass removed -- as a separate ellipsoid it interpenetrated the neck/body and z-fought; the body front + shoulder carry the bulk)

    // LEGS -- thick graviportal pillars. An elephant's leg is nearly columnar; the knee is a SMOOTH swelling in
    // the pillar (not a stuck-on ball) with a gentle forward set, and the shank narrows to the ankle before the
    // broad foot flares. Modelled as ONE continuous tapered tube so there is no doorknob seam.
    const stride = (pose.gait || 0);
    const legs = [[0.80, 0.42, 0, 0.10], [0.80, -0.42, 1, 0.10], [-0.86, 0.42, 2, 0.16], [-0.86, -0.42, 3, 0.16]];  // x,z,phase,kneeSet(hind knee sits a touch more forward)
    for (const [lx, lz, idx, kset] of legs) {
      const ph = stride * 2 * Math.PI + idx * Math.PI / 2;   // diagonal-ish walking sequence
      const swing = Math.sin(ph) * 0.16 * (stride ? 1 : 0), lift = Math.max(0, Math.sin(ph)) * 0.11 * (stride ? 1 : 0);
      const bend = Math.max(0, -Math.sin(ph)) * 0.08 * (stride ? 1 : 0);
      const footY = 0.06 + lift, kneeY = 0.72 + lift * 0.5;
      const kx = lx + kset + swing * 0.4 + bend, fx = lx + swing;    // knee sits slightly forward; steps out on the swing
      const path = [ V(lx + kset * 0.2, 1.60, lz),                          // near-point tip buried DEEP in the belly -> no open hip funnel
                     V(lx + kset * 0.4, 1.14, lz),                          // emerges from the body at full width
                     V(kx, kneeY, lz),                                      // knee
                     V(kx * 0.4 + fx * 0.6, (kneeY + footY) / 2, lz),       // shank
                     V(fx, footY + 0.08, lz) ];                             // ankle, just above the foot
      const rad  = [ 0.06, 0.225, 0.246, 0.195, 0.216 ];                    // buried tip -> thigh -> SWELL at the knee -> narrow shank -> ankle flare
      tube(out, path, rad, q(8, 16), SKIN);
      ellipsoid(out, V(fx, footY, lz), V(0.30, 0.085, 0.30), q(8, 14), q(5, 7), SKIN_D);   // broad FLAT foot pad (not a ball)
      for (let n = -1; n <= 1; n++) ellipsoid(out, V(fx + 0.20, footY + 0.015, lz + n * 0.12), V(0.046, 0.038, 0.05), 6, 4, IVORY); // neat toenails at the front rim
    }

    // HEAD -- an African head (taller than deep), forward+low of the shoulders on a short thick neck
    const hx = 1.34, hy = 1.74 - headDown * 0.36, hz = 0;
    tube(out, [V(0.95, 1.70, 0), V(hx - 0.16, hy - 0.06, 0)], [0.46, 0.42], q(8, 14), SKIN);   // short thick neck (kept UNDER the head crown -- a fat neck ring here pokes up as a spike)
    ellipsoid(out, V(hx, hy, hz), asian ? V(0.45, 0.55, 0.49) : V(0.42, 0.52, 0.46), q(11, 20), q(9, 15), SKIN, asian ? (P) => {
      if (P.y > hy + 0.22) { const dz = P.z / 0.49; P.y += 0.11 * Math.abs(dz) - 0.06; }   // Asian TWIN-DOME: two crown bumps with a dip between
      return P;
    } : null);
    // EYES -- small, dark, wet beads set LOW on the head-sides (about trunk-root height), deep in a fleshy socket,
    // hooded by a heavy upper lid. NOT cartoon dots and NOT glowing: a dark honey iris nearly filled by the pupil,
    // sold as "alive" by a single tiny catchlight. Elephant eyes are small for the head -- restraint is correctness.
    const AMBER = [92, 63, 38], PUP = [22, 17, 13], HI = [224, 221, 213];
    for (const s of [-1, 1]) {
      const ex = hx + 0.30, ey = hy - 0.01, ez = s * 0.36;
      // NO dark socket-cave -- the eyeball already sits proud of the head-side; frame it with fleshy LIDS instead,
      // which gives the deep-lidded elephant look without a black pit swallowing the eye.
      ellipsoid(out, V(ex, ey, ez), V(0.056, 0.059, 0.054), 10, 8, AMBER);                       // small dark-honey eyeball, proud on the head-side
      ellipsoid(out, V(ex + 0.040, ey, ez + s * 0.006), V(0.033, 0.035, 0.031), 8, 6, PUP);      // iris/pupil nearly filling it
      if (detail > 0.4) {
        ellipsoid(out, V(ex + 0.050, ey + 0.017, ez - s * 0.010), V(0.010, 0.010, 0.010), 5, 4, HI); // one tiny catchlight -> wet, alive
        ellipsoid(out, V(ex - 0.02, ey + 0.052, ez), V(0.11, 0.032, 0.094), 8, 4, SKIN_D);           // heavy hooded upper lid (a fold, not a cave)
        ellipsoid(out, V(ex - 0.01, ey - 0.050, ez), V(0.10, 0.028, 0.086), 8, 4, SKIN_D);           // soft lower lid / cheek fold
      }
    }

    // EARS -- African: enormous fans that reach below the jaw; Asian: much smaller, higher, folded. Flare opens them.
    for (const s of [-1, 1]) earFan(out, hx - 0.28, hy + 0.24, hz + s * (0.37 + flare * 0.12), s, (asian ? 0.58 : 1.05) + flare * 0.06, SKIN, INNER, detail);   // hinge on the head SIDE surface (not deep inside it) -> far less ear/head z-fighting

    // TRUNK -- a long tapering PREHENSILE nose from the head-front. Curls with pose.trunkCurl, OR reaches its tip to
    // a model-space target pose.trunkReach {x,y,z} (an action-layer hook: the trunk grasps/points/paints toward a spot).
    {
      const rootx = hx + 0.48, rooty = hy - 0.16;   // forward to the FACE FRONT + lower to the mouth, so the trunk clearly grows FROM the face (not from behind the head ball)
      const P0 = V(rootx, rooty, 0);
      let P1, P2, P3;
      const reach = pose.trunkReach;
      if (reach) {                                              // aim the tip AT the target, with a natural sagging prehensile curve
        const tx = reach.x, ty = reach.y, tz = reach.z || 0;
        P1 = V(rootx + 0.34, rooty - 0.42, tz * 0.15);          // initial droop out of the face
        P2 = V((rootx + tx) / 2 + 0.06, Math.min(rooty, ty) - 0.34, tz * 0.6);  // mid sag below the chord
        P3 = V(tx, ty, tz);                                     // the tip, AT the target
      } else {
        P1 = V(rootx + 0.35, rooty - 0.55 - curl * 0.1, 0);
        P2 = V(rootx + 0.45 - curl * 0.7, rooty - 1.05 + curl * 0.5, 0);
        P3 = V(rootx + 0.30 - curl * 1.4, rooty - 1.35 + curl * 1.25, 0);
      }
      const path = [], rad = [], NB = q(8, 14);
      for (let i = 0; i <= NB; i++) { const u = i / NB; path.push(bez(P0, P1, P2, P3, u)); rad.push(0.20 * (1 - 0.72 * u)); }
      tube(out, path, rad, q(8, 14), SKIN, true);
      // two finger-lips at the tip
      const tip = path[NB];
      if (asian) ellipsoid(out, V(tip.x + 0.02, tip.y + 0.03, 0), V(0.055, 0.06, 0.05), 6, 4, SKIN_D);   // Asian: ONE finger-lip
      else { ellipsoid(out, V(tip.x, tip.y + 0.03, 0.035), V(0.05, 0.05, 0.05), 6, 4, SKIN_D);           // African: TWO finger-lips
             ellipsoid(out, V(tip.x, tip.y + 0.03, -0.035), V(0.05, 0.05, 0.05), 6, 4, SKIN_D); }
    }

    // TUSKS -- curved ivory, forward-down then hooking up, beside the trunk root
    for (const s of [-1, 1]) {
      const bx = hx + 0.30, by = hy - 0.22, bz = s * 0.22;
      const rr = asian ? 0.05 : 0.088, ext = asian ? 0.42 : 1.0;    // Asian tusks are short tushes; African tusks are grand
      const T0 = V(bx, by, bz), T1 = V(bx + 0.45 * ext, by - 0.30 * ext, bz + s * 0.05), T2 = V(bx + 0.75 * ext, by - 0.45 * ext, bz + s * 0.02), T3 = V(bx + 0.92 * ext, by - 0.30 * ext, bz);
      const path = [], rad = [], NB = 8;
      for (let i = 0; i <= NB; i++) { const u = i / NB; path.push(bez(T0, T1, T2, T3, u)); rad.push(rr * (1 - 0.8 * u) + 0.012); }
      tube(out, path, rad, 8, IVORY, true);
    }

    // TAIL -- thin, from the hind, with a dark tuft
    {
      const T0 = V(-1.15, 1.75, 0), T1 = V(-1.30, 1.35, 0), T2 = V(-1.28, 0.95, 0.02), T3 = V(-1.22, 0.72, 0);
      const path = [], rad = [], NB = 7;
      for (let i = 0; i <= NB; i++) { const u = i / NB; path.push(bez(T0, T1, T2, T3, u)); rad.push(0.05 * (1 - 0.5 * u)); }
      tube(out, path, rad, 6, SKIN);
      ellipsoid(out, path[NB], V(0.07, 0.12, 0.07), 6, 5, DARK);   // the tuft
    }
    return out;
  }

  // ---------- the RED-EYED TREE FROG: crouched, bright green, the signature bulging red eyes, folded hind legs ----------
  function buildTreeFrog(pose) {
    pose = pose || {};
    const out = [];
    const GREEN=[96,186,82], BELLY=[226,228,201], EYE=[214,54,44], PUP=[28,24,26], HI=[255,255,255], FOOT=[236,150,58], TOE=[244,168,74], FLANK=[64,112,196], CREAM=[232,234,214];
    const detail = pose.detail==null?0.78:pose.detail, q=(lo,hi)=>Math.max(5,Math.round(lo+(hi-lo)*detail));
    const t=pose.t||0, breath=Math.sin(t*1.7), call=pose.call||0, throat=Math.max(0,Math.sin(t*1.7))*Math.max(0,Math.sin(t*1.7))+call*1.4;  // slow breath; the throat balloons on the out-breath and HUGE on a croak
    // its SIGNATURE flank marking: vertical blue-and-cream bars low on the sides, green everywhere else
    const skin=(P)=>{ const az=Math.abs(P.z); if (az>0.42 && P.y>0.34 && P.y<0.72){ const b=Math.sin((P.x+0.15)*6.2); if (b>0.30) return FLANK; if (b<-0.34) return CREAM; } return GREEN; };
    // BODY -- crouched, rounded, a touch wider than tall; belly flattened, chest/head lifted (the alert perch)
    ellipsoid(out, V(0,0.62,0), V(0.86,0.60,0.66), q(16,30), q(11,20), skin, (P)=>{
      const fx=(P.x+0.86)/1.72;
      if (P.y < 0.62) P.y = 0.62 + (P.y-0.62)*0.70;                 // flatten the belly
      if (fx>0.60) P.y += (fx-0.60)*0.55;                            // lift the front toward the head
      if (fx<0.18){ const t2=(0.18-fx)/0.18; P.x += 0.06*t2; }       // tuck the rump in
      P.y += breath*0.016;                                           // the whole body breathes
      return P;
    });
    ellipsoid(out, V(0.66,0.42,0), V(0.22,0.10+throat*0.17,0.28+throat*0.07), q(9,14), q(6,9), GREEN);  // the throat sac rests small and puffs on the out-breath (always drawn -> no pop-in)
    ellipsoid(out, V(0.03,0.30,0), V(0.66,0.24,0.52), q(12,20), q(7,12), BELLY);        // cream belly underneath
    ellipsoid(out, V(0.82,0.80,0), V(0.30,0.27,0.42), q(10,18), q(7,12), GREEN, (P)=>{ if(P.x>0.82)P.x=0.82+(P.x-0.82)*0.65; return P; });  // rounded snout
    // EYES -- two HUGE bulging turrets high on the head; the red globe with a vertical slit pupil is the whole identity
    for (const s of [-1,1]) {
      const ex=0.60, ey=1.22, ez=s*0.42;
      ellipsoid(out, V(ex-0.02,ey-0.06,ez*0.96), V(0.27,0.27,0.26), q(11,17), q(8,12), GREEN);   // green eye-turret socket
      ellipsoid(out, V(ex+0.06,ey+0.06,ez*1.02), V(0.245,0.255,0.245), q(13,20), q(9,13), EYE);  // the big red globe
      ellipsoid(out, V(ex+0.235,ey+0.08,ez*1.08), V(0.075,0.185,0.055), q(9,13), q(7,10), PUP);  // tall vertical slit pupil, proud of the globe (its signature)
      ellipsoid(out, V(ex+0.275,ey+0.17,ez*1.06+0.05*s), V(0.045,0.055,0.045), 6,5, HI);         // catchlight above the pupil
      ellipsoid(out, V(ex+0.02,ey-0.20,ez), V(0.22,0.07,0.20), q(8,12), q(5,8), FOOT);           // warm lower eyelid rim
    }
    // LEGS -- a plump hind thigh folded up along the flank, knee high at the rear, shank down-forward to a splayed foot; short front legs tucked under
    const foot=(x,y,z)=>{ for(let t=-1;t<=1;t++){ const a=t*0.42; ellipsoid(out, V(x+Math.cos(a)*0.18, y-0.01, z+Math.sin(a)*0.18), V(0.10,0.05,0.055),7,5,TOE); } ellipsoid(out, V(x,y,z), V(0.11,0.06,0.14), 8,6, FOOT); };
    for (const s of [-1,1]) {
      ellipsoid(out, V(-0.20,0.78,s*0.60), V(0.36,0.28,0.19), q(9,15), q(6,10), GREEN);                                   // folded thigh mass (reads as a leg, not a blade)
      tube(out, [V(0.00,0.98,s*0.70), V(0.20,0.54,s*0.74), V(0.40,0.20,s*0.68), V(0.54,0.08,s*0.60)], [0.135,0.115,0.09,0.075], q(6,9), GREEN);  // shank -> ankle
      foot(0.66, 0.05, s*0.58);
      tube(out, [V(0.58,0.52,s*0.40), V(0.68,0.28,s*0.42), V(0.80,0.09,s*0.36)], [0.10,0.085,0.055], q(5,8), GREEN);      // front leg, tucked
      foot(0.90, 0.05, s*0.32);
    }
    return out;
  }

  // ---------- a small perched SONGBIRD (the first bird): plump upright body, warm breast, folded wings, short beak ----------
  function buildBird(pose) {
    pose = pose || {};
    const out = [];
    const BACK=[104,116,140], WING=[80,92,118], WDK=[58,68,92], BELLY=[234,228,208], BREAST=[212,118,70], CAP=[60,66,82], BEAK=[236,166,64], EYE=[24,20,24], HI=[255,255,255], LEG=[206,150,86];
    const detail=pose.detail==null?0.8:pose.detail, q=(lo,hi)=>Math.max(5,Math.round(lo+(hi-lo)*detail));
    const t=pose.t||0, breath=Math.sin(t*2.0), bob=Math.sin(t*1.3), look=Math.sin(t*0.7)*0.10, call=pose.call||0, hl=call*0.11;   // breast breathes, tail bobs, head glances; on a song the head LIFTS
    // BODY -- an upright egg, plump chest forward (+x), tapering back-and-down to the tail
    ellipsoid(out, V(0,0.80,0), V(0.48,0.62,0.45), q(14,26), q(10,18), BACK, (P)=>{
      const fy=(P.y-0.18)/1.24;
      if (P.x>0) P.x *= (0.86+0.34*fy) * (1+breath*0.03);     // fuller chest toward the top-front; breathing swells it
      if (fy<0.32){ P.y -= (0.32-fy)*0.14; P.x -= (0.32-fy)*0.12; }   // tuck the tail-base under
      return P;
    });
    ellipsoid(out, V(0.26,0.78,0), V(0.30,0.44,0.35), q(10,18), q(7,12), BREAST);   // warm breast
    ellipsoid(out, V(0.18,0.44,0), V(0.30,0.24,0.30), q(9,14), q(6,10), BELLY);     // cream belly
    // HEAD -- round, high and forward, with a darker cap; it glances slowly side to side (look)
    ellipsoid(out, V(0.24,1.36+hl,look), V(0.31,0.31,0.30), q(12,20), q(8,13), BACK);
    ellipsoid(out, V(0.24,1.49+hl,look), V(0.27,0.18,0.27), q(10,16), q(6,10), CAP);
    tube(out, [V(0.50,1.35+hl,look), V(0.68,1.34+hl+call*0.05,look*1.4), V(0.80,1.33+hl+call*0.10,look*1.7)], [0.095,0.05,0.012], q(6,9), BEAK, true);   // short cone beak; tips up as it sings
    if (call>0.15) ellipsoid(out, V(0.60,1.30+hl,look), V(0.05,0.045+call*0.04,0.05), 6,5, [40,26,26]);   // an open gape when singing
    for (const s of [-1,1]) { ellipsoid(out, V(0.42,1.42+hl,s*0.18+look), V(0.065,0.075,0.05), 8,6, EYE); ellipsoid(out, V(0.47,1.46+hl,s*0.18+look), V(0.022,0.028,0.02), 5,4, HI); }
    // WINGS -- a bowed sheet folded down each flank, from the shoulder back to the tail (darker primaries at the tip)
    for (const s of [-1,1]) {
      sheet(out, V(0.30,1.08,s*0.40), V(-0.86,-0.30,0), V(0.02,-0.46,0), 0.13, V(0,0,s), q(8,13), q(4,7), WING);
      tube(out, [V(-0.30,0.86,s*0.42), V(-0.66,0.66,s*0.30), V(-0.92,0.52,s*0.16)], [0.07,0.05,0.02], q(5,8), WDK);   // primary-feather bundle to the tail
    }
    // TAIL -- a downward-back fan of a few feather tubes; it cocks up and down (bob)
    for (let k=-2;k<=2;k++){ const a=k*0.13; tube(out, [V(-0.42,0.62,0), V(-0.82,0.44+bob*0.05,a*0.5), V(-1.10,0.30+bob*0.10,a)], [0.055,0.05,0.02], 4, WING); }
    // LEGS -- two thin legs to little perched feet
    for (const s of [-1,1]) { tube(out, [V(0.10,0.26,s*0.15), V(0.12,0.06,s*0.15)], [0.032,0.026], 5, LEG);
      for (let k=-1;k<=1;k++) tube(out, [V(0.12,0.05,s*0.15), V(0.24+k*0.03,0.02,s*0.15+k*0.06)], [0.022,0.010], 4, LEG); }
    return out;
  }

  // ---------- a bright reef FISH (mandarin-ish): laterally compressed body, fanned fins, a small pursed mouth ----------
  function buildFish(pose) {
    pose = pose || {};
    const out = [];
    const BODY=[46,158,152], BELLY=[126,200,182], FIN=[236,142,52], FINE=[248,196,96], EYE=[22,20,24], HI=[255,255,255], LIP=[220,120,110];
    const detail=pose.detail==null?0.8:pose.detail, q=(lo,hi)=>Math.max(5,Math.round(lo+(hi-lo)*detail));
    const t=pose.t||0, swish=Math.sin(t*2.4), sw2=Math.sin(t*2.4-0.9);   // idle: the body swims a slow S, the tail lags
    const swayZ=(fx01)=> swish*0.16*Math.max(0,0.66-fx01);               // sway grows toward the tail, head held steady
    const DARK=[26,104,116], scaleCol=(P)=>{ const bar=Math.sin(P.x*6.8+0.4); if (bar>0.5 && P.y>0.52) return DARK; if (P.y>0.98) return DARK; return BODY; };  // a few darker vertical bands + a dark dorsal line
    // BODY -- an ellipsoid squashed thin in z (laterally compressed), tallest at the middle, tapering to the tail
    ellipsoid(out, V(0,0.70,0), V(0.72,0.46,0.17), q(16,28), q(11,18), scaleCol, (P)=>{
      const fx=(P.x+0.72)/1.44;                     // 0 tail .. 1 head
      if (fx<0.30){ const t2=(0.30-fx)/0.30; P.y=0.70+(P.y-0.70)*(1-0.55*t2); P.z*=(1-0.7*t2); }  // narrow the caudal peduncle to the tail
      if (P.y<0.70) P.y=0.70+(P.y-0.70)*0.92;
      P.z += swayZ(fx);                             // the whole body flexes as it swims
      return P;
    });
    ellipsoid(out, V(0.04,0.50,0), V(0.52,0.18,0.15), q(11,18), q(6,10), BELLY, (P)=>{ P.z += swayZ((P.x+0.72)/1.44); return P; });   // pale belly
    // DISPLAY -- a reef fish has no voice; it signals by FLARING its fins and firing up their colour.
    // pose.call (0..1) raises the dorsal, fans the pectorals wide, and flushes every fin hot -- its call, in fins.
    const call=pose.call||0, flare=1+call*0.9, mixC=(a,b,r)=>[a[0]+(b[0]-a[0])*r, a[1]+(b[1]-a[1])*r, a[2]+(b[2]-a[2])*r];
    const cFIN = call>0.01 ? mixC(FIN,[255,86,38],call*0.85) : FIN;        // fins flush hot orange on display
    const cFINE= call>0.01 ? mixC(FINE,[255,232,120],call*0.85) : FINE;    // ...and the soft-ray fins to bright gold
    // TAIL FIN -- a fanned sheet at the back (-x), two-sided; it lags the body's swish, sweeping side to side
    const tz=sw2*0.26, tf=1+call*0.35;
    sheet(out, V(-0.62,0.70,tz), V(-0.42,0.30,tz*0.6), V(0,-0.60*tf,0), 0.05, V(0,0,1), q(6,10), q(4,7), cFIN);
    sheet(out, V(-0.62,0.70,tz), V(-0.42,-0.30,tz*0.6), V(0,0.60*tf,0), 0.05, V(0,0,1), q(6,10), q(4,7), cFIN);
    // DORSAL FIN -- a long sheet cresting the back (rises on display); PELVIC/ANAL beneath
    sheet(out, V(0.34,1.06,0), V(-0.70,-0.02,0), V(0,0.34*flare,0), 0.06, V(0,0,1), q(7,12), q(3,6), cFINE);
    sheet(out, V(0.18,0.30,0), V(-0.46,-0.02,0), V(0,-0.28*flare,0), 0.05, V(0,0,1), q(6,10), q(3,5), cFIN);
    // PECTORAL FINS -- one each side, fanning back (spread WIDE + long on display)
    for (const s of [-1,1]) sheet(out, V(0.30,0.66,s*0.14), V(-0.30,0.02,0), V(0,-0.30*flare, s*0.10*(1+call*2.4)), 0.09, V(0,0,s), q(5,9), q(3,6), cFINE);
    // HEAD detail -- eyes high near the front, a small pursed mouth
    for (const s of [-1,1]) { ellipsoid(out, V(0.46,0.82,s*0.12), V(0.075,0.09,0.07), 9,7, EYE); ellipsoid(out, V(0.51,0.86,s*0.13), V(0.025,0.03,0.022),5,4,HI); }
    ellipsoid(out, V(0.70,0.62,0), V(0.07,0.055,0.10), 8,6, LIP);   // little mouth
    return out;
  }

  // ---------- an OCTOPUS: domed mantle, big side eyes, eight curling arms undulating in a travelling wave ----------
  function buildOctopus(pose) {
    pose = pose || {};
    const out = [];
    const MANTLE=[198,88,72], SPOT=[150,54,48], ARM=[214,116,98], SUCK=[238,204,186], EYE=[26,20,22], PUP=[236,232,224], HI=[255,255,255];
    const detail=pose.detail==null?0.8:pose.detail, q=(lo,hi)=>Math.max(5,Math.round(lo+(hi-lo)*detail));
    const t=pose.t||0, pulse=Math.sin(t*1.6)*0.03;   // the mantle breathes
    // CHROMATOPHORE DISPLAY -- an octopus has no voice; it speaks in SKIN. pose.flash (0..1) runs a dark
    // "passing cloud" wave down the body and flushes it deimatically -- the creature's call, in colour.
    // TWO DISPLAYS -- an octopus has no voice; it speaks in SKIN and POSTURE. pose.flash = the calm "passing
    // cloud" conversation (a dark wave down the body). pose.startle = the DEIMATIC threat: it rears up, flings its
    // arms wide, and goes PALE with bold dark false-spots to look huge and dangerous -- the opposite of the cloud.
    const flash=pose.flash||0, startle=pose.startle||0, DARK=[58,18,26], PALE=[228,201,183], DSPOT=[40,15,21], wavePh=t*2.4;
    const mixC=(a,b,r)=>[a[0]+(b[0]-a[0])*r, a[1]+(b[1]-a[1])*r, a[2]+(b[2]-a[2])*r];
    const chroma=(base,P)=>{
      if(startle>0.01){ const m=Math.sin(P.x*8.5)*Math.sin(P.z*8.5)*Math.sin(P.y*6.0);   // DEIMATIC: pale body, bold dark false-spots
        return mixC(base, m>0.42?DSPOT:PALE, Math.min(0.94,startle)); }
      if(flash<0.01) return base;
      const band=0.5+0.5*Math.sin(P.y*3.4 - wavePh), cloud=Math.pow(band,2.4);            // the calm passing cloud runs down the body
      return mixC(base, DARK, Math.min(0.92, flash*(0.32 + 0.62*cloud))); };
    // arm centreline reach + drop; on STARTLE the arms fling WIDE (reach) and UP into the threat-spread (drop rises)
    const reachOf=(u)=>(0.38+u*1.06)*(1+startle*0.5), dropOf=(u)=>1.02 - Math.pow(u,1.35)*1.12 + startle*(0.5+u*1.05);
    const skin=(P)=>{ const m=Math.sin(P.x*8.5)*Math.sin((P.z)*8.5)*Math.sin(P.y*6.0); return chroma(m>0.28 ? SPOT : MANTLE, P); };   // mottled skin, alive with the display
    // MANTLE -- a domed sac, highest at the top, drawn in at the base where the arms spring
    ellipsoid(out, V(0,1.34,0), V(0.44,0.56,0.44), q(15,26), q(10,17), skin, (P)=>{
      if (P.y>1.34) P.y += (P.y-1.34)*0.34;                       // dome the top
      P.y += startle*0.26*Math.max(0, P.y-0.95);                  // rear UP on the deimatic startle
      if (P.y<1.10){ const t2=(1.10-P.y)/0.9; P.x*=(1-0.35*t2); P.z*=(1-0.35*t2); }   // gather toward the arm crown
      const r=1+pulse; P.x*=r; P.z*=r;                            // gentle breathing
      return P;
    });
    // EYES -- large, set on the sides high on the mantle, with a horizontal bar pupil
    for (const s of [-1,1]) {
      ellipsoid(out, V(0.02, 1.42, s*0.40), V(0.17,0.19,0.16), q(9,14), q(6,10), MANTLE);   // raised eye mound
      ellipsoid(out, V(0.10, 1.44, s*0.44), V(0.13,0.14,0.12), q(10,15), q(7,10), EYE);      // dark eyeball
      ellipsoid(out, V(0.19, 1.43, s*0.45), V(0.05,0.10,0.045), q(7,10), q(5,8), PUP);       // pale W-shaped horizontal pupil (approx)
      ellipsoid(out, V(0.23, 1.48, s*0.45), V(0.022,0.028,0.02), 5,4, HI);
    }
    // ARMS -- eight, radiating from the crown, curling out and down, undulating (a travelling wave to the tip)
    for (let i=0;i<8;i++) {
      const a=i/8*Math.PI*2, ca=Math.cos(a), sa=Math.sin(a), px=-sa, pz=ca;
      const N=9, path=[], rad=[];
      for (let k=0;k<=N;k++) { const u=k/N;
        const reach=reachOf(u), drop=dropOf(u);
        const wv=Math.sin(t*3.0 + a*1.6 + u*5.5)*0.18*u;         // side-to-side undulation, grows to the tip
        const curlY=Math.max(0, u-0.78)*0.7;                      // the very tip curls back up
        path.push(V(ca*reach + px*wv, drop + curlY, sa*reach + pz*wv));
        rad.push(Math.max(0.02, 0.145*(1-u*0.92)));
      }
      tube(out, path, rad, q(5,8), (P)=>chroma(ARM,P), true);   // the passing cloud runs down the arms too
      // suckers: a few pale discs along the underside of the arm
      for (let k=2;k<=7;k+=2){ const u=k/N, reach=reachOf(u), drop=dropOf(u), wv=Math.sin(t*3.0+a*1.6+u*5.5)*0.18*u;
        ellipsoid(out, V(ca*reach+px*wv, drop-0.06*(1-u), sa*reach+pz*wv), V(0.04,0.02,0.04), 5,4, SUCK); }
    }
    return out;
  }

  // ---------- render ----------
  const LIGHT = norm(V(-0.4, 0.8, 0.5));      // key from upper-front-left
  function render(ctx, faces, opt) {
    opt = opt || {};
    const az = opt.az || 0, el = opt.el || 0, scale = opt.scale || 120;
    const ox = opt.ox != null ? opt.ox : ctx.canvas.width / 2, oy = opt.oy != null ? opt.oy : ctx.canvas.height / 2;
    const ca = Math.cos(az), sa = Math.sin(az), ce = Math.cos(el), se = Math.sin(el);
    const rot = (p) => {                        // rotate around Y (az) then X (el)
      const x = p.x * ca + p.z * sa, z = -p.x * sa + p.z * ca;
      const y = p.y * ce - z * se, z2 = p.y * se + z * ce;
      return { x, y, z: z2 };
    };
    // ground contact shadow (flatten silhouette to a plane BELOW the feet -- at foot level it z-fights the foot
    // undersides and glitters as the model turns; -0.03 keeps the pool clearly beneath them)
    if (opt.shadow !== false) {
      ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.22)';
      for (const f of faces) { ctx.beginPath();
        for (let i = 0; i < f.v.length; i++) { const r = rot(V(f.v[i].x, -0.03, f.v[i].z)); const X = ox + r.x * scale, Y = oy - (r.y - opt.yc) * scale;
          i ? ctx.lineTo(X, Y) : ctx.moveTo(X, Y); } ctx.closePath(); ctx.fill(); }
      ctx.restore();
    }
    // SMOOTH (Gouraud-ish) shading: average face normals at shared vertices so lighting flows ACROSS facets
    // instead of jumping face-to-face. Canvas 2D can't barycentric-interpolate, so each quad is filled with a
    // gradient between its darkest and brightest corner -- adjacent faces share corner normals, hence equal
    // corner shades, so the seams melt. dbl faces (thin ear sheets) stay flat. Disable with opt.smooth===false.
    const smooth = opt.smooth !== false;
    // a COARSE position key (~0.036 buckets): identical shared vertices still bucket together (within-surface
    // smoothness preserved), AND vertices from DIFFERENT primitives that meet at a junction (shoulder->body,
    // leg->body, trunk->face) now average too -- so the smooth shading flows across the seams between masses.
    const vkey = (p) => Math.round(p.x * 28) + ',' + Math.round(p.y * 28) + ',' + Math.round(p.z * 28);
    // If faces already carry per-vertex normals (f.n -- e.g. the SDF/rigged mesh), SKIP the per-frame bucketing
    // prepass entirely and use those (far cheaper for an animated mesh); n-less faces fall back to flat. Only the
    // parametric mesh (no f.n anywhere) needs the bucket-average pass.
    const anyN = faces.some((f) => f.n);
    let vnorm = null;
    if (smooth && !anyN) {
      vnorm = Object.create(null);
      for (const f of faces) {
        if (f.dbl) continue;
        const gn = norm(cross(sub(f.v[1], f.v[0]), sub(f.v[2], f.v[0])));   // model-space geometric normal
        for (const p of f.v) { const k = vkey(p);
          let a = vnorm[k]; if (!a) a = vnorm[k] = { x: 0, y: 0, z: 0 }; a.x += gn.x; a.y += gn.y; a.z += gn.z; }
      }
    }
    const shadeOf = (rn) => Math.min(1, 0.42 + 0.72 * Math.max(0, dot(rn, LIGHT)) + Math.max(0, rn.y) * 0.12);
    const drawn = [];
    for (const f of faces) {
      const rv = f.v.map(rot);
      const n = norm(cross(sub(rv[1], rv[0]), sub(rv[2], rv[0])));
      let nn = n, facing = n.z;                  // camera looks along -z; visible if normal.z>0
      if (f.dbl && facing < 0) { nn = V(-n.x, -n.y, -n.z); facing = -facing; }
      else if (facing < -0.02) continue;         // backface cull (single-sided)
      // depth = a blend of the face's AVERAGE and its NEAREST vertex. Pure average mis-orders interpenetrating
      // solids (a leg buried in the body flickers through); leaning toward the nearest vertex lets the closer
      // surface win the pixel, so the body occludes the buried leg-top instead of z-fighting it.
      const zavg = (rv[0].z + rv[1].z + rv[2].z + (rv[3] ? rv[3].z : rv[2].z)) / f.v.length;
      const zmax = Math.max(rv[0].z, rv[1].z, rv[2].z, (rv[3] ? rv[3].z : rv[2].z));
      const depth = zavg * 0.55 + zmax * 0.45;
      const sh = shadeOf(nn);                     // flat fallback shade
      let corners = null;
      if (smooth && !f.dbl) {
        if (f.n) corners = f.n.map((nv) => shadeOf(rot(norm(nv))));   // ANALYTIC per-vertex normals (e.g. SDF gradient) -> exact smooth shading
        else if (vnorm) corners = f.v.map((p) => { const a = vnorm[vkey(p)]; return a ? shadeOf(rot(norm(a))) : sh; });
        else corners = null;   // no normals available (an add-on face on an f.n mesh) -> flat
      }
      drawn.push({ rv, c: f.c, sh, depth, corners, unlit: f.unlit });
    }
    drawn.sort((a, b) => a.depth - b.depth);     // far first
    const rgb = (c, s) => `rgb(${(c[0] * s) | 0},${(c[1] * s) | 0},${(c[2] * s) | 0})`;
    // TRUE per-TRIANGLE Gouraud: shade linearly interpolated across a triangle is an AFFINE function of screen
    // position, so a canvas linear gradient along the shade-gradient direction reproduces it EXACTLY. Adjacent
    // triangles share an edge's two vertex shades and interpolate linearly along it -> the shading is CONTINUOUS
    // across every edge, killing the per-quad dark-edge/light-edge seam a single 2-stop quad gradient left behind.
    function paintTri(P, S, col) {
      const [x0, y0] = P[0], [x1, y1] = P[1], [x2, y2] = P[2];
      const smin = Math.min(S[0], S[1], S[2]), smax = Math.max(S[0], S[1], S[2]);
      let fill;
      if (smax - smin < 0.008) { fill = rgb(col, (smin + smax) * 0.5); }   // only TRULY flat triangles skip the gradient (a higher cutoff reintroduces per-facet steps)
      else {
        const det = (x1 - x0) * (y2 - y0) - (x2 - x0) * (y1 - y0);
        if (Math.abs(det) < 1e-6) { fill = rgb(col, (smin + smax) * 0.5); }
        else {
          const a = ((S[1] - S[0]) * (y2 - y0) - (S[2] - S[0]) * (y1 - y0)) / det;   // d(shade)/dx
          const b = ((x1 - x0) * (S[2] - S[0]) - (x2 - x0) * (S[1] - S[0])) / det;   // d(shade)/dy
          const gl = Math.hypot(a, b);
          if (gl < 1e-9) { fill = rgb(col, S[0]); }
          else {
            const gx = a / gl, gy = b / gl;   // shade rises along (gx,gy), constant perpendicular to it
            let iMin = 0, iMax = 0, tMin = Infinity, tMax = -Infinity;
            for (let i = 0; i < 3; i++) { const t = P[i][0] * gx + P[i][1] * gy; if (t < tMin) { tMin = t; iMin = i; } if (t > tMax) { tMax = t; iMax = i; } }
            const g = ctx.createLinearGradient(P[iMin][0], P[iMin][1], P[iMax][0], P[iMax][1]);
            g.addColorStop(0, rgb(col, S[iMin])); g.addColorStop(1, rgb(col, S[iMax])); fill = g;
          }
        }
      }
      ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); ctx.lineTo(x2, y2); ctx.closePath();
      ctx.fillStyle = fill; ctx.fill();
      if (!opt.wire) { ctx.strokeStyle = fill; ctx.lineWidth = 1.0; ctx.lineJoin = 'round'; ctx.stroke(); }   // close the AA seam in-paint
    }
    for (const d of drawn) {
      const pts = d.rv.map((v) => [ox + v.x * scale, oy - (v.y - opt.yc) * scale]);
      if (d.unlit) {                                      // UNLIT: one flat static colour, no lighting (e.g. tusks/ivory)
        const f = rgb(d.c, 0.92);
        ctx.beginPath(); for (let i = 0; i < pts.length; i++) { i ? ctx.lineTo(pts[i][0], pts[i][1]) : ctx.moveTo(pts[i][0], pts[i][1]); } ctx.closePath();
        ctx.fillStyle = f; ctx.fill();
        if (opt.wire) { ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.lineWidth = 0.5; } else { ctx.strokeStyle = f; ctx.lineWidth = 1.0; ctx.lineJoin = 'round'; }
        ctx.stroke();
      } else if (d.corners && pts.length >= 3) {          // smooth: split quad into 2 triangles, exact Gouraud each
        paintTri([pts[0], pts[1], pts[2]], [d.corners[0], d.corners[1], d.corners[2]], d.c);
        if (pts.length === 4) paintTri([pts[0], pts[2], pts[3]], [d.corners[0], d.corners[2], d.corners[3]], d.c);
      } else {                                            // flat face (dbl ear sheets, tube caps, or smooth off)
        const f = rgb(d.c, d.sh);
        ctx.beginPath(); for (let i = 0; i < pts.length; i++) { i ? ctx.lineTo(pts[i][0], pts[i][1]) : ctx.moveTo(pts[i][0], pts[i][1]); } ctx.closePath();
        ctx.fillStyle = f; ctx.fill();
        if (opt.wire) { ctx.strokeStyle = 'rgba(0,0,0,0.15)'; ctx.lineWidth = 0.5; } else { ctx.strokeStyle = f; ctx.lineWidth = 1.0; ctx.lineJoin = 'round'; }
        ctx.stroke();
      }
      if (opt.wire && d.corners) { ctx.strokeStyle = 'rgba(0,0,0,0.2)'; ctx.lineWidth = 0.5; ctx.beginPath(); for (let i = 0; i < pts.length; i++) { i ? ctx.lineTo(pts[i][0], pts[i][1]) : ctx.moveTo(pts[i][0], pts[i][1]); } ctx.closePath(); ctx.stroke(); }
    }
  }

  // the canonical bkit multi-view, for verifying correctness from every angle at once
  const VIEWS = [
    { name: 'side (R)', az: 0, el: -0.12 },
    { name: 'front', az: -Math.PI / 2, el: -0.12 },
    { name: 'back', az: Math.PI / 2, el: -0.12 },
    { name: 'side (L)', az: Math.PI, el: -0.12 },
    { name: 'iso FR', az: -Math.PI / 4, el: -0.28 },
    { name: 'iso BL', az: 3 * Math.PI / 4, el: -0.28 },
    { name: 'iso FL', az: -3 * Math.PI / 4, el: -0.28 },
    { name: 'top', az: 0, el: -1.35 },
  ];
  function montage(ctx, pose, opt) {
    opt = opt || {}; const cols = 4, rows = 2, W = ctx.canvas.width, H = ctx.canvas.height;
    const cw = W / cols, ch = H / rows, faces = (opt.build || buildElephant)(pose), yc = opt.yc != null ? opt.yc : 0.95;
    ctx.fillStyle = opt.bg || '#0e1116'; ctx.fillRect(0, 0, W, H);
    VIEWS.forEach((view, i) => {
      const cx = (i % cols) * cw + cw / 2, cy = Math.floor(i / cols) * ch + ch / 2;
      const sc = Math.min(cw, ch) * (view.name === 'top' ? 0.21 : 0.30);
      render(ctx, faces, { az: view.az, el: view.el, scale: sc, ox: cx, oy: cy + ch * 0.10, yc, shadow: false });
      ctx.fillStyle = '#7f8894'; ctx.font = '12px system-ui'; ctx.textAlign = 'center';
      ctx.fillText(view.name, cx, Math.floor(i / cols) * ch + ch - 8);
      ctx.strokeStyle = 'rgba(255,255,255,0.06)'; ctx.strokeRect((i % cols) * cw, Math.floor(i / cols) * ch, cw, ch);
    });
  }

  // place a built face-list into a SCENE: scale, yaw about Y, then translate -> returns transformed copies
  // (so several creatures + props compose into one face list for a single render call -> a living tableau)
  function place(faces, tx, ty, tz, sc, ry, rx) {
    sc = sc == null ? 1 : sc; ry = ry || 0; rx = rx || 0;
    const cy = Math.cos(ry), sy = Math.sin(ry), cx = Math.cos(rx), sx = Math.sin(rx);
    return faces.map(f => ({ c: f.c, dbl: f.dbl, v: f.v.map(p => {
      let x = p.x * sc, y = p.y * sc, z = p.z * sc;
      const y1 = y * cx - z * sx, z1 = y * sx + z * cx; y = y1; z = z1;   // pitch about X
      return { x: (x * cy + z * sy) + tx, y: y + ty, z: (-x * sy + z * cy) + tz };   // yaw about Y, then translate
    }) }));
  }
  // ---------- a SCULPTED, SKINNED creature (2026-09-26: my zebra and giraffe, natura quad3d) ----------
  // Not built from primitives: its own skin (decimated for the canvas, each face coloured from its baked coat),
  // skinned by its own rig's weights, driven by joint matrices baked frame by frame from its IK gait and a pause
  // (quad3d/export_natura3d.py). pose.clip 'walk' | 'pause', pose.t seconds.
  function buildSkinned(D, pose) {
    pose = pose || {};
    const clip = pose.clip || 'walk', F = D.frames[clip], cyc = D.cycle[clip];
    const u = (((pose.t || 0) / cyc) % 1 + 1) % 1, fx = u * F.length, i0 = Math.floor(fx) % F.length, i1 = (i0 + 1) % F.length, a = fx - Math.floor(fx);
    const nj = F[0].length, M = new Float64Array(nj * 12);
    for (let j = 0; j < nj; j++) { const m0 = F[i0][j], m1 = F[i1][j]; for (let k = 0; k < 12; k++) M[j * 12 + k] = m0[k] * (1 - a) + m1[k] * a; }
    const vs = D.verts, wi = D.widx, wv = D.wval, nv = vs.length, P = new Float64Array(nv * 3);
    for (let v = 0; v < nv; v++) {
      const x = vs[v][0], y = vs[v][1], z = vs[v][2], ji = wi[v], ww = wv[v]; let px = 0, py = 0, pz = 0;
      for (let q = 0; q < 4; q++) { const w = ww[q]; if (!w) continue; const o = ji[q] * 12;
        px += w * (M[o] * x + M[o + 1] * y + M[o + 2] * z + M[o + 3]);
        py += w * (M[o + 4] * x + M[o + 5] * y + M[o + 6] * z + M[o + 7]);
        pz += w * (M[o + 8] * x + M[o + 9] * y + M[o + 10] * z + M[o + 11]); }
      P[v * 3] = px; P[v * 3 + 1] = py; P[v * 3 + 2] = pz;
    }
    // smooth normals from the posed faces (the mesh is split at its UV seams: weld by position for the normals)
    const T = D.tris, N = new Float64Array(nv * 3);
    for (const t of T) { const A = t[0] * 3, B = t[1] * 3, Cc = t[2] * 3;
      const ux = P[B] - P[A], uy = P[B + 1] - P[A + 1], uz = P[B + 2] - P[A + 2], vx = P[Cc] - P[A], vy = P[Cc + 1] - P[A + 1], vz = P[Cc + 2] - P[A + 2];
      const nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx;
      for (const k of t) { N[k * 3] += nx; N[k * 3 + 1] += ny; N[k * 3 + 2] += nz; } }
    if (!D.__weld) { const key = Object.create(null); D.__weld = new Int32Array(nv);
      for (let v = 0; v < nv; v++) { const k = vs[v][0].toFixed(4) + ',' + vs[v][1].toFixed(4) + ',' + vs[v][2].toFixed(4); D.__weld[v] = key[k] == null ? (key[k] = v) : key[k]; } }
    const W = D.__weld;
    for (let v = 0; v < nv; v++) if (W[v] !== v) { const r = W[v]; N[r * 3] += N[v * 3]; N[r * 3 + 1] += N[v * 3 + 1]; N[r * 3 + 2] += N[v * 3 + 2]; }
    const faces = new Array(T.length);
    for (let f = 0; f < T.length; f++) { const t = T[f];
      faces[f] = { v: t.map((k) => V(P[k * 3], P[k * 3 + 1], P[k * 3 + 2])), c: D.fcol[f],
                   n: t.map((k) => { const r = W[k]; return V(N[r * 3], N[r * 3 + 1], N[r * 3 + 2]); }) }; }
    return faces;
  }

  return { buildElephant, buildTreeFrog, buildBird, buildFish, buildOctopus, buildSkinned, place, render, montage, VIEWS, prims: { ellipsoid, tube, sheet, earFan, bez } };
});