traveler.ts
tools/art/traveler.ts
1/** Original Darkwind traveler. Metres, +Y up, +Z forward; feet at ground zero.
2 * Authored surfaces and a shared fourteen-joint rig, grouped by material.
3 * This produces source GLB; the normal compiler remains the admission boundary.
4 */
5type V = [number, number, number];
6type Joint = { name: string; parent: number; at: V };
7const rig: Joint[] = [
8 { name: 'Root', parent: -1, at: [0, 0, 0] },
9 { name: 'Pelvis', parent: 0, at: [0, .83, 0] },
10 { name: 'Chest', parent: 1, at: [0, 1.11, 0] },
11 { name: 'Head', parent: 2, at: [0, 1.43, 0] },
12 { name: 'Left shoulder', parent: 2, at: [.235, 1.29, 0] },
13 { name: 'Left elbow', parent: 4, at: [.30, 1.045, .015] },
14 { name: 'Left hand', parent: 5, at: [.33, .84, .025] },
15 { name: 'Right shoulder', parent: 2, at: [-.235, 1.29, 0] },
16 { name: 'Right elbow', parent: 7, at: [-.30, 1.045, .015] },
17 { name: 'Right hand', parent: 8, at: [-.33, .84, .025] },
18 { name: 'Left hip', parent: 1, at: [.115, .84, 0] },
19 { name: 'Left knee', parent: 10, at: [.115, .46, 0] },
20 { name: 'Right hip', parent: 1, at: [-.115, .84, 0] },
21 { name: 'Right knee', parent: 12, at: [-.115, .46, 0] },
22];
23type Influence = number | ((p: V) => [number, number, number]);
24export function travelerGlb(options: { lantern?: boolean; relaxedMotion?: boolean } = {}): Buffer {
25 const materials: object[] = [], groups: { p: number[]; n: number[]; uv: number[]; j: number[]; w: number[]; i: number[] }[] = [];
26 function material(name: string, color: V, roughness = .85, metallic = 0, emissive?: V) {
27 const id = materials.length;
28 materials.push({ name, pbrMetallicRoughness: { baseColorFactor: [...color, 1], roughnessFactor: roughness, metallicFactor: metallic }, ...(emissive ? { emissiveFactor: emissive } : {}) });
29 groups.push({ p: [], n: [], uv: [], j: [], w: [], i: [] }); return id;
30 }
31 const coat = material('Deep teal wool', [.045, .14, .13]), lining = material('Sage folded wool', [.12, .24, .19]);
32 const scarf = material('Saffron woven scarf', [.62, .32, .085]), leather = material('Chestnut leather', [.16, .071, .035], .65);
33 const trousers = material('Charcoal linen', [.065, .081, .076]), skin = material('Warm terracotta skin', [.62, .34, .255], .8);
34 const hair = material('Auburn swept hair', [.105, .032, .021], .7), brass = material('Brushed brass', [.57, .34, .10], .36, .75);
35 const cream = material('Warm ivory', [.82, .75, .59]), ink = material('Dark eyes and stitching', [.012, .02, .019], .6);
36 const lips = material('Rosewood lips and ear folds', [.40, .13, .105], .8);
37 const glow = material('Amber lantern glass', [.65, .21, .025], .4, 0, [1, .42, .08]);
38 function vertex(mat: number, p: V, n: V, uv: [number, number], influence: Influence) {
39 const g = groups[mat], id = g.p.length / 3, [a, b, mix] = typeof influence === 'number' ? [influence, 0, 0] : influence(p);
40 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;
41 }
42 // Parametric ellipsoids: smooth normals with an optional tilt about Z.
43 function oval(mat: number, center: V, radius: V, joint: Influence, tilt = 0, rings = 10, segments = 16) {
44 const g = groups[mat], base = g.p.length / 3, c = Math.cos(tilt), s = Math.sin(tilt);
45 for (let y = 0; y <= rings; y++) for (let x = 0; x <= segments; x++) {
46 const u = x / segments, v = y / rings, phi = v * Math.PI, theta = u * Math.PI * 2;
47 const q: V = [Math.sin(phi) * Math.cos(theta), Math.cos(phi), Math.sin(phi) * Math.sin(theta)];
48 const a = q[0] * radius[0], b = q[1] * radius[1];
49 const raw = q.map((n, i) => n / radius[i]), length = Math.hypot(...raw);
50 const normal: V = [(raw[0] * c - raw[1] * s) / length, (raw[0] * s + raw[1] * c) / length, raw[2] / length];
51 vertex(mat, [center[0] + a * c - b * s, center[1] + a * s + b * c, center[2] + q[2] * radius[2]], normal, [u, v], joint);
52 }
53 for (let y = 0; y < rings; y++) for (let x = 0; x < segments; x++) {
54 const a = base + y * (segments + 1) + x, b = a + segments + 1;
55 if (y > 0) g.i.push(a, a + 1, b);
56 if (y < rings - 1) g.i.push(a + 1, b + 1, b);
57 }
58 }
59 // Tailored closed ring surfaces, with their true slope in the normals.
60 function tailored(mat: number, levels: [number, number, number][], joint: Influence, z = 0, segments = 20) {
61 const g = groups[mat], base = g.p.length / 3;
62 for (let y = 0; y < levels.length; y++) for (let x = 0; x <= segments; x++) {
63 const [height, rx, rz] = levels[y], prev = levels[Math.max(0, y - 1)], next = levels[Math.min(levels.length - 1, y + 1)];
64 const angle = x / segments * Math.PI * 2, c = Math.cos(angle), s = Math.sin(angle), dy = next[0] - prev[0];
65 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);
66 vertex(mat, [c * rx, height, z + s * rz], raw.map(n => n / length) as V, [x / segments, y / (levels.length - 1)], joint);
67 }
68 for (let y = 0; y < levels.length - 1; y++) for (let x = 0; x < segments; x++) {
69 const a = base + y * (segments + 1) + x, b = a + segments + 1; g.i.push(a, b, a + 1, a + 1, b, b + 1);
70 }
71 for (const end of [0, levels.length - 1]) {
72 const [height, rx, rz] = levels[end], normal: V = [0, end === 0 ? -1 : 1, 0];
73 const middle = vertex(mat, [0, height, z], normal, [.5, .5], joint), start = g.p.length / 3;
74 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); }
75 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);
76 }
77 }
78 // Smooth authored sections keep cheek/jaw, sleeve and cloth contours distinct.
79 // Each section is [x, y, z, half-width, half-depth]. Cubic interpolation shares
80 // surface normals across rows instead of leaving separate intersecting ellipsoids.
81 type Section = [number, number, number, number, number];
82 const section = (rows: Section[], t: number): Section => {
83 const f = Math.min(t, 1) * (rows.length - 1), i = Math.min(Math.floor(f), rows.length - 2), u = f - i;
84 return rows[i].map((_, a) => {
85 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];
86 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);
87 }) as Section;
88 };
89 function surface(mat: number, sample: (u: number, v: number) => V, joint: Influence, rows: number, segments: number, closed = true, reverse = false) {
90 const g = groups[mat], base = g.p.length / 3, eps = .0001;
91 for (let y = 0; y <= rows; y++) for (let x = 0; x <= segments; x++) {
92 const u = x / segments, v = y / rows;
93 const l = sample(closed ? u - eps : Math.max(0, u - eps), v), r = sample(closed ? u + eps : Math.min(1, u + eps), v);
94 const b = sample(u, Math.max(0, v - eps)), t = sample(u, Math.min(1, v + eps));
95 const a = t.map((n, i) => n - b[i]), c = r.map((n, i) => n - l[i]);
96 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);
97 if (length < 1e-14) throw new Error('Degenerate traveler surface');
98 vertex(mat, sample(u, v), n.map(v => v / length * (reverse ? -1 : 1)) as V, [u, v], joint);
99 }
100 for (let y = 0; y < rows; y++) for (let x = 0; x < segments; x++) {
101 const a = base + y * (segments + 1) + x, b = a + segments + 1;
102 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);
103 }
104 }
105 function loft(mat: number, rows: Section[], joint: Influence, segments = 24, flattenFront = 1) {
106 surface(mat, (u, v) => {
107 const [x, y, z, rx, rz] = section(rows, v), a = u * Math.PI * 2, sine = Math.sin(a);
108 return [x + Math.cos(a) * rx, y, z + (sine > 0 ? Math.pow(sine, flattenFront) : sine) * rz];
109 }, joint, (rows.length - 1) * 4, segments);
110 for (const end of [0, rows.length - 1]) {
111 const [x, y, z, rx, rz] = rows[end], g = groups[mat], normal: V = [0, end === 0 ? -1 : 1, 0];
112 const middle = vertex(mat, [x,y,z], normal, [.5,.5], joint), start = g.p.length / 3;
113 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);}
114 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);
115 }
116 }
117 // Swept, tapered strands and fine seams. Sections turn with the path in XY.
118 function sweep(mat: number, rows: Section[], joint: Influence, segments = 10) {
119 surface(mat, (u, v) => {
120 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));
121 const dx = next[0] - prev[0], dy = next[1] - prev[1], length = Math.hypot(dx, dy), a = u * Math.PI * 2;
122 return [x + dy / length * width * Math.cos(a), y - dx / length * width * Math.cos(a), z + depth * Math.sin(a)];
123 }, joint, (rows.length - 1) * 6, segments);
124 }
125 const torso: Influence = p => p[1] < .9
126 ? [1, p[0] > 0 ? 10 : 12, Math.min(1, (.9-p[1])/.17) * Math.min(1, Math.abs(p[0])/.12) * .7]
127 : [1, 2, Math.max(0, Math.min(1, (p[1] - .88) / .29))];
128 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]];
129 loft(coat, coatSections, torso);
130 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);
131 tailored(leather, [[.94, .168, .121], [.978, .166, .121]], torso);
132 oval(brass, [.015, .958, .139], [.028, .022, .008], torso, 0, 6, 12);
133 oval(leather, [.015, .958, .147], [.017, .013, .003], torso, 0, 6, 12);
134 for (const y of [1.075, 1.155, 1.235]) oval(brass, [.025, y, .14], [.009, .009, .006], torso, 0, 6, 10);
135 // Front placket and pocket welts follow the tailored coat.
136 sweep(lining, [[.006,.985,.126,.0025,.002],[.006,1.12,.139,.0025,.002],[.006,1.27,.115,.0025,.002]], torso, 6);
137 for (const side of [-1,1]) {
138 sweep(lining, [[side*.125,.84,.116,.003,.003],[side*.155,.875,.103,.003,.003],[side*.167,.91,.078,.003,.003]], torso, 6);
139 // Folded collar, broad near the shoulder and tapering to the lapel point.
140 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);
141 }
142 oval(skin, [0, 1.433, 0], [.047, .095, .047], 3);
143 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);
144 // A folded cloth tail with a pointed end, raised selvedge and short fringe.
145 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);
146 sweep(cream, [[-.091,1.32,.147,.002,.002],[-.12,1.20,.156,.002,.002],[-.145,1.079,.16,.002,.002]], 2, 6);
147 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);
148 for (const side of [-1, 1]) {
149 const shoulder = side === 1 ? 4 : 7, elbow = shoulder + 1, hand = shoulder + 2, hip = side === 1 ? 10 : 12, knee = hip + 1;
150 const arm: Influence = p => [shoulder, elbow, Math.max(0, Math.min(1, (1.10 - p[1]) / .10))];
151 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);
152 loft(lining, [[side*.323,.92,.021,.052,.051],[side*.321,.946,.02,.054,.053],[side*.318,.954,.02,.052,.051]], elbow, 20);
153 oval(brass,[side*.344,.937,.065],[.006,.007,.004],elbow,0,6,8);
154 // A tapered palm, separated relaxed fingers and a thumb replace the mittens.
155 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);
156 for(let finger=0;finger<4;finger++){
157 const x=side*(.307+finger*.015),end=.784+Math.abs(finger-1)*.006;
158 sweep(skin,[[x,.846,.039,.009,.011],[x+side*.003,.817,.047,.008,.009],[x+side*.002,end,.044,.004,.006]],hand,8);
159 }
160 sweep(skin,[[side*.303,.882,.031,.014,.014],[side*.286,.86,.048,.012,.012],[side*.285,.836,.055,.006,.007]],hand,10);
161 const leg: Influence = p => [hip, knee, Math.max(0, Math.min(1, (.51 - p[1]) / .10))];
162 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);
163 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);
164 oval(leather, [side * .115, .082, .063], [.081, .082, .145], knee);
165 oval(ink, [side * .115, .027, .063], [.083, .027, .146], knee);
166 loft(leather, [[side*.115,.347,0,.078,.086],[side*.115,.372,0,.079,.087]], knee, 20);
167 for (const y of [.17, .22, .27]) {
168 sweep(cream,[[side*.115-.024,y+.01,.083,.0018,.0018],[side*.115+.024,y-.01,.083,.0018,.0018]],knee,6);
169 for(const x of [-1,1]) oval(brass,[side*.115+x*.028,y,.079],[.004,.004,.003],knee,0,4,8);
170 }
171 }
172 // Shorter lower face, rounded cheeks and a continuous brow plane.
173 // The curved front keeps the eyes seated in the face instead of on a flat mask.
174 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]];
175 loft(skin,faceSections,3,48,.72);
176 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);};
177 function faceOval(mat:number,x:number,y:number,rx:number,ry:number,rz:number,depth:number){
178 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);
179 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;}
180 }
181 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]);}
182
183 for(const side of [-1,1]){
184 oval(skin,[side*.132,1.613,-.011],[.021,.035,.023],3,side*.13,10,16);
185 oval(lips,[side*.143,1.614,.009],[.006,.017,.003],3,side*.13,8,12);
186 oval(brass,[side*.14,1.586,.01],[.007,.009,.006],3,0,8,12);
187 const eyeX=side*.055,eyeY=1.644;
188 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);
189 faceOval(lining,eyeX,eyeY,.013,.014,.0015,.005);
190 faceOval(ink,eyeX,eyeY,.0065,.009,.001,.0065);
191 faceOval(cream,eyeX-.004,eyeY+.005,.0025,.0025,.0008,.0078);
192 faceOval(cream,eyeX+.0035,eyeY-.004,.001,.001,.0006,.0078);
193 // Lids follow the face curvature so the eyes sit within the cheek plane.
194 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]]);
195 faceLine(skin,[[eyeX-.03,eyeY-side*.003,.001,.001,.001],[eyeX,eyeY-.015,.001,.0016,.001],[eyeX+.03,eyeY+side*.003,.001,.001,.001]]);
196 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]]);
197 // Tiny warm freckles across the cheeks, avoiding the eye whites.
198 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);
199 }
200 // A low bridge and small rounded tip; lips follow the cheek's front contour.
201 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);
202 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);
203 sweep(lips,[[-.025,1.563,.105,.0008,.001],[0,1.556,.109,.0028,.002],[.025,1.563,.105,.0008,.001]],3,10);
204 // A continuous bob with an asymmetric hairline and broad, flattened locks.
205 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);
206 for(let i=0;i<4;i++){
207 const x=.085-i*.025;
208 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);
209 }
210 for(const side of [-1,1]){
211 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);
212 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);
213 }
214 // Cross-body satchel and a small brass lantern on the left hand.
215 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);
216 sweep(leather,[[-.22,.99,-.06,.077,.012],[-.22,.96,-.017,.085,.01],[-.22,.88,-.017,.06,.008]],1,12);
217 oval(brass, [-.217, .88, -.021], [.016, .025, .008], 1, 0, 6, 10);
218 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);
219 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);
220 if(options.lantern !== false){
221 oval(brass, [.33, .765, .035], [.062, .059, .012], 6);
222 oval(ink, [.33, .766, .041], [.041, .038, .014], 6);
223 oval(glow, [.33, .645, .035], [.063, .085, .063], 6);
224 oval(brass, [.33, .727, .035], [.08, .024, .08], 6);
225 oval(brass, [.33, .559, .035], [.077, .019, .077], 6);
226 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);
227
228 }
229
230 // Bring the face into the collar while preserving the shared joint contracts.
231 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;
232
233 const chunks: Buffer[] = [], views: object[] = [], accessors: object[] = []; let size = 0;
234 function accessor(data: Float32Array | Uint16Array | Uint32Array, type: string, width: number, extra: object = {}) {
235 const padding = (4 - size % 4) % 4; if (padding) { chunks.push(Buffer.alloc(padding)); size += padding; }
236 views.push({ buffer: 0, byteOffset: size, byteLength: data.byteLength }); chunks.push(Buffer.from(data.buffer)); size += data.byteLength;
237 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;
238 }
239 const primitives = groups.flatMap((g, material) => g.i.length ? [{ attributes: {
240 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))) }),
241 NORMAL: accessor(new Float32Array(g.n), 'VEC3', 3), TEXCOORD_0: accessor(new Float32Array(g.uv), 'VEC2', 2),
242 JOINTS_0: accessor(new Uint16Array(g.j), 'VEC4', 4), WEIGHTS_0: accessor(new Float32Array(g.w), 'VEC4', 4),
243 }, indices: accessor(new Uint32Array(g.i), 'SCALAR', 1), material }] : []);
244 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] : []) }));
245 nodes.push({ name: 'Traveler surfaces', mesh: 0, skin: 0 });
246 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);
247 function clip(name: string, duration: number, walk: boolean) {
248 const relaxed=options.relaxedMotion===true;
249 const count = 17, times = Array.from({ length: count }, (_, i) => i * duration / (count - 1));
250 const input = accessor(new Float32Array(times), 'SCALAR', 1, { min: [0], max: [duration] }), samplers: object[] = [], channels: object[] = [];
251 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' }); }
252 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)]; })); }
253 rotate(2, [0, 0, 1], t => Math.sin(t) * (walk ? .025 : relaxed ? .008 : .018));
254 rotate(3, [0, 1, 0], t => Math.sin(t) * (walk ? .035 : relaxed ? .028 : .06));
255 for (const side of [-1, 1]) {
256 const shoulder = side === 1 ? 4 : 7, hip = side === 1 ? 10 : 12;
257 rotate(shoulder, [1, 0, 0], t => (relaxed ? -.035 : 0) + Math.sin(t) * side * (walk ? relaxed ? .24 : .28 : relaxed ? .018 : .025));
258 rotate(shoulder + 1, [1, 0, 0], t => walk ? (relaxed ? -.14 : -.08) - Math.max(0, -Math.sin(t) * side) * (relaxed ? .22 : .16) : relaxed ? -.10 : -.06);
259 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); }
260 }
261 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];}));
262 return { name, channels, samplers };
263 }
264 // A held airborne pose; motor grounding selects it, rather than world drift.
265 function airborneClip(){
266 const input=accessor(new Float32Array([0,1]),'SCALAR',1,{min:[0],max:[1]}),samplers:object[]=[],channels:object[]=[];
267 for(const [node,angle] of [[2,.06],[4,-.28],[7,-.28],[5,-.32],[8,-.32],[10,-.25],[12,-.25],[11,.55],[13,.55]]){
268 const rotation=[Math.sin(angle/2),0,0,Math.cos(angle/2)],output=accessor(new Float32Array([...rotation,...rotation]),'VEC4',4);
269 channels.push({sampler:samplers.length,target:{node,path:'rotation'}});samplers.push({input,output,interpolation:'LINEAR'});
270 }
271 return {name:'Traveler airborne',samplers,channels};
272 }
273 function swimmingClip(){
274 const duration=1.6,count=25,times=Array.from({length:count},(_,i)=>duration*i/(count-1));
275 const input=accessor(new Float32Array(times),'SCALAR',1,{min:[0],max:[duration]}),samplers:object[]=[],channels:object[]=[];
276 function rotate(node:number,angles:(phase:number)=>[number,number]){
277 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];});
278 const output=accessor(new Float32Array(values),'VEC4',4);channels.push({sampler:samplers.length,target:{node,path:'rotation'}});samplers.push({input,output,interpolation:'LINEAR'});
279 }
280 // A relaxed breaststroke: chest toward the surface, face forward,
281 // a symmetric arm sweep and a small recovery kick. Feet remain below it.
282 rotate(1,t=>[.8+.035*Math.sin(t),0]);rotate(2,t=>[.05,0]);rotate(3,t=>[-.55,0]);
283 for(const side of [-1,1]){
284 const shoulder=side===1?4:7,hip=side===1?10:12;
285 rotate(shoulder,t=>[-1.15-.4*Math.cos(t),side*(.25+.5*(1-Math.cos(t)))]);
286 rotate(shoulder+1,t=>[-.55-.45*(1-Math.cos(t)),0]);
287 rotate(hip,t=>[-.15-.18*Math.sin(t),side*.08]);rotate(hip+1,t=>[.3+.35*(1-Math.sin(t)),0]);
288 }
289 return {name:'Traveler swim',samplers,channels};
290 }
291 function seatedClip(){
292 const input=accessor(new Float32Array([0,1]),'SCALAR',1,{min:[0],max:[1]}),samplers:object[]=[],channels:object[]=[];
293 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]]){
294 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'});
295 }
296 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'});
297 return {name:'Traveler seated',samplers,channels};
298 }
299 const animations = [clip('Hearth idle', 3.2, false), clip('Traveler walk', .9, true),airborneClip(),swimmingClip(),seatedClip()];
300 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 };
301 const json = Buffer.from(JSON.stringify(root)), padded = Buffer.alloc(Math.ceil(json.length / 4) * 4, 32); padded.set(json);
302 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;
303}
304