返回 CodeWhale
rig.js
1 // The mark is the rig. Profile contours are partitioned from the supplied SVG,
2 // retaining its cubic anchors. Every pose retains each part's command topology.
3 (function (root) {
4 'use strict';
5 const TAU = Math.PI * 2, D2R = Math.PI / 180;
6 const lerp = (a,b,t) => a+(b-a)*t;
7 const clamp = (v,a,b) => Math.max(a,Math.min(b,v));
8 const smooth = (a,b,v) => { const t=clamp((v-a)/(b-a),0,1); return t*t*(3-2*t); };
9 const POSE = {x:0,y:0,rot:0,scale:1,squash:0,tilt:0,yaw:0,curl:0,arch:0,head:0,
10 fluke:6,flukeSpread:1,fin:0,finFar:0,lookX:0,lookY:0,lid:0,lidLow:0,eyeScale:1,
11 brow:0,browTilt:0,mouth:0,smile:.4,mouthSide:0};
12 const DIRECTIONS = {
13 mark:{key:'mark',name:'01 / Emblem',uncurl:0,frontWidth:1},
14 cruise:{key:'cruise',name:'02 / Cruise',uncurl:.56,frontWidth:1.08},
15 open:{key:'open',name:'03 / Open C',uncurl:.25,frontWidth:.94}
16 };
17 DIRECTIONS.calf=DIRECTIONS.mark; // Compatibility with gallery.html#dir=calf.
18 const [outer,pouch,eye,pleat1,pleat2]=root.WhaleMark.contours;
19 const copy=x=>JSON.parse(JSON.stringify(x));
20 const end=c=>c.slice(-2);
21 // The pointed blade below the white wedge is the flipper. The rounded lobe
22 // above that wedge is the body, and must never rotate off with the arm.
23 const seam=['C',250,405,273,445,262.32,471.04];
24 // The far-side blade stays tucked in the mark. The near blade lifts from it.
25 // Keeping the body beneath that joint also keeps the jaw band continuous.
26 const body=[...copy(outer.slice(0,125)),['C',215,92,218,59,215.77,40.96],['Z']];
27 const flipper=[['M',228.68,388.82],...copy(outer.slice(48,54)),['C',273,445,250,405,228.68,388.82],['Z']];
28 const flukes=[['M',203.32,110.65],...copy(outer.slice(125,-1)),['Z']];
29 const indexAt=(path,x,y)=>path.findIndex(c=>c.at(-2)===x&&c.at(-1)===y);
30 const ji=indexAt(outer,141.12,447.47), je=indexAt(outer,496,236.55), pe=indexAt(pouch,238.49,455.19);
31 const jaw=[['M',141.12,447.47],...copy(outer.slice(ji+1,je+1)),['C',497,249,486,265,483.42,270.06],
32 ...copy(pouch.slice(3,pe+1)),['C',209,463,169,458,141.12,447.47],['Z']];
33 const parse=d=>{const t=d.match(/[MCZ]|-?\d*\.?\d+/g),p=[];for(let i=0;i<t.length;){const op=t[i++],n={M:2,C:6,Z:0}[op];p.push([op,...t.slice(i,i+n).map(Number)]);i+=n;}return p;};
34 const frontBody=parse('M244 70 C147 65 78 114 76 228 C75 279 76 303 94 342 C112 370 127 380 151 378 C172 450 250 475 286 465 C384 453 436 399 442 341 C442 320 440 310 431 310 C353 269 270 274 215 288 C176 300 138 314 121 294 C101 269 112 222 127 192 C153 145 203 127 244 128 C244 108 244 87 244 70 Z');
35 const frontPouch=parse('M416 353 C405 398 362 431 288 443 C234 448 184 410 155 352 C204 368 241 374 281 374 C328 374 375 365 416 353 Z');
36 const frontFlukes=parse('M244 128 C270 130 274 154 302 151 C324 149 343 126 346 108 C321 107 314 94 302 100 C279 95 263 97 250 96 C265 86 282 67 285 52 C267 49 249 64 244 70 C244 96 244 116 244 128 Z');
37 const frontFin=parse('M165 360 C147 376 139 397 124 404 C147 408 163 402 180 390 C183 378 174 367 165 360 Z');
38 const frontJaw=parse('M155 352 C184 410 234 448 288 443 C362 431 405 398 416 353 C424 354 435 350 442 341 C436 399 384 453 286 465 C230 470 180 435 151 378 C150 368 152 359 155 352 Z');
39 const frontPleat1=parse('M330 384 C329 405 315 424 294 434 C314 430 336 405 330 384 Z');
40 const frontPleat2=parse('M247 386 C247 404 261 423 277 433 C257 426 242 403 247 386 Z');
41 function ellipse(x,y,rx,ry){const k=.55228475;return [['M',x+rx,y],['C',x+rx,y+ry*k,x+rx*k,y+ry,x,y+ry],['C',x-rx*k,y+ry,x-rx,y+ry*k,x-rx,y],['C',x-rx,y-ry*k,x-rx*k,y-ry,x,y-ry],['C',x+rx*k,y-ry,x+rx,y-ry*k,x+rx,y],['Z']];}
42 // Resample only the authored front target to match the exact source topology.
43 // Shared Catmull tangents make the target C1-continuous at every anchor.
44 function points(path){let p=[],last;for(const c of path){if(c[0]==='M'){last=c.slice(1);p.push(last);}else if(c[0]==='C'){let a=last;for(let j=1;j<=10;j++){let t=j/10,q=1-t;p.push([q*q*q*a[0]+3*q*q*t*c[1]+3*q*t*t*c[3]+t*t*t*c[5],q*q*q*a[1]+3*q*q*t*c[2]+3*q*t*t*c[4]+t*t*t*c[6]]);}last=c.slice(-2);}}return p;}
45 function measure(path){let start=null,last=null,length=0,knots=[],samples=[];
46 for(const c of path){if(c[0]==='M'){start=last=c.slice(1);samples.push({l:0,p:last,d:[1,0]});}
47 else if(c[0]==='C'){const a=last,l0=length;let prev=a;
48 for(let j=1;j<=30;j++){const t=j/30,q=1-t;
49 const p=[q*q*q*a[0]+3*q*q*t*c[1]+3*q*t*t*c[3]+t*t*t*c[5],q*q*q*a[1]+3*q*q*t*c[2]+3*q*t*t*c[4]+t*t*t*c[6]];
50 const d=[3*q*q*(c[1]-a[0])+6*q*t*(c[3]-c[1])+3*t*t*(c[5]-c[3]),3*q*q*(c[2]-a[1])+6*q*t*(c[4]-c[2])+3*t*t*(c[6]-c[4])];
51 length+=Math.hypot(p[0]-prev[0],p[1]-prev[1]);samples.push({l:length,p,d});prev=p;
52 }
53 const seg=length-l0;knots.push({l0,l1:length,h0:Math.hypot(c[1]-a[0],c[2]-a[1])/(seg||1),h1:Math.hypot(c[3]-c[5],c[4]-c[6])/(seg||1)});last=c.slice(-2);
54 }}samples[0].d=samples[1].d;return {length,knots,samples};}
55 function match(source,target){const src=measure(source),dst=measure(target);
56 const at=u=>{const l=clamp(u,0,1)*dst.length;let i=1;while(i<dst.samples.length-1&&dst.samples[i].l<l)i++;
57 const a=dst.samples[i-1],b=dst.samples[i],t=(l-a.l)/(b.l-a.l||1),d=[lerp(a.d[0],b.d[0],t),lerp(a.d[1],b.d[1],t)],n=Math.hypot(...d)||1;
58 return {p:[lerp(a.p[0],b.p[0],t),lerp(a.p[1],b.p[1],t)],d:[d[0]/n,d[1]/n]};};
59 const out=[['M',...at(0).p]];
60 for(const k of src.knots){const a=at(k.l0/src.length),b=at(k.l1/src.length),span=(k.l1-k.l0)/src.length*dst.length,h0=span*Math.min(.6,k.h0),h1=span*Math.min(.6,k.h1);
61 out.push(['C',a.p[0]+a.d[0]*h0,a.p[1]+a.d[1]*h0,b.p[0]-b.d[0]*h1,b.p[1]-b.d[1]*h1,...b.p]);}
62 out.push(['Z']);return out;}
63
64 const SRC={body,pouch,'pleat-1':pleat1,'pleat-2':pleat2,'jaw-band':jaw,eye,eyelid:ellipse(294,337,18,15),flipper,flukes,'eye-far':copy(eye)};
65 const TARGET={body:frontBody,pouch:frontPouch,'pleat-1':frontPleat1,'pleat-2':frontPleat2,'jaw-band':frontJaw,
66 eye:ellipse(200,330,13,8),eyelid:ellipse(200,330,18,15),flipper:frontFin,flukes:frontFlukes,'eye-far':ellipse(365,330,13,8)};
67 for(const path of Object.values(SRC)){const first=path[0].slice(1),last=path.at(-2).slice(-2);if(Math.hypot(first[0]-last[0],first[1]-last[1])>.01)path.splice(-1,0,['C',lerp(last[0],first[0],1/3),lerp(last[1],first[1],1/3),lerp(last[0],first[0],2/3),lerp(last[1],first[1],2/3),...first]);}
68 const signedArea=path=>{const p=points(path);return p.reduce((sum,a,i)=>{const b=p[(i+1)%p.length];return sum+a[0]*b[1]-b[0]*a[1];},0)/2;};
69 function orientLike(source,path){if(signedArea(source)*signedArea(path)>=0)return path;
70 const segments=[];let at=path[0].slice(1),start=at;
71 for(const c of path)if(c[0]==='C'){segments.push({at,c});at=c.slice(-2);}
72 if(Math.hypot(at[0]-start[0],at[1]-start[1])>.01)segments.push({at,c:['C',...at,...start,...start]});
73 return [['M',...start],...segments.reverse().map(({at,c})=>['C',c[3],c[4],c[1],c[2],...at]),['Z']];
74 }
75 const FRONT=Object.fromEntries(Object.keys(SRC).map(k=>[k,match(SRC[k],orientLike(SRC[k],TARGET[k]))]));
76 // Match anatomical landmarks, not percentages around the entire perimeter.
77 // Otherwise the short snout turns into a corner and the belly migrates upward.
78 function pieces(source,stops,targets){let begin=0,result=[];
79 for(let i=0;i<stops.length;i++){
80 const last=stops[i],section=begin===0?source.slice(0,last+1):[['M',...end(source[begin])],...source.slice(begin+1,last+1)];
81 const fitted=match(section,parse(targets[i]));result.push(...fitted.slice(i?1:0,-1));begin=last;
82 }return [...result,['Z']];}
83 FRONT.body=pieces(SRC.body,[47,53,80,124,125],[
84 'M244 70 C147 65 78 114 76 228 C75 279 98 325 128 335 C142 347 144 363 151 378',
85 'M151 378 C164 410 187 434 210 449 C231 462 261 468 286 465',
86 'M286 465 C384 453 436 399 442 341 C442 320 440 310 431 310',
87 'M431 310 C353 269 270 274 215 288 C176 300 138 314 121 294 C101 269 112 222 127 192 C153 145 203 127 244 128',
88 'M244 128 C244 108 244 87 244 70'
89 ]);
90 // The founder rejected the frontal grin. Keep the original tapered throat
91 // and parallel pleats: this is whale anatomy, not a human smiling mouth.
92 for(const id of ['pouch','pleat-1','pleat-2'])FRONT[id]=SRC[id].map(c=>[c[0],...c.slice(1).map((v,i)=>i%2?355+(v-355)*.65:300+(v-300)*.9)]);
93 FRONT.flipper=pieces(SRC.flipper,[4,7],[
94 'M165 360 C147 376 139 397 124 404',
95 'M124 404 C147 408 163 402 180 390 C183 378 174 367 165 360'
96 ]);
97 const tailOpen=match([...SRC.flukes.slice(0,-2),['Z']],[...TARGET.flukes.slice(0,-2),['Z']]);
98 FRONT.flukes=[...tailOpen.slice(0,-1),...TARGET.flukes.slice(-2)];
99 // Overlap only the internal closing seam; leave the visible mark contour exact.
100 for(const path of [SRC.flukes,FRONT.flukes]){path.at(-2)[1]-=2;path.at(-2)[3]-=2;}
101 // Authored on a 24-cell grid, then optical-fit independently for 16/24/32.
102 // These deliberately widen the throat and flukes; they are not hero downsamples.
103 const SMALL_GRID={
104 body:parse('M10.5 3.5 C6 3.5 3 7 3 12 C3 16.5 5 18.5 8 18.5 C9.5 18.5 10.5 18 11 17 C10.5 18.5 9 19.5 8 19.5 C9 20.5 11 20.5 12.5 20 C17 20 21 16 21.5 11.5 C22 8.5 15 10.5 12 12 C6.5 15 5.5 13 6 10 C6 8 8 6.5 10 6.5 C10.5 5.5 10.5 4.5 10.5 3.5 Z'),
105 flukes:parse('M10 6.5 C11.5 6.5 11.5 8 13.5 8 C15 8 16.5 7 17 6.5 C14 6 13 5 12 6 C13 5.5 14 4.5 14.5 4 C13 3.5 12 3.5 10.5 3.5 C10.5 4.5 10.5 5.5 10 6.5 Z'),
106 pouch:parse('M20.5 12.5 C20 17 16 20 11.5 19.5 C15 18.5 15.5 13 20.5 12.5 Z'),
107 flipper:parse('M11 17 C10.5 18.5 9 19.5 8 19.5 C9 20.5 11 20.5 12.5 20 C13 19.5 12 18 11 17 Z'),
108 'jaw-band':parse('M11 20 C17 20 21 16 22 12 C21 17 17 21 11 21 C10 21 10 20 11 20 Z'),
109 eye:ellipse(14,15,0.65,.46),'eye-far':ellipse(14,15,.65,.46),eyelid:ellipse(14,15,1,.7)
110 };
111 const SMALL_CACHE={};
112 function smallSource(size){if(SMALL_CACHE[size])return SMALL_CACHE[size];const out={};
113 for(const id of Object.keys(SRC)){const path=SMALL_GRID[id];if(!path){out[id]=SRC[id];continue;}
114 const fit=path.map(c=>[c[0],...c.slice(1).map(v=>{
115 const cell=Math.round(v/24*size*2)/2;
116 return (cell/size-.5)*634.88+256;
117 })]);out[id]=match(SRC[id],orientLike(SRC[id],fit));}
118 return SMALL_CACHE[size]=out;
119 }
120 const pathString=path=>path.map(c=>c[0]+c.slice(1).map(n=>+n.toFixed(3)).join(' ')).join(' ');
121 function rotate(x,y,cx,cy,a){const c=Math.cos(a),s=Math.sin(a);return [cx+(x-cx)*c-(y-cy)*s,cy+(x-cx)*s+(y-cy)*c];}
122 // Morph a blade in its shoulder-to-tip frame. A straight Cartesian blend
123 // collapses a down-pointing fin when its target points up.
124 function morphBlade(a,b,t){
125 const aa=a[0].slice(1),bb=b[0].slice(1),at=a[4].slice(-2),bt=b[4].slice(-2);
126 let angle=Math.atan2(bt[1]-bb[1],bt[0]-bb[0])-Math.atan2(at[1]-aa[1],at[0]-aa[0]);
127 angle=Math.atan2(Math.sin(angle),Math.cos(angle));
128 return a.map((c,i)=>{const out=[c[0]];for(let j=1;j<c.length;j+=2){
129 const target=rotate(b[i][j]-bb[0],b[i][j+1]-bb[1],0,0,-angle);
130 const p=rotate(lerp(c[j]-aa[0],target[0],t),lerp(c[j+1]-aa[1],target[1],t),0,0,angle*t);
131 out.push(p[0]+lerp(aa[0],bb[0],t),p[1]+lerp(aa[1],bb[1],t));
132 }return out;});
133 }
134 function build(direction,pose={},lod=0,opt={}){
135 const dir=typeof direction==='string'?DIRECTIONS[direction]:direction||DIRECTIONS.mark,p={...POSE,...pose};
136 // Yaw now requests a modest attentive turn. A full frontal face made the
137 // mark look like a mask; the visible profile and single eye stay intact.
138 const yaw=clamp(p.yaw,0,1), headYaw=smooth(0,.82,yaw)*.18,tailYaw=smooth(.24,1,yaw)*.12;
139 const uncurl=dir.uncurl*(1-clamp(p.curl,0,1))+Math.max(0,-p.curl)*.9-Math.max(0,p.curl)*.08;
140 const small=lod>=2, size=opt.size||24,dpr=opt.dpr||1;
141 const toolWeight=clamp((p.page||0)+(p.pencil||0)+(p.lens||0)+(p.glass||0),0,1);
142 const targets=[['page',392,222,132],['pencil',394,218,118],['lens',380,240,168],['glass',401,255,122]];
143 let tx=335*(1-toolWeight),ty=386*(1-toolWeight),total=0;
144 for(const [key,x,y,neutral] of targets){const weight=p[key]||0;total+=weight;tx+=(x+(p.fin-neutral)*.38)*weight;ty+=(y+(p.fin-neutral)*.18)*weight;}
145 if(total>1){tx/=total;ty/=total;}
146 const lift=smooth(30,98,p.fin),attention=smooth(.65,1,yaw)*(1-toolWeight);
147 // A lifted blade has its own two continuous edges. Stretching each source
148 // point by distance from the joint made the edges cross into thin spikes.
149 const heldFin=pieces(SRC.flipper,[4,7],[
150 `M230 398 C238 408 ${lerp(252,250,toolWeight)} ${lerp(427,425,toolWeight)} ${lerp(262,255,toolWeight)} ${lerp(438,435,toolWeight)} C${lerp(290,240,toolWeight)} ${lerp(416,350,toolWeight)} ${tx-lerp(10,35,toolWeight)} ${ty+lerp(18,5,toolWeight)} ${tx} ${ty}`,
151 `M${tx} ${ty} C${tx-66} ${ty+20} 230 357 230 398`
152 ]);
153 const profileFin=morphBlade(small?smallSource(size).flipper:SRC.flipper,heldFin,lift);
154 const posedFin=morphBlade(profileFin,FRONT.flipper,headYaw);
155 const transform=(id,x,y)=>{
156 const w=(1-smooth(165,295,y))*smooth(315,135,x);
157 x-=uncurl*48*w;y+=uncurl*60*w;
158 // Head leads, tail follows. The mark's tail roots stay attached.
159 if(id==='flukes'){const hinge=lerp(220,246,tailYaw),weight=smooth(hinge,hinge+75,x);[x,y]=rotate(x,y,hinge,110,(p.fluke-6)*.48*D2R*weight);}
160 if(y>285){const jawWeight=smooth(320,470,y);y+=p.mouth*22*jawWeight;
161 if(id==='pouch'){y+=(p.smile-.4)*7*Math.sin((x-230)/260*Math.PI);y+=p.mouthSide*10*smooth(280,480,x)*(1-smooth(400,460,y));}}
162 const headWeight=smooth(185,470,x)*smooth(150,300,y);
163 [x,y]=rotate(x,y,290,305,-p.head*.34*D2R*headWeight);
164 x=(x-256)/5.12;y=(y-256)/5.12;
165 x*=p.scale*(1+p.squash*.5);y*=p.scale*(1-p.squash);
166 [x,y]=rotate(x,y,0,0,(p.rot+p.tilt*.28)*D2R);
167 if(small){x*=.9;y*=.9;}
168 return [x+p.x,y+p.y];
169 };
170 const shapes=[];
171 for(const id of ['flukes','body','jaw-band','pouch','pleat-1','pleat-2','flipper','eye','eye-far','eyelid']){
172 const a=small?smallSource(size)[id]:SRC[id],b=FRONT[id],t=id==='flukes'?tailYaw:headYaw;
173 let opacity=1;
174 if(id==='flipper')opacity=lift*(1-attention);
175 // The band already belongs to the body. An overlay caused seams while
176 // turning; retain its named path for the rig without painting it twice.
177 if(id==='jaw-band')opacity=0;
178 if(id.startsWith('pleat')&&small)opacity=0;
179 // An eye smaller than two device pixels is omitted, never made googly.
180 if((id.startsWith('eye'))&&small&&26/512*size*dpr<2)opacity=0;
181 if(id==='eyelid')opacity=0; // Aperture morph owns the blink; retain the named part.
182 if(id==='eye-far')opacity=0; // The far side stays occluded in the modest turn.
183 const path=a.map((c,i)=>{if(c[0]==='Z')return ['Z'];const out=[c[0]];for(let j=1;j<c.length;j+=2){const blend=id==='body'?lerp(headYaw,tailYaw,1-smooth(140,260,c[j+1])):t;let x=lerp(c[j],b[i][j],blend),y=lerp(c[j+1],b[i][j+1],blend);
184 // Preserve the foreshortened blade through the three-quarter turn.
185 // Straight interpolation accidentally aligned these anchors vertically.
186 if(id==='body')x-=8*Math.sin(yaw*Math.PI)*smooth(90,0,Math.hypot(c[j]-160,c[j+1]-442));
187 if(id==='flipper'){x=posedFin[i][j];y=posedFin[i][j+1];}
188 if(yaw>0)x=256+(x-256)*lerp(1,dir.frontWidth,headYaw);
189 if(id==='eye'||id==='eye-far'){
190 const cx=id==='eye'?lerp(294,200,headYaw):lerp(294,365,headYaw),cy=lerp(337,330,headYaw);
191 const visible=id==='eye-far'?0:1;
192 x=cx+(x-cx)*p.eyeScale*visible;y=cy+(y-cy)*p.eyeScale*(1-p.lid*.97-p.lidLow*.64)*visible;
193 y+=p.lidLow*3*(Math.pow((x-cx)/13,2)-1);
194 x+=p.lookX*2;y+=p.lookY*2;
195 }
196 const xy=transform(id,x,y),grid=124/(size*dpr);
197 out.push(...(small?xy.map(v=>Math.round(v/grid*2)/2*grid):xy));}return out;});
198 shapes.push({id,path,role:['pouch','eye','eye-far'].includes(id)?'hole':'body',opacity});
199 }
200 // Keep the legacy named lid geometry; closing the eye narrows its aperture.
201 const lid=shapes.find(s=>s.id==='eyelid');lid.opacity=0;
202 lid.path=SRC.eyelid.map((c,i)=>{if(c[0]==='Z')return ['Z'];let o=[c[0]];for(let j=1;j<c.length;j+=2){const x=lerp(c[j],FRONT.eyelid[i][j],headYaw),cy=lerp(337,328,headYaw),y=lerp(c[j+1],FRONT.eyelid[i][j+1],headYaw);o.push(...transform('eye',x,cy-13+(y-cy+15)*p.lid*.40));}return o;});
203 const anchor=(x,y)=>{const z=transform('body',x,y);return {x:z[0],y:z[1]};};
204 const tip=shapes.find(s=>s.id==='flipper').path[4].slice(-2);
205 // A tapered negative-space wedge is the mark's existing flipper separation,
206 // carried up with the arm. It is a filled shape, not an outline or shadow.
207 const left=[[230,398],[230,357],[tx-66,ty+20],[tx,ty]],mix=(a,b,t)=>a.map((v,i)=>lerp(v,b[i],t));
208 const q1=mix(left[0],left[1],.42),mid=mix(left[1],left[2],.42),q2=mix(q1,mid,.42),q3=mix(q2,mix(mid,mix(left[2],left[3],.42),.42),.42);
209 const point=(p,dx=0,dy=0)=>transform('body',p[0]+dx,p[1]+dy);
210 const gap={id:'flipper-gap',path:[['M',...point(left[0],-6,-4)],['C',...point(q1,-7,-2),...point(q2,-5,-1),...point(q3)],['C',...point(q2),...point(q1),...point(left[0],-6,-4)],['Z']],role:'hole',opacity:lift*(1-headYaw)*(1-attention)};
211 shapes.splice(shapes.findIndex(s=>s.id==='flipper'),0,gap);
212 const tail=transform('flukes',lerp(289,296,tailYaw),lerp(113,121,tailYaw));
213 const anchors={blowhole:anchor(lerp(325,278,headYaw),lerp(234,278,headYaw)),spout:anchor(410,222),mouth:anchor(lerp(480,426,headYaw),lerp(300,353,headYaw)),chin:anchor(lerp(397,285,headYaw),lerp(376,425,headYaw)),eye:anchor(lerp(294,200,headYaw),lerp(337,330,headYaw)),
214 finTip:{x:tip[0],y:tip[1]},tail:{x:tail[0],y:tail[1]},tailAng:(p.rot+p.tilt*.28+(p.fluke-6)*.35)*D2R,
215 finAng:(p.rot+p.tilt*.28)*D2R,headAng:(p.rot+p.tilt*.28-p.head*.34)*D2R,eyeR:2.5};
216 return {shapes,anchors,pose:p,lod,small,size,dpr,unitScale:p.scale*(small?.9:1)};
217 }
218 function resolveLook(theme='paper',px=1,dpr=1,lod=0){return {theme,px,dpr,lod,body:theme==='charcoal'?['#3594D8','#2B70D5']:['#1E8FD8','#0B48BB'],hole:'#faf8f5',ink:theme==='mono'?'#000000':theme==='charcoal'?'#faf8f5':'#202123',mono:theme==='mono',bg:theme==='charcoal'?'#202123':'#faf8f5'};}
219 function trace(ctx,path){for(const c of path){if(c[0]==='M')ctx.moveTo(c[1],c[2]);else if(c[0]==='C')ctx.bezierCurveTo(...c.slice(1));else ctx.closePath();}}
220 function holesFor(id,shapes,mono=true){const prefix=id.slice(0,-4);
221 return shapes.filter(h=>(mono&&id.endsWith('body')&&h.role==='hole'&&[prefix+'pouch',prefix+'eye',prefix+'eye-far'].includes(h.id))||
222 (mono&&h.id==='flipper-gap'&&id==='body')||
223 (h.role==='cutout'&&['prop-page','prop-pad'].includes(id)&&h.id.startsWith(id+'-'))).map(h=>{
224 const origin=h.path[0].slice(1),w=h.opacity;
225 return h.path.map(c=>[c[0],...c.slice(1).map((v,i)=>lerp(origin[i%2],v,w))]);
226 });}
227 function draw(ctx,parts,look){const g=ctx.createLinearGradient(-50,-50,50,50);g.addColorStop(0,look.body[0]);g.addColorStop(1,look.body[1]);
228 for(const s of parts.shapes){if(s.opacity<=0||s.role==='cutout'||(look.mono&&s.role==='hole'))continue;ctx.save();ctx.globalAlpha*=s.opacity;ctx.beginPath();trace(ctx,s.path);
229 for(const hole of holesFor(s.id,parts.shapes,look.mono))trace(ctx,hole);
230 ctx.fillStyle=look.mono?look.ink:s.role==='hole'?look.hole:s.role==='pointer'?(look.theme==='charcoal'?'#66d6de':'#147888'):s.role==='accent'?'#D2A34E':s.role==='paper'?look.hole:s.role==='water'?look.body[0]:s.role==='tool'?look.ink:g;
231 ctx.fill('evenodd');ctx.restore();}return look;}
232 function svg(parts,theme='paper',size=512){const look=resolveLook(theme);
233 const paths=parts.shapes.map(s=>{let opacity=s.opacity,d=pathString(s.path);d+=' '+holesFor(s.id,parts.shapes,look.mono).map(pathString).join(' ');if(s.role==='cutout'||(look.mono&&s.role==='hole'))opacity=0;
234 const fill=look.mono?'currentColor':s.role==='hole'||s.role==='paper'?look.hole:s.role==='pointer'?(theme==='charcoal'?'#66d6de':'#147888'):s.role==='accent'?'#D2A34E':s.role==='water'?look.body[0]:s.role==='tool'?look.ink:'url(#whale-blue)';
235 return `<path id="${s.id}" d="${d}" fill="${fill}" fill-rule="evenodd" opacity="${+opacity.toFixed(4)}"/>`;}).join('\n');
236 return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="-62 -62 124 124" width="${size}" height="${size}" role="img" aria-label="Codewhale character"><defs><linearGradient id="whale-blue" gradientUnits="userSpaceOnUse" x1="-50" y1="-50" x2="50" y2="50"><stop stop-color="${look.body[0]}"/><stop offset="1" stop-color="${look.body[1]}"/></linearGradient></defs>\n${paths}\n</svg>`;
237 }
238 root.WhaleRig={TAU,D2R,lerp,clamp,smooth,POSE,DIRECTIONS,build,resolveLook,draw,svg,pathString,parse,ellipse,trace,signedArea,holesFor,sourceParts:SRC};
239 })(typeof window!=='undefined'?window:globalThis);
240
240 lines JAVASCRIPT