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 } };
});