/** Original Darkwind traveler. Metres, +Y up, +Z forward; feet at ground zero.
 * Authored surfaces and a shared fourteen-joint rig, grouped by material.
 * This produces source GLB; the normal compiler remains the admission boundary.
 */
type V = [number, number, number];
type Joint = { name: string; parent: number; at: V };
const rig: Joint[] = [
    { name: 'Root', parent: -1, at: [0, 0, 0] },
    { name: 'Pelvis', parent: 0, at: [0, .83, 0] },
    { name: 'Chest', parent: 1, at: [0, 1.11, 0] },
    { name: 'Head', parent: 2, at: [0, 1.43, 0] },
    { name: 'Left shoulder', parent: 2, at: [.235, 1.29, 0] },
    { name: 'Left elbow', parent: 4, at: [.30, 1.045, .015] },
    { name: 'Left hand', parent: 5, at: [.33, .84, .025] },
    { name: 'Right shoulder', parent: 2, at: [-.235, 1.29, 0] },
    { name: 'Right elbow', parent: 7, at: [-.30, 1.045, .015] },
    { name: 'Right hand', parent: 8, at: [-.33, .84, .025] },
    { name: 'Left hip', parent: 1, at: [.115, .84, 0] },
    { name: 'Left knee', parent: 10, at: [.115, .46, 0] },
    { name: 'Right hip', parent: 1, at: [-.115, .84, 0] },
    { name: 'Right knee', parent: 12, at: [-.115, .46, 0] },
];
type Influence = number | ((p: V) => [number, number, number]);
export function travelerGlb(options: { lantern?: boolean; relaxedMotion?: boolean } = {}): Buffer {
    const materials: object[] = [], groups: { p: number[]; n: number[]; uv: number[]; j: number[]; w: number[]; i: number[] }[] = [];
    function material(name: string, color: V, roughness = .85, metallic = 0, emissive?: V) {
        const id = materials.length;
        materials.push({ name, pbrMetallicRoughness: { baseColorFactor: [...color, 1], roughnessFactor: roughness, metallicFactor: metallic }, ...(emissive ? { emissiveFactor: emissive } : {}) });
        groups.push({ p: [], n: [], uv: [], j: [], w: [], i: [] }); return id;
    }
    const coat = material('Deep teal wool', [.045, .14, .13]), lining = material('Sage folded wool', [.12, .24, .19]);
    const scarf = material('Saffron woven scarf', [.62, .32, .085]), leather = material('Chestnut leather', [.16, .071, .035], .65);
    const trousers = material('Charcoal linen', [.065, .081, .076]), skin = material('Warm terracotta skin', [.62, .34, .255], .8);
    const hair = material('Auburn swept hair', [.105, .032, .021], .7), brass = material('Brushed brass', [.57, .34, .10], .36, .75);
    const cream = material('Warm ivory', [.82, .75, .59]), ink = material('Dark eyes and stitching', [.012, .02, .019], .6);
    const lips = material('Rosewood lips and ear folds', [.40, .13, .105], .8);
    const glow = material('Amber lantern glass', [.65, .21, .025], .4, 0, [1, .42, .08]);
    function vertex(mat: number, p: V, n: V, uv: [number, number], influence: Influence) {
        const g = groups[mat], id = g.p.length / 3, [a, b, mix] = typeof influence === 'number' ? [influence, 0, 0] : influence(p);
        g.p.push(...p); g.n.push(...n); g.uv.push(...uv); g.j.push(a, b, 0, 0); g.w.push(1 - mix, mix, 0, 0); return id;
    }
    // Parametric ellipsoids: smooth normals with an optional tilt about Z.
    function oval(mat: number, center: V, radius: V, joint: Influence, tilt = 0, rings = 10, segments = 16) {
        const g = groups[mat], base = g.p.length / 3, c = Math.cos(tilt), s = Math.sin(tilt);
        for (let y = 0; y <= rings; y++) for (let x = 0; x <= segments; x++) {
            const u = x / segments, v = y / rings, phi = v * Math.PI, theta = u * Math.PI * 2;
            const q: V = [Math.sin(phi) * Math.cos(theta), Math.cos(phi), Math.sin(phi) * Math.sin(theta)];
            const a = q[0] * radius[0], b = q[1] * radius[1];
            const raw = q.map((n, i) => n / radius[i]), length = Math.hypot(...raw);
            const normal: V = [(raw[0] * c - raw[1] * s) / length, (raw[0] * s + raw[1] * c) / length, raw[2] / length];
            vertex(mat, [center[0] + a * c - b * s, center[1] + a * s + b * c, center[2] + q[2] * radius[2]], normal, [u, v], joint);
        }
        for (let y = 0; y < rings; y++) for (let x = 0; x < segments; x++) {
            const a = base + y * (segments + 1) + x, b = a + segments + 1;
            if (y > 0) g.i.push(a, a + 1, b);
            if (y < rings - 1) g.i.push(a + 1, b + 1, b);
        }
    }
    // Tailored closed ring surfaces, with their true slope in the normals.
    function tailored(mat: number, levels: [number, number, number][], joint: Influence, z = 0, segments = 20) {
        const g = groups[mat], base = g.p.length / 3;
        for (let y = 0; y < levels.length; y++) for (let x = 0; x <= segments; x++) {
            const [height, rx, rz] = levels[y], prev = levels[Math.max(0, y - 1)], next = levels[Math.min(levels.length - 1, y + 1)];
            const angle = x / segments * Math.PI * 2, c = Math.cos(angle), s = Math.sin(angle), dy = next[0] - prev[0];
            const raw = [c / rx, -(c * c * (next[1] - prev[1]) / rx + s * s * (next[2] - prev[2]) / rz) / dy, s / rz], length = Math.hypot(...raw);
            vertex(mat, [c * rx, height, z + s * rz], raw.map(n => n / length) as V, [x / segments, y / (levels.length - 1)], joint);
        }
        for (let y = 0; y < levels.length - 1; y++) for (let x = 0; x < segments; x++) {
            const a = base + y * (segments + 1) + x, b = a + segments + 1; g.i.push(a, b, a + 1, a + 1, b, b + 1);
        }
        for (const end of [0, levels.length - 1]) {
            const [height, rx, rz] = levels[end], normal: V = [0, end === 0 ? -1 : 1, 0];
            const middle = vertex(mat, [0, height, z], normal, [.5, .5], joint), start = g.p.length / 3;
            for (let x = 0; x <= segments; x++) { const a = x / segments * Math.PI * 2; vertex(mat, [Math.cos(a) * rx, height, z + Math.sin(a) * rz], normal, [0, 0], joint); }
            for (let x = 0; x < segments; x++) if (end === 0) g.i.push(middle, start + x, start + x + 1); else g.i.push(middle, start + x + 1, start + x);
        }
    }
    // Smooth authored sections keep cheek/jaw, sleeve and cloth contours distinct.
    // Each section is [x, y, z, half-width, half-depth]. Cubic interpolation shares
    // surface normals across rows instead of leaving separate intersecting ellipsoids.
    type Section = [number, number, number, number, number];
    const section = (rows: Section[], t: number): Section => {
        const f = Math.min(t, 1) * (rows.length - 1), i = Math.min(Math.floor(f), rows.length - 2), u = f - i;
        return rows[i].map((_, a) => {
            const p0 = rows[Math.max(0, i - 1)][a], p1 = rows[i][a], p2 = rows[i + 1][a], p3 = rows[Math.min(rows.length - 1, i + 2)][a];
            return .5 * (2 * p1 + (-p0 + p2) * u + (2 * p0 - 5 * p1 + 4 * p2 - p3) * u * u + (-p0 + 3 * p1 - 3 * p2 + p3) * u * u * u);
        }) as Section;
    };
    function surface(mat: number, sample: (u: number, v: number) => V, joint: Influence, rows: number, segments: number, closed = true, reverse = false) {
        const g = groups[mat], base = g.p.length / 3, eps = .0001;
        for (let y = 0; y <= rows; y++) for (let x = 0; x <= segments; x++) {
            const u = x / segments, v = y / rows;
            const l = sample(closed ? u - eps : Math.max(0, u - eps), v), r = sample(closed ? u + eps : Math.min(1, u + eps), v);
            const b = sample(u, Math.max(0, v - eps)), t = sample(u, Math.min(1, v + eps));
            const a = t.map((n, i) => n - b[i]), c = r.map((n, i) => n - l[i]);
            const n = [a[1] * c[2] - a[2] * c[1], a[2] * c[0] - a[0] * c[2], a[0] * c[1] - a[1] * c[0]], length = Math.hypot(...n);
            if (length < 1e-14) throw new Error('Degenerate traveler surface');
            vertex(mat, sample(u, v), n.map(v => v / length * (reverse ? -1 : 1)) as V, [u, v], joint);
        }
        for (let y = 0; y < rows; y++) for (let x = 0; x < segments; x++) {
            const a = base + y * (segments + 1) + x, b = a + segments + 1;
            if (reverse) g.i.push(a, a + 1, b, a + 1, b + 1, b); else g.i.push(a, b, a + 1, a + 1, b, b + 1);
        }
    }
    function loft(mat: number, rows: Section[], joint: Influence, segments = 24, flattenFront = 1) {
        surface(mat, (u, v) => {
            const [x, y, z, rx, rz] = section(rows, v), a = u * Math.PI * 2, sine = Math.sin(a);
            return [x + Math.cos(a) * rx, y, z + (sine > 0 ? Math.pow(sine, flattenFront) : sine) * rz];
        }, joint, (rows.length - 1) * 4, segments);
        for (const end of [0, rows.length - 1]) {
            const [x, y, z, rx, rz] = rows[end], g = groups[mat], normal: V = [0, end === 0 ? -1 : 1, 0];
            const middle = vertex(mat, [x,y,z], normal, [.5,.5], joint), start = g.p.length / 3;
            for(let i=0;i<=segments;i++) {const a=i/segments*Math.PI*2,sine=Math.sin(a);vertex(mat,[x+rx*Math.cos(a),y,z+rz*(sine>0?Math.pow(sine,flattenFront):sine)],normal,[i/segments,0],joint);}
            for(let i=0;i<segments;i++) if(end===0)g.i.push(middle,start+i,start+i+1);else g.i.push(middle,start+i+1,start+i);
        }
    }
    // Swept, tapered strands and fine seams. Sections turn with the path in XY.
    function sweep(mat: number, rows: Section[], joint: Influence, segments = 10) {
        surface(mat, (u, v) => {
            const [x, y, z, width, depth] = section(rows, v), prev = section(rows, Math.max(0, v - .001)), next = section(rows, Math.min(1, v + .001));
            const dx = next[0] - prev[0], dy = next[1] - prev[1], length = Math.hypot(dx, dy), a = u * Math.PI * 2;
            return [x + dy / length * width * Math.cos(a), y - dx / length * width * Math.cos(a), z + depth * Math.sin(a)];
        }, joint, (rows.length - 1) * 6, segments);
    }
    const torso: Influence = p => p[1] < .9
        ? [1, p[0] > 0 ? 10 : 12, Math.min(1, (.9-p[1])/.17) * Math.min(1, Math.abs(p[0])/.12) * .7]
        : [1, 2, Math.max(0, Math.min(1, (p[1] - .88) / .29))];
    const coatSections: Section[] = [[0,.81,0,.199,.126],[0,.855,0,.197,.131],[0,.97,0,.157,.111],[0,1.13,0,.187,.126],[0,1.27,0,.223,.105],[0,1.32,0,.179,.085],[0,1.34,0,.073,.061]];
    loft(coat, coatSections, torso);
    surface(lining,(u,v)=>{const [x,y,z,rx,rz]=section(coatSections,.015+v*.065),a=u*Math.PI*2;return [x+Math.cos(a)*(rx+.0015),y,z+Math.sin(a)*(rz+.0015)];},torso,6,24);
    tailored(leather, [[.94, .168, .121], [.978, .166, .121]], torso);
    oval(brass, [.015, .958, .139], [.028, .022, .008], torso, 0, 6, 12);
    oval(leather, [.015, .958, .147], [.017, .013, .003], torso, 0, 6, 12);
    for (const y of [1.075, 1.155, 1.235]) oval(brass, [.025, y, .14], [.009, .009, .006], torso, 0, 6, 10);
    // Front placket and pocket welts follow the tailored coat.
    sweep(lining, [[.006,.985,.126,.0025,.002],[.006,1.12,.139,.0025,.002],[.006,1.27,.115,.0025,.002]], torso, 6);
    for (const side of [-1,1]) {
        sweep(lining, [[side*.125,.84,.116,.003,.003],[side*.155,.875,.103,.003,.003],[side*.167,.91,.078,.003,.003]], torso, 6);
        // Folded collar, broad near the shoulder and tapering to the lapel point.
        sweep(lining, [[side*.067,1.337,.064,.025,.012],[side*.128,1.287,.103,.035,.012],[side*.095,1.20,.128,.002,.003]], torso, 12);
    }
    oval(skin, [0, 1.433, 0], [.047, .095, .047], 3);
    loft(scarf, [[0,1.315,0,.106,.099],[0,1.34,0,.113,.103],[0,1.367,0,.09,.087],[0,1.395,0,.071,.068]], 2);
    // A folded cloth tail with a pointed end, raised selvedge and short fringe.
    sweep(scarf, [[-.063,1.354,.102,.037,.014],[-.088,1.27,.151,.039,.011],[-.105,1.13,.151,.035,.012],[-.117,1.065,.159,.031,.009]], 2, 12);
    sweep(cream, [[-.091,1.32,.147,.002,.002],[-.12,1.20,.156,.002,.002],[-.145,1.079,.16,.002,.002]], 2, 6);
    for(let i=0;i<6;i++) sweep(scarf,[[-.142+i*.01,1.075,.165,.002,.002],[-.146+i*.01,1.05+(i%2)*.005,.166,.001,.001]],2,6);
    for (const side of [-1, 1]) {
        const shoulder = side === 1 ? 4 : 7, elbow = shoulder + 1, hand = shoulder + 2, hip = side === 1 ? 10 : 12, knee = hip + 1;
        const arm: Influence = p => [shoulder, elbow, Math.max(0, Math.min(1, (1.10 - p[1]) / .10))];
        loft(coat, [[side*.321,.924,.02,.049,.048],[side*.314,.97,.018,.055,.055],[side*.301,1.045,.015,.061,.064],[side*.277,1.18,.005,.067,.071],[side*.225,1.29,0,.068,.069],[side*.21,1.315,0,.05,.053],[side*.17,1.325,0,.005,.009]], arm, 20);
        loft(lining, [[side*.323,.92,.021,.052,.051],[side*.321,.946,.02,.054,.053],[side*.318,.954,.02,.052,.051]], elbow, 20);
        oval(brass,[side*.344,.937,.065],[.006,.007,.004],elbow,0,6,8);
        // A tapered palm, separated relaxed fingers and a thumb replace the mittens.
        loft(skin,[[side*.331,.82,.034,.027,.019],[side*.331,.85,.028,.033,.025],[side*.326,.90,.021,.026,.023],[side*.322,.928,.02,.023,.023]],hand,16);
        for(let finger=0;finger<4;finger++){
            const x=side*(.307+finger*.015),end=.784+Math.abs(finger-1)*.006;
            sweep(skin,[[x,.846,.039,.009,.011],[x+side*.003,.817,.047,.008,.009],[x+side*.002,end,.044,.004,.006]],hand,8);
        }
        sweep(skin,[[side*.303,.882,.031,.014,.014],[side*.286,.86,.048,.012,.012],[side*.285,.836,.055,.006,.007]],hand,10);
        const leg: Influence = p => [hip, knee, Math.max(0, Math.min(1, (.51 - p[1]) / .10))];
        loft(trousers, [[side*.115,.32,0,.062,.066],[side*.115,.45,0,.068,.074],[side*.115,.55,0,.076,.084],[side*.115,.69,0,.082,.09],[side*.115,.83,0,.073,.084]], leg, 20);
        loft(leather, [[side*.115,.08,.025,.073,.095],[side*.115,.20,.01,.063,.075],[side*.115,.30,.005,.07,.079],[side*.115,.365,0,.076,.084]], knee, 20);
        oval(leather, [side * .115, .082, .063], [.081, .082, .145], knee);
        oval(ink, [side * .115, .027, .063], [.083, .027, .146], knee);
        loft(leather, [[side*.115,.347,0,.078,.086],[side*.115,.372,0,.079,.087]], knee, 20);
        for (const y of [.17, .22, .27]) {
            sweep(cream,[[side*.115-.024,y+.01,.083,.0018,.0018],[side*.115+.024,y-.01,.083,.0018,.0018]],knee,6);
            for(const x of [-1,1]) oval(brass,[side*.115+x*.028,y,.079],[.004,.004,.003],knee,0,4,8);
        }
    }
    // Shorter lower face, rounded cheeks and a continuous brow plane.
    // The curved front keeps the eyes seated in the face instead of on a flat mask.
    const faceSections:Section[]=[[0,1.49,.012,.018,.023],[0,1.505,.019,.052,.056],[0,1.535,.013,.087,.08],[0,1.575,.005,.117,.104],[0,1.625,-.004,.132,.116],[0,1.67,-.011,.13,.12],[0,1.72,-.018,.115,.107],[0,1.765,-.024,.08,.073],[0,1.788,-.025,.008,.012]];
    loft(skin,faceSections,3,48,.72);
    const faceZ=(x:number,y:number)=>{let lo=0,hi=1;for(let i=0;i<24;i++){const t=(lo+hi)/2;if(section(faceSections,t)[1]<y)lo=t;else hi=t;}const row=section(faceSections,(lo+hi)/2);return row[2]+row[4]*Math.pow(Math.sqrt(Math.max(0,1-(x/row[3])**2)),.72);};
    function faceOval(mat:number,x:number,y:number,rx:number,ry:number,rz:number,depth:number){
        const group=groups[mat],start=group.p.length,base=faceZ(x,y);oval(mat,[x,y,base+depth],[rx,ry,rz],3,0,10,20);
        for(let i=start;i<group.p.length;i+=3){const px=group.p[i],py=group.p[i+1],dx=(faceZ(px+.0001,py)-faceZ(px-.0001,py))/.0002,dy=(faceZ(px,py+.0001)-faceZ(px,py-.0001))/.0002;group.p[i+2]+=faceZ(px,py)-base;const n=[group.n[i]-dx*group.n[i+2],group.n[i+1]-dy*group.n[i+2],group.n[i+2]],length=Math.hypot(...n);for(let k=0;k<3;k++)group.n[i+k]=n[k]/length;}
    }
    function faceLine(mat:number,rows:Section[]){const group=groups[mat],start=group.p.length;sweep(mat,rows,3,10);for(let i=start;i<group.p.length;i+=3)group.p[i+2]+=faceZ(group.p[i],group.p[i+1]);}

    for(const side of [-1,1]){
        oval(skin,[side*.132,1.613,-.011],[.021,.035,.023],3,side*.13,10,16);
        oval(lips,[side*.143,1.614,.009],[.006,.017,.003],3,side*.13,8,12);
        oval(brass,[side*.14,1.586,.01],[.007,.009,.006],3,0,8,12);
        const eyeX=side*.055,eyeY=1.644;
        surface(cream,(u,v)=>{const a=u*Math.PI*2,r=.001+.999*v,x=Math.cos(a)*r,y=Math.sin(a)*r,px=eyeX+x*.03,py=eyeY+y*.0155*Math.pow(Math.max(0,1-x*x),.24)+side*x*.003;return [px,py,faceZ(px,py)+.0008+Math.sqrt(Math.max(0,1-r*r))*.005];},3,10,32);
        faceOval(lining,eyeX,eyeY,.013,.014,.0015,.005);
        faceOval(ink,eyeX,eyeY,.0065,.009,.001,.0065);
        faceOval(cream,eyeX-.004,eyeY+.005,.0025,.0025,.0008,.0078);
        faceOval(cream,eyeX+.0035,eyeY-.004,.001,.001,.0006,.0078);
        // Lids follow the face curvature so the eyes sit within the cheek plane.
        faceLine(hair,[[eyeX-.03,eyeY-side*.003,.001,.0006,.001],[eyeX-.016,eyeY+.014-side*.0015,.001,.0018,.001],[eyeX+.014,eyeY+.015+side*.0015,.001,.0018,.001],[eyeX+.03,eyeY+.003+side*.003,.001,.0006,.001]]);
        faceLine(skin,[[eyeX-.03,eyeY-side*.003,.001,.001,.001],[eyeX,eyeY-.015,.001,.0016,.001],[eyeX+.03,eyeY+side*.003,.001,.001,.001]]);
        faceLine(hair,[[eyeX-.03,1.68-side*.004,.001,.0006,.001],[eyeX-.008,1.687-side*.002,.001,.0028,.002],[eyeX+.017,1.684+side*.002,.001,.002,.002],[eyeX+.03,1.677+side*.004,.001,.0006,.001]]);
        // Tiny warm freckles across the cheeks, avoiding the eye whites.
        for(let i=0;i<4;i++)oval(lips,[side*(.062+i*.009),1.606+(i%2)*.004,faceZ(side*(.062+i*.009),1.606+(i%2)*.004)+.0005],[.0018,.0013,.0008],3,0,4,6);
    }
    // A low bridge and small rounded tip; lips follow the cheek's front contour.
    loft(skin,[[0,1.597,.116,.004,.003],[0,1.602,.12,.011,.008],[0,1.61,.12,.013,.012],[0,1.624,.114,.009,.009],[0,1.652,.107,.006,.004]],3,24);
    sweep(lips,[[-.026,1.566,.105,.0008,.001],[-.012,1.562,.108,.0025,.002],[0,1.564,.11,.002,.002],[.012,1.562,.108,.0025,.002],[.026,1.566,.105,.0008,.001]],3,10);
    sweep(lips,[[-.025,1.563,.105,.0008,.001],[0,1.556,.109,.0028,.002],[.025,1.563,.105,.0008,.001]],3,10);
    // A continuous bob with an asymmetric hairline and broad, flattened locks.
    surface(hair,(u,v)=>{const a=u*Math.PI*2,front=Math.max(0,Math.sin(a)),opening=Math.max(0,Math.min(1,(front-.05)/.55)),phi=.008+v*(2.66-1.42*opening*opening*(3-2*opening));const groove=1+.009*Math.sin(a*13+phi*1.4)*Math.sin(phi);return [Math.cos(a)*Math.sin(phi)*.153*groove,1.654+Math.cos(phi)*.158,-.029+Math.sin(a)*Math.sin(phi)*.155*groove];},3,28,56,true,true);
    for(let i=0;i<4;i++){
        const x=.085-i*.025;
        sweep(hair,[[x,1.78-i*.003,.049,.023,.009],[x-.025,1.755-i*.007,.104,.032,.012],[x-.070,1.711-i*.006,.12,.029,.010],[x-.105,1.67-i*.01,.095,.013,.008],[x-.115,1.622-i*.012,.055,.001,.002]],3,16);
    }
    for(const side of [-1,1]){
        sweep(hair,[[side*.116,1.72,-.009,.025,.05],[side*.144,1.657,-.016,.023,.052],[side*.144,1.572,-.025,.025,.045],[side*.12,1.512,-.018,.02,.029],[side*.10,1.501,.01,.001,.003]],3,16);
        sweep(hair,[[side*.108,1.705,-.09,.026,.026],[side*.12,1.63,-.11,.028,.026],[side*.12,1.55,-.105,.025,.024],[side*.08,1.499,-.077,.002,.003]],3,14);
    }
    // Cross-body satchel and a small brass lantern on the left hand.
    loft(leather,[[-.22,.755,-.075,.065,.035],[-.22,.77,-.075,.092,.05],[-.22,.91,-.075,.094,.052],[-.22,.98,-.075,.073,.04]],1,20,.4);
    sweep(leather,[[-.22,.99,-.06,.077,.012],[-.22,.96,-.017,.085,.01],[-.22,.88,-.017,.06,.008]],1,12);
    oval(brass, [-.217, .88, -.021], [.016, .025, .008], 1, 0, 6, 10);
    sweep(leather,[[.16,1.31,.085,.014,.004],[.10,1.24,.13,.014,.004],[-.045,1.04,.137,.014,.004],[-.16,.94,.107,.014,.004],[-.22,.90,.035,.014,.004]],torso,10);
    sweep(leather,[[.16,1.31,-.075,.014,.004],[.10,1.24,-.126,.014,.004],[-.045,1.04,-.136,.014,.004],[-.16,.94,-.107,.014,.004],[-.22,.90,-.12,.014,.004]],torso,10);
    if(options.lantern !== false){
    oval(brass, [.33, .765, .035], [.062, .059, .012], 6);
    oval(ink, [.33, .766, .041], [.041, .038, .014], 6);
    oval(glow, [.33, .645, .035], [.063, .085, .063], 6);
    oval(brass, [.33, .727, .035], [.08, .024, .08], 6);
    oval(brass, [.33, .559, .035], [.077, .019, .077], 6);
    for (const x of [-1, 1]) for (const z of [-1, 1]) oval(brass, [.33 + x * .046, .644, .035 + z * .046], [.009, .084, .009], 6, 0, 6, 8);

    }

    // Bring the face into the collar while preserving the shared joint contracts.
    for(const group of groups)for(let i=0;i<group.p.length/3;i++)if(group.j[i*4]===3)group.p[i*3+1]-=.03;

    const chunks: Buffer[] = [], views: object[] = [], accessors: object[] = []; let size = 0;
    function accessor(data: Float32Array | Uint16Array | Uint32Array, type: string, width: number, extra: object = {}) {
        const padding = (4 - size % 4) % 4; if (padding) { chunks.push(Buffer.alloc(padding)); size += padding; }
        views.push({ buffer: 0, byteOffset: size, byteLength: data.byteLength }); chunks.push(Buffer.from(data.buffer)); size += data.byteLength;
        accessors.push({ bufferView: views.length - 1, componentType: data instanceof Float32Array ? 5126 : data instanceof Uint16Array ? 5123 : 5125, count: data.length / width, type, ...extra }); return accessors.length - 1;
    }
    const primitives = groups.flatMap((g, material) => g.i.length ? [{ attributes: {
        POSITION: accessor(new Float32Array(g.p), 'VEC3', 3, { min: [0, 1, 2].map(a => Math.min(...g.p.filter((_, i) => i % 3 === a))), max: [0, 1, 2].map(a => Math.max(...g.p.filter((_, i) => i % 3 === a))) }),
        NORMAL: accessor(new Float32Array(g.n), 'VEC3', 3), TEXCOORD_0: accessor(new Float32Array(g.uv), 'VEC2', 2),
        JOINTS_0: accessor(new Uint16Array(g.j), 'VEC4', 4), WEIGHTS_0: accessor(new Float32Array(g.w), 'VEC4', 4),
    }, indices: accessor(new Uint32Array(g.i), 'SCALAR', 1), material }] : []);
    const nodes: object[] = rig.map((j, index) => ({ name: j.name, translation: j.at.map((v, a) => v - (j.parent < 0 ? 0 : rig[j.parent].at[a])), children: rig.flatMap((child, i) => child.parent === index ? [i] : []) }));
    nodes.push({ name: 'Traveler surfaces', mesh: 0, skin: 0 });
    const inverseBindMatrices = accessor(new Float32Array(rig.flatMap(j => [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, -j.at[0], -j.at[1], -j.at[2], 1])), 'MAT4', 16);
    function clip(name: string, duration: number, walk: boolean) {
        const relaxed=options.relaxedMotion===true;
        const count = 17, times = Array.from({ length: count }, (_, i) => i * duration / (count - 1));
        const input = accessor(new Float32Array(times), 'SCALAR', 1, { min: [0], max: [duration] }), samplers: object[] = [], channels: object[] = [];
        function channel(node: number, path: 'rotation' | 'translation', values: number[]) { const output = accessor(new Float32Array(values), path === 'rotation' ? 'VEC4' : 'VEC3', path === 'rotation' ? 4 : 3); channels.push({ sampler: samplers.length, target: { node, path } }); samplers.push({ input, output, interpolation: 'LINEAR' }); }
        function rotate(node: number, axis: V, angle: (t: number) => number) { channel(node, 'rotation', times.flatMap((_, i) => { const a = angle(i === count - 1 ? 0 : i / (count - 1) * Math.PI * 2) / 2; return [...axis.map(v => v * Math.sin(a)), Math.cos(a)]; })); }
        rotate(2, [0, 0, 1], t => Math.sin(t) * (walk ? .025 : relaxed ? .008 : .018));
        rotate(3, [0, 1, 0], t => Math.sin(t) * (walk ? .035 : relaxed ? .028 : .06));
        for (const side of [-1, 1]) {
            const shoulder = side === 1 ? 4 : 7, hip = side === 1 ? 10 : 12;
            rotate(shoulder, [1, 0, 0], t => (relaxed ? -.035 : 0) + Math.sin(t) * side * (walk ? relaxed ? .24 : .28 : relaxed ? .018 : .025));
            rotate(shoulder + 1, [1, 0, 0], t => walk ? (relaxed ? -.14 : -.08) - Math.max(0, -Math.sin(t) * side) * (relaxed ? .22 : .16) : relaxed ? -.10 : -.06);
            if (walk) { rotate(hip, [1, 0, 0], t => -Math.sin(t) * side * .42); rotate(hip + 1, [1, 0, 0], t => Math.max(0, Math.sin(t) * side) * .58); }
        }
        if (!walk || relaxed) channel(1, 'translation', times.flatMap((_, i) => {const phase=i === count - 1 ? 0 : i / (count - 1) * Math.PI * 2;return [0,.83+(relaxed ? walk ? -.022*Math.sin(phase)**2 : 0 : Math.sin(phase)*.005),0];}));
        return { name, channels, samplers };
    }
    // A held airborne pose; motor grounding selects it, rather than world drift.
    function airborneClip(){
        const input=accessor(new Float32Array([0,1]),'SCALAR',1,{min:[0],max:[1]}),samplers:object[]=[],channels:object[]=[];
        for(const [node,angle] of [[2,.06],[4,-.28],[7,-.28],[5,-.32],[8,-.32],[10,-.25],[12,-.25],[11,.55],[13,.55]]){
            const rotation=[Math.sin(angle/2),0,0,Math.cos(angle/2)],output=accessor(new Float32Array([...rotation,...rotation]),'VEC4',4);
            channels.push({sampler:samplers.length,target:{node,path:'rotation'}});samplers.push({input,output,interpolation:'LINEAR'});
        }
        return {name:'Traveler airborne',samplers,channels};
    }
    function swimmingClip(){
        const duration=1.6,count=25,times=Array.from({length:count},(_,i)=>duration*i/(count-1));
        const input=accessor(new Float32Array(times),'SCALAR',1,{min:[0],max:[duration]}),samplers:object[]=[],channels:object[]=[];
        function rotate(node:number,angles:(phase:number)=>[number,number]){
            const values=times.flatMap((_,i)=>{const [x,z]=angles(i===count-1?0:i/(count-1)*Math.PI*2),sx=Math.sin(x/2),cx=Math.cos(x/2),sz=Math.sin(z/2),cz=Math.cos(z/2);return [sx*cz,-sx*sz,cx*sz,cx*cz];});
            const output=accessor(new Float32Array(values),'VEC4',4);channels.push({sampler:samplers.length,target:{node,path:'rotation'}});samplers.push({input,output,interpolation:'LINEAR'});
        }
        // A relaxed breaststroke: chest toward the surface, face forward,
        // a symmetric arm sweep and a small recovery kick. Feet remain below it.
        rotate(1,t=>[.8+.035*Math.sin(t),0]);rotate(2,t=>[.05,0]);rotate(3,t=>[-.55,0]);
        for(const side of [-1,1]){
            const shoulder=side===1?4:7,hip=side===1?10:12;
            rotate(shoulder,t=>[-1.15-.4*Math.cos(t),side*(.25+.5*(1-Math.cos(t)))]);
            rotate(shoulder+1,t=>[-.55-.45*(1-Math.cos(t)),0]);
            rotate(hip,t=>[-.15-.18*Math.sin(t),side*.08]);rotate(hip+1,t=>[.3+.35*(1-Math.sin(t)),0]);
        }
        return {name:'Traveler swim',samplers,channels};
    }
    function seatedClip(){
        const input=accessor(new Float32Array([0,1]),'SCALAR',1,{min:[0],max:[1]}),samplers:object[]=[],channels:object[]=[];
        for(const [node,angle] of [[2,.04],[4,-.55],[7,-.55],[5,-.8],[8,-.8],[10,-1.5],[12,-1.5],[11,1.5],[13,1.5]]){
            const roll=options.relaxedMotion&&(node===4||node===7)?(node===4?-.38:.38):0,sx=Math.sin(angle/2),cx=Math.cos(angle/2),sz=Math.sin(roll/2),cz=Math.cos(roll/2),rotation=[sx*cz,-sx*sz,cx*sz,cx*cz],output=accessor(new Float32Array([...rotation,...rotation]),'VEC4',4);channels.push({sampler:samplers.length,target:{node,path:'rotation'}});samplers.push({input,output,interpolation:'LINEAR'});
        }
        const output=accessor(new Float32Array([0,options.relaxedMotion ? .49 : .45,0,0,options.relaxedMotion ? .49 : .45,0]),'VEC3',3);channels.push({sampler:samplers.length,target:{node:1,path:'translation'}});samplers.push({input,output,interpolation:'LINEAR'});
        return {name:'Traveler seated',samplers,channels};
    }
    const animations = [clip('Hearth idle', 3.2, false), clip('Traveler walk', .9, true),airborneClip(),swimmingClip(),seatedClip()];
    const bin = Buffer.concat(chunks), root = { asset: { version: '2.0', generator: 'Darkwind original traveler authoring v3' }, scene: 0, scenes: [{ nodes: [0, rig.length] }], nodes, meshes: [{ name: 'Traveler', primitives }], materials, skins: [{ name: 'Traveler rig', skeleton: 0, joints: rig.map((_, i) => i), inverseBindMatrices }], animations, buffers: [{ byteLength: bin.length }], bufferViews: views, accessors };
    const json = Buffer.from(JSON.stringify(root)), padded = Buffer.alloc(Math.ceil(json.length / 4) * 4, 32); padded.set(json);
    const result = Buffer.alloc(28 + padded.length + bin.length); result.writeUInt32LE(0x46546c67); result.writeUInt32LE(2, 4); result.writeUInt32LE(result.length, 8); result.writeUInt32LE(padded.length, 12); result.writeUInt32LE(0x4e4f534a, 16); result.set(padded, 20); result.writeUInt32LE(bin.length, 20 + padded.length); result.writeUInt32LE(0x004e4942, 24 + padded.length); result.set(bin, 28 + padded.length); return result;
}
