aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/faceArt.js
blob: a14f53a043d8968aebc47a8a625a9a67c696851a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
/**
 * The face artwork: everything that used to be a hand-drawn PNG in GIMP is now
 * drawn from parameters onto a canvas, which is then used as the texture of the
 * two overlay planes that already exist on the model.
 *
 * The plates have a linear UV mapping fitted to the original artwork, so drawing
 * in "texture pixels" lands exactly where the original artwork did. Every constant
 * below was measured from the original textures (see README for the numbers), so
 * the defaults reproduce the 2022 artwork while every part of it stays editable.
 *
 * Texture space: the artwork WINDOW, 1024 x 380, origin at the top-left, y grows
 * downwards. `canvasLayout` then says where that window sits on the bigger canvas
 * a face plate actually needs (see below), and `Face` hands the bounds back as
 * `limits`, so the drawing can clamp against the real edge instead of assuming
 * the window is all there is.
 *
 * glTF stores v downwards too, and the textures are uploaded with flipY = false,
 * so no flipping is needed anywhere.
 */

const TAU = Math.PI * 2;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
const HUGE = 4000;

/**
 * The artwork window: the rectangle the 2022 hand-drawn textures occupied. Every
 * constant below is written in this space.
 */
export const ART_WINDOW = { width: 1024, height: 380 };

/** Keep this much clear at the canvas border, so filtering has room to breathe. */
const EDGE_MARGIN = 8;

/** What a drawing falls back to when no plate bounds are supplied. */
const WINDOW_LIMITS = {
  top: 0, bottom: ART_WINDOW.height, left: 0, right: ART_WINDOW.width,
};

/**
 * The canvas a face plate's drawing needs, and where the artwork window sits on it.
 *
 * WHY: the plates are the *whole* front half of the body, so their UVs run far
 * outside the 0..1 the artwork was drawn in (measured on the shipped model: the
 * eye plate covers v -1.02..1.93, and 9 of 10 of its vertices sit within a
 * twentieth of the border). Everything outside 0..1 used to clamp to the canvas
 * edge row, so the moment a drawing reached the border - a tear dropped low, a
 * brow lifted, a thick mouth pushed down - that edge row was copied across the
 * rest of the plate and the mark stretched into a long smear.
 *
 * `Face` rewrites each plate's UVs so this range maps onto 0..1 instead, which
 * removes the clamp altogether. The map is affine, so the artwork keeps its exact
 * pixel size and position and merely shifts by `offset`; the canvas grows by the
 * same factor, and those extra rows are the room a moved tear, brow or mouth
 * needs (`limits` says where the real edge now is, in window coordinates).
 *
 * @param {{u0:number,u1:number,v0:number,v1:number}} range the plate's UV range
 * @param {{width:number,height:number}} [window] the artwork window
 */
export function canvasLayout(range, window = ART_WINDOW) {
  const u0 = Number.isFinite(range?.u0) ? range.u0 : 0;
  const u1 = Number.isFinite(range?.u1) ? range.u1 : 1;
  const v0 = Number.isFinite(range?.v0) ? range.v0 : 0;
  const v1 = Number.isFinite(range?.v1) ? range.v1 : 1;
  const du = Math.max(1e-6, u1 - u0);
  const dv = Math.max(1e-6, v1 - v0);
  const width = Math.max(1, Math.round(window.width * du));
  const height = Math.max(1, Math.round(window.height * dv));
  // `+ 0` turns a `-0` into a plain `0`, so the offsets compare cleanly.
  const offsetX = Math.round(-u0 * window.width) + 0;
  const offsetY = Math.round(-v0 * window.height) + 0;
  return {
    width,
    height,
    offsetX,
    offsetY,
    window,
    // The canvas edges in the artwork's own coordinates. `top` is negative when
    // the plate reaches above the window: that is the extra room.
    limits: {
      top: -offsetY + 0,
      bottom: height - offsetY,
      left: -offsetX + 0,
      right: width - offsetX,
    },
  };
}

/** Fixed layout of the eye plane, measured from the original `eyes-open.png`. */
export const EYE_LAYOUT = {
  radius: 84,
  irisRadius: 62.5,
  highlightRadius: 28,
  highlightOffset: { x: 29.5, y: -18 },
  // The model's left eye (its own left, +x) sits in the u > 0.5 half.
  eyes: [
    { key: 'right', cx: 267.5, cy: 240, towardNose: 1 },
    { key: 'left', cx: 755.5, cy: 240, towardNose: -1 },
  ],
  lidRadiusFactor: 1.35,
  lidStroke: 14,
  // Eyebrows are painted into the same texture as the eyes. The eye plane is a
  // flat-ish patch on a round head, so only its lower part is really outside
  // the head: a brow high on the forehead would be swallowed by it. The brow
  // therefore sits just above the eyeball (row 156) and the eyeball is drawn
  // over its lower edge afterwards.
  // The brow sits just above the eyeball, and the eyeball is drawn over its
  // lower edge. `lift` is that resting height, so `height: 0` is the brow's
  // natural place and a POSITIVE height raises it (a negative one sinks it
  // towards the eye, where the eyeball will cover it).
  brow: { lift: 114, length: 134, thickness: 15, curve: 0.14, offsetX: 6 },
  // A teardrop hangs well below the eye. The plate keeps the whole canvas, so
  // the drop can sit low without being clipped (measured with a ruler grid).
  tear: { size: 34, offsetX: 66, offsetY: 88 },
  closedLine: {
    // The single shut line is the *long* form of the eyelid, the same reach as
    // the arms of the "ぎゅっ" below, so switching between 1 and 3 lines does not
    // change how wide the eye reads.
    length: 205,
    offsetX: 6,
    offsetY: -10,
    slantDeg: 3.9,
    bow: 3,
    // 2- and 3-line shut eyes, copied from the original `eyes-close-tight`
    // artwork: three strokes sharing one vertex that points at the nose, opening
    // to a wide bird's foot / arrow shape. `spread` is half the height the arms
    // open to at the far end.
    armSpan: 205,
    spread: 62,
    vertex: 87.5,
    armX: 104,
  },
  arch: { edgesUp: 8, apexUp: 44 },
};

/**
 * ほっぺ (a manga blush) and 頭の模様 (a mark on the head), both drawn into the eye
 * plate next to the eyes and brows.
 *
 * They are deliberately *generic* manga devices, not a copy of any one character:
 * a pink patch crossed by short diagonal strokes is a stock convention, and the
 * head mark is one of a few simple shapes (a hook, a spiral, three strokes, a dot
 * row) that starts off. Both stay editable through the panel. The cheek positions
 * are tied to `EYE_LAYOUT` so they sit beside the eyes; the head mark sits up in
 * the plate's upper area, i.e. on the forehead above the brows.
 */
export const CHEEK_LAYOUT = {
  drop: 64,        // how far below the eye centre the patch sits (artwork px)
  outward: 66,     // how far outwards, away from the midline, it sits
  radius: 50,      // half-width of the patch
  squash: 0.55,    // half-height, as a fraction of the half-width (a flat ellipse)
  // The blush is *always* four strokes - the reference artwork has four - so
  // there is no line-count control any more. The strokes are horizontal and
  // hand-drawn: each wobbles a little and tapers to a point at both ends.
  lines: 4,
  lineWidth: 10,   // the stroke thickness at its widest (artwork px)
  spread: 0.62,    // how far the outermost stroke sits from the centre (x half-height)
  margin: 0.18,    // clear gap left between a stroke's end and the patch outline
  wobble: 0.32,    // sideways wander, as a fraction of the stroke thickness
};

/**
 * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol.
 *
 * The face plate is a shell of the *whole* front half of the body, so the plate's
 * own edge is the head's front silhouette. Filling the plate from its top edge
 * down to an adjustable boundary therefore makes the covering's outline follow
 * the silhouette for free - there is no separate ellipse to keep in step.
 *
 * It is a generic device: a colour cap, a fringe, a side part, or a left/right
 * two-tone split. It is deliberately *not* a copy of any one character's hair.
 * It starts off.
 */
export const HEAD_MARK_LAYOUT = {
  // Where the boundary sits by default: on the forehead, just above the brows
  // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it).
  cy: 120,
  reach: 190,      // artwork px the boundary moves per unit of `size`
};

/**
 * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate
 * at the nose, the same spot the snot bubble uses. `shape` picks a well-known
 * kind; `size` scales it about the nose, and the colours are adjustable.
 */
export const BEARD_LAYOUT = {
  cx: 511.5,
  cy: 352,        // just under the nose
  width: 240,     // base width of the widest beard at size 1
};

/**
 * 眼鏡 / サングラス, drawn into the same texture as the eyes and brows.
 *
 * Not measured off any artwork - the character has no glasses - but the numbers
 * are tied to the eyeball so a lens is always a little wider than the eye it
 * covers. The lens centre sits *below* the eye centre because the brow is drawn
 * first, just above the eyeball (see `EYE_LAYOUT.brow`): a lens wide enough to
 * read as glasses would otherwise cut straight through it.
 *
 * The two kinds get separate shapes on purpose. They used to share one ellipse
 * and differ only in how dark the lens was filled, which made them hard to tell
 * apart; the round 眼鏡 below and the wide, angular サングラス in
 * `SUNGLASSES_LAYOUT` now read as different objects at a glance.
 */
const GLASSES_LAYOUT = {
  widthFactor: 1.08,   // lens half-width, as a multiple of the eyeball radius
  heightFactor: 1.0,   // a circle, so the round 眼鏡 stays round (a flatter
                       // ellipse used to drift towards the shades below)
  drop: 20,            // the lens centre hangs this far below the eye centre (artwork px)
  bridgeRise: 8,       // how much the bridge arcs up over the nose
  bridgeWidth: 0.75,   // bridge thickness, as a fraction of the frame stroke
  templeLength: 1.5,   // temple stub length, as a multiple of the eyeball radius
  templeRise: 30,      // how far the temple climbs towards the side of the head
};

/**
 * サングラス: a long, pointed cat-eye rather than the round 眼鏡 lens. Each side is a
 * slim wedge whose *outer end starts low*, rises to a *high, pointed outer corner*
 * set further out (`tipX`), and whose top edge then sweeps back down towards the
 * nose - the "外側が長くとがって上に上がる" shape the reference shows. The inner end is
 * short, so the lens tapers inwards. The eye is allowed to poke out above and
 * below it, so the lens no longer has to cover the whole eyeball. A level bar
 * joins the two inner ends, so the pair still reads as one dark visor. The ratios
 * are of the eyeball radius, exactly like `GLASSES_LAYOUT`, so `scale` means the
 * same thing for both kinds.
 */
const SUNGLASSES_LAYOUT = {
  widthFactor: 1.6,    // a long lens: the outer end reaches well past the eyeball
  heightFactor: 0.58,  // slim, so the eye is free to show above and below it
  drop: 8,             // sits a little lower than the round pair
  corner: 0.05,        // corner rounding, as a fraction of the half-height; kept
                       // tiny so the outer corner stays pointed
  topSkew: 1.0,        // the pointed corner rises this far above the lens top (x half-height)
  innerTop: 0.42,      // the inner top is low, which is what makes the top edge climb
  innerBottom: 0.34,   // the inner bottom is pinched up towards the nose (x half-height)
  outerEndX: 0.75,     // the low outer end sits this far out (x half-width)
  outerBottom: 0.82,   // ...and this deep (x half-height)
  tipX: 1.22,          // the pointed corner juts this far out (x half-width)
  bridgeWidth: 1.15,   // a short, thick bar - thicker than the frame stroke
  bridgeLift: 0.25,    // the bar sits this far above the lens centre (x half-height)
  templeLength: 1.15,  // a short stub, like the round pair's but a little shorter
  templeRise: 24,
  // A heavy rim. It used to be slimmer than the round pair's stroke, which read
  // as a thin pair of shades; the reference look is a chunky frame.
  frameFactor: 1.6,
};

/** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole
 *  1024 x 380 canvas (see tools/build-face-plates.py), so the drawing no longer
 *  has to be squeezed into a band. The only limit left is the canvas itself. */
export const MOUTH_LAYOUT = {
  centreX: 511.5,
  chordY: 88,
  halfChord: 420.5,
  sag: 177,
  // With the ends and the depth both fixed - which is what the slider promises -
  // the only thing left to choose about the curve is *where* it bends. 1/3 is a
  // quadratic Bézier (a parabola): it is flattest at the apex and falls away
  // fastest near the ends. Pulling the cubic's control points in towards the ends
  // makes the middle of the smile straighter still and lets the fall happen near
  // the corners, which is what reads as a *gentler* curve at the same depth.
  sagBend: 0.2,
  thickness: 21,
  // Kept for reference: the 2022 plane only showed these rows.
  bandTop: 46,
  bandBottom: 332,
  // Only used to cap the *size* of the round "O" mouth, so the surprise face
  // stays a mouth and not a hole. The mouth's travel no longer stops here: the
  // drawing is given the plate's real edges through `limits`.
  safeBottom: 372,
  // The nose is a separate mesh that pokes out in front of the plate. The plate
  // is depth tested, so the nose hides whatever is drawn behind it - but a frown
  // arcs *up* into that hiding place, so slide the mouth down until the middle
  // of the arc clears the nose. Only the middle is checked: a smile curves away
  // from the nose, and checking its ends instead is what used to pin the whole
  // mouth in place and make the height slider do nothing.
  noseClear: 132,
  // The original hand-drawn mouth is a *shallow* arc with tall corner strokes
  // flicking up at the ends (measured from the artwork: the arc's own sag is
  // ~0.13 of its chord, while the corners reach ~90px above it). Keeping that
  // split is what makes `smile: 1` read as the original; making the arc itself
  // deep instead looked too steep.
  //
  // The tongue rises from the lip line to a rounded top just above the corner
  // strokes. Measured off the original: its crown is a *super-ellipse* -
  // `rise = height * (1 - |dx/half|^2.5)` - so it is steep-sided with a smooth,
  // almost flat top. It is convex upwards, but it is **not** a point.
  tongue: { pos: 0.84, width: 126, height: 115, crown: 2.5 },
  corner: { fromX: 26, fromY: 3, toX: 32, toY: 42, width: 11, curve: 26 },
  openRise: 64,
  // Was 0.25: opening the mouth used to flatten the smile to stay inside the
  // texture band. There is no band to stay inside now.
  openFlatten: 0,
};

/* ------------------------------------------------------------------ helpers */

function circlePath(ctx, cx, cy, r) {
  ctx.beginPath();
  ctx.arc(cx, cy, r, 0, TAU);
  ctx.closePath();
}

function fillCircle(ctx, cx, cy, r, color) {
  circlePath(ctx, cx, cy, r);
  ctx.fillStyle = color;
  ctx.fill();
}

/**
 * A heart, used for the "love" eyes. The path is wider than it is tall, which
 * is what makes it read as a heart rather than a blob at small sizes.
 */
function heartPath(ctx, cx, cy, r) {
  ctx.beginPath();
  ctx.moveTo(cx, cy + r * 0.80);
  ctx.bezierCurveTo(cx - r * 1.24, cy - r * 0.34, cx - r * 0.50, cy - r * 1.18, cx, cy - r * 0.40);
  ctx.bezierCurveTo(cx + r * 0.50, cy - r * 1.18, cx + r * 1.24, cy - r * 0.34, cx, cy + r * 0.80);
  ctx.closePath();
}

function fillHeart(ctx, cx, cy, r, color) {
  heartPath(ctx, cx, cy, r);
  ctx.fillStyle = color;
  ctx.fill();
}

/** A teardrop, used by the crying expression. */
function dropPath(ctx, cx, cy, size) {
  ctx.beginPath();
  ctx.moveTo(cx, cy - size * 1.32);
  ctx.bezierCurveTo(cx + size * 0.95, cy - size * 0.34, cx + size * 0.95, cy + size * 0.78, cx, cy + size * 0.78);
  ctx.bezierCurveTo(cx - size * 0.95, cy + size * 0.78, cx - size * 0.95, cy - size * 0.34, cx, cy - size * 1.32);
  ctx.closePath();
}

function strokeCircle(ctx, cx, cy, r, color, width) {
  circlePath(ctx, cx, cy, r);
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.stroke();
}

function tracePolyline(ctx, points, closed) {
  if (!points.length) return;
  ctx.beginPath();
  ctx.moveTo(points[0].x, points[0].y);
  for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
  if (closed) ctx.closePath();
}

function strokePolyline(ctx, points, { color, width, closed = false }) {
  if (points.length < 2) return;
  ctx.save();
  ctx.strokeStyle = color;
  ctx.lineWidth = width;
  ctx.lineJoin = 'round';
  ctx.lineCap = 'round';
  tracePolyline(ctx, points, closed);
  ctx.stroke();
  ctx.restore();
}

function fillPolygon(ctx, points, color) {
  if (points.length < 3) return;
  ctx.save();
  ctx.fillStyle = color;
  tracePolyline(ctx, points, true);
  ctx.fill();
  ctx.restore();
}

/**
 * Trace a closed polygon with rounded corners. Used for the squarish
 * サングラス lens: an ellipse cannot be angular, and a plain polygon has cusps.
 * Each corner is cut back by `radius` (clamped so short edges cannot overlap)
 * and joined with a quadratic through the original vertex.
 */
function traceRoundedPolygon(ctx, points, radius) {
  const n = points.length;
  ctx.beginPath();
  for (let i = 0; i < n; i++) {
    const prev = points[(i + n - 1) % n];
    const cur = points[i];
    const next = points[(i + 1) % n];
    const inLen = Math.hypot(cur.x - prev.x, cur.y - prev.y) || 1;
    const outLen = Math.hypot(next.x - cur.x, next.y - cur.y) || 1;
    const cut = Math.min(radius, inLen / 2, outLen / 2);
    const from = { x: cur.x + ((prev.x - cur.x) / inLen) * cut, y: cur.y + ((prev.y - cur.y) / inLen) * cut };
    const to = { x: cur.x + ((next.x - cur.x) / outLen) * cut, y: cur.y + ((next.y - cur.y) / outLen) * cut };
    if (i === 0) ctx.moveTo(from.x, from.y);
    else ctx.lineTo(from.x, from.y);
    ctx.quadraticCurveTo(cur.x, cur.y, to.x, to.y);
  }
  ctx.closePath();
}

/** Points along a quadratic Bézier, `steps` segments (steps + 1 points). */
function quadraticPoints(p0, p1, p2, steps) {
  const out = [];
  for (let i = 0; i <= steps; i++) {
    const t = i / steps;
    const u = 1 - t;
    out.push({
      x: u * u * p0.x + 2 * u * t * p1.x + t * t * p2.x,
      y: u * u * p0.y + 2 * u * t * p1.y + t * t * p2.y,
    });
  }
  return out;
}

/** A point on a cubic Bézier. */
function cubicPoint(p0, p1, p2, p3, t) {
  const u = 1 - t;
  const a = u * u * u;
  const b = 3 * u * u * t;
  const c = 3 * u * t * t;
  const d = t * t * t;
  return {
    x: a * p0.x + b * p1.x + c * p2.x + d * p3.x,
    y: a * p0.y + b * p1.y + c * p2.y + d * p3.y,
  };
}

/** Points along a cubic Bézier, `steps` segments (steps + 1 points). */
function cubicPoints(p0, p1, p2, p3, steps) {
  const out = [];
  for (let i = 0; i <= steps; i++) out.push(cubicPoint(p0, p1, p2, p3, i / steps));
  return out;
}

/**
 * The four control points of the mouth's centreline: the same two ends, the same
 * depth, but `bend` decides where the curve actually bends (see `sagBend`).
 */
function mouthControls(left, right, sag, bend) {
  const width = right.x - left.x;
  const y = left.y + (4 / 3) * sag;
  return {
    p0: left,
    p1: { x: left.x + width * bend, y },
    p2: { x: right.x - width * bend, y },
    p3: right,
  };
}

function rotate(points, pivot, degrees) {
  if (!degrees) return points;
  const a = (degrees * Math.PI) / 180;
  const cos = Math.cos(a);
  const sin = Math.sin(a);
  return points.map((p) => {
    const dx = p.x - pivot.x;
    const dy = p.y - pivot.y;
    return { x: pivot.x + dx * cos - dy * sin, y: pivot.y + dx * sin + dy * cos };
  });
}

/* --------------------------------------------------------------------- eyes */

/**
 * Intersect the canvas clip with the eyeball disc and the (possibly closed)
 * lids. Because the overlay plane is transparent and sits in front of the face,
 * clipping is all that is needed: whatever is clipped away shows the real
 * shaded face behind it. That is what makes the eyelid blend perfectly without
 * baking a face-coloured background into the texture.
 */
function clipEye(ctx, eye, open, lower, flat = false) {
  circlePath(ctx, eye.cx, eye.cy, eye.r);
  ctx.clip();

  const upper = 1 - clamp(open, 0, 1);
  if (upper > 0.0005) {
    const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
    const lowest = eye.cy - eye.r + 2 * eye.r * upper;
    ctx.beginPath();
    if (flat) {
      // A flat lid: a straight edge straight across the eye.
      ctx.rect(eye.cx - HUGE, lowest, HUGE * 2, HUGE);
    } else {
      const cy = lowest - rl;
      ctx.moveTo(eye.cx - HUGE, cy);
      ctx.lineTo(eye.cx - rl, cy);
      ctx.arc(eye.cx, cy, rl, Math.PI, 0, true); // lower semicircle: bulges down
      ctx.lineTo(eye.cx + HUGE, cy);
      ctx.lineTo(eye.cx + HUGE, cy + HUGE);
      ctx.lineTo(eye.cx - HUGE, cy + HUGE);
    }
    ctx.closePath();
    ctx.clip();
  }

  const bottom = clamp(lower, 0, 1);
  if (bottom > 0.0005) {
    const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
    const highest = eye.cy + eye.r - 2 * eye.r * bottom;
    ctx.beginPath();
    if (flat) {
      ctx.rect(eye.cx - HUGE, highest - HUGE, HUGE * 2, HUGE);
    } else {
      const cy = highest + rl;
      ctx.moveTo(eye.cx - HUGE, cy);
      ctx.lineTo(eye.cx - rl, cy);
      ctx.arc(eye.cx, cy, rl, Math.PI, 0, false); // upper semicircle: bulges up
      ctx.lineTo(eye.cx + HUGE, cy);
      ctx.lineTo(eye.cx + HUGE, cy - HUGE);
      ctx.lineTo(eye.cx - HUGE, cy - HUGE);
    }
    ctx.closePath();
    ctx.clip();
  }
}

function lidPath(ctx, eye, amount, lower, flat = false) {
  ctx.beginPath();
  if (flat) {
    // A straight lid edge; the caller clips it to the eyeball.
    const y = lower
      ? eye.cy + eye.r - 2 * eye.r * amount
      : eye.cy - eye.r + 2 * eye.r * (1 - amount);
    ctx.moveTo(eye.cx - eye.r * 2, y);
    ctx.lineTo(eye.cx + eye.r * 2, y);
    return;
  }
  const rl = eye.r * EYE_LAYOUT.lidRadiusFactor;
  if (!lower) {
    const lowest = eye.cy - eye.r + 2 * eye.r * (1 - amount);
    ctx.arc(eye.cx, lowest - rl, rl, Math.PI, 0, true);
  } else {
    const highest = eye.cy + eye.r - 2 * eye.r * amount;
    ctx.arc(eye.cx, highest + rl, rl, Math.PI, 0, false);
  }
}

/** The "eyes shut" artwork: a line, a chevron or a happy arch. */
function drawShutEye(ctx, eye, spec, style) {
  const layout = EYE_LAYOUT.closedLine;
  const { line, width } = style;

  if (spec.closed === 'chevron') {
    // The chevron points *at* the nose, matching the original artwork.
    const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 };
    const armX = eye.cx - eye.towardNose * layout.armX;
    const spread = layout.spread * (spec.spread ?? 1);
    strokePolyline(ctx, [vertex, { x: armX, y: vertex.y - spread }], { color: line, width });
    strokePolyline(ctx, [vertex, { x: armX, y: vertex.y + spread }], { color: line, width });
    return;
  }

  if (spec.closed === 'three') {
    // A real "3": an upper bowl and a lower bowl that meet at a single pinch on
    // the nose side. Stacking two C's instead made the two lobes sit on top of
    // each other; joining them at a point is what makes it read as the digit.
    // `length` scales the whole shape.
    const r = 34 * (spec.length ?? 1);
    // Which way the digit faces. It used to follow the nose side, which made the
    // pair a mirror image; each eye can now be set either way, because a pair of
    // mirrored 3s does not always read the way you want.
    const dir = (spec.threeFlip ? -1 : 1) * eye.towardNose;
    const sx = eye.cx;
    const cy = eye.cy + layout.offsetY;
    const at = (x, y) => ({ x: sx + dir * r * x, y: cy + r * y });
    const pinch = at(0.72, 0);
    const upper = quadraticPoints(at(0.02, -1.62), at(2.05, -1.40), pinch, 26);
    const lower = quadraticPoints(pinch, at(2.05, 1.46), at(0.02, 1.78), 26);
    strokePolyline(ctx, upper, { color: line, width });
    strokePolyline(ctx, lower, { color: line, width });
    return;
  }

  if (spec.closed === 'arch') {
    const edges = { x: eye.r, y: EYE_LAYOUT.arch.edgesUp };
    const points = quadraticPoints(
      { x: eye.cx - edges.x, y: eye.cy + edges.y },
      { x: eye.cx, y: eye.cy + edges.y - EYE_LAYOUT.arch.apexUp * 2 },
      { x: eye.cx + edges.x, y: eye.cy + edges.y },
      24,
    );
    strokePolyline(ctx, points, { color: line, width });
    return;
  }

  // Default: the gently slanted line of the original artwork, mirrored so it
  // always slopes down towards the nose. With `closedLines` set to 2 or 3 the
  // strokes share one endpoint instead - a ">" or bird's-foot shape, which is
  // how manga draws a happily squeezed-shut eye.
  const count = clamp(Math.round(spec.closedLines ?? 1), 1, 3);
  if (count >= 2) {
    const vertex = { x: eye.cx + eye.towardNose * layout.vertex, y: eye.cy + layout.offsetY - 8 };
    // Reach as far as the single line does, not just a short stub: the arms are
    // the *long* form of the same eyelid the one-line version covers.
    const outX = -eye.towardNose * layout.armSpan;
    const arms = count === 2
      ? [{ x: outX, y: -layout.spread }, { x: outX, y: layout.spread }]
      : [
        { x: outX, y: -layout.spread },
        { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread), y: 0 },
        { x: outX, y: layout.spread },
      ];
    for (const arm of arms) {
      strokePolyline(ctx, [vertex, { x: vertex.x + arm.x, y: vertex.y + arm.y }], { color: line, width });
    }
    return;
  }

  const half = (layout.length / 2) * (spec.length ?? 1);
  const midX = eye.cx - eye.towardNose * layout.offsetX;
  const midY = eye.cy + layout.offsetY;
  const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180;
  const bow = (layout.bow ?? 0) * (spec.slant ?? 1);
  const from = { x: midX - Math.cos(angle) * half, y: midY - Math.sin(angle) * half };
  const to = { x: midX + Math.cos(angle) * half, y: midY + Math.sin(angle) * half };
  const points = quadraticPoints(from, { x: midX, y: midY + bow * 2 }, to, 20);
  strokePolyline(ctx, points, { color: line, width });
}

/**
 * A stroke whose width runs from `w0` at the first point to `w1` at the last.
 *
 * Used for a ゴルゴ13-style brow: a filled wedge that comes to a point at one end
 * reads as a heavy eyebrow, where a constant-width stroke reads as a soft arc.
 */
function taperedStroke(ctx, points, w0, w1, color) {
  const count = points.length;
  if (count < 2) return;
  const side = [[], []];
  for (let i = 0; i < count; i++) {
    const t = i / (count - 1);
    const w = (w0 + (w1 - w0) * t) / 2;
    const before = points[Math.max(0, i - 1)];
    const after = points[Math.min(count - 1, i + 1)];
    const dx = after.x - before.x;
    const dy = after.y - before.y;
    const len = Math.hypot(dx, dy) || 1;
    const nx = -dy / len;
    const ny = dx / len;
    side[0].push({ x: points[i].x + nx * w, y: points[i].y + ny * w });
    side[1].push({ x: points[i].x - nx * w, y: points[i].y - ny * w });
  }
  ctx.save();
  tracePolyline(ctx, [...side[0], ...side[1].reverse()], true);
  ctx.fillStyle = color;
  ctx.fill();
  ctx.restore();
}

/** An eyebrow: a short arc above the eye, mirrored between the two eyes so a
 *  positive `angle` always means "inner end down" (an angry brow). */
function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) {
  const brow = style.brow;
  if (!brow?.enabled) return;
  const layout = EYE_LAYOUT.brow;
  // 0 is allowed: a zero-length stroke with round caps is a dot, which is what a
  // length of 0 is asking for.
  const length = Math.max(0, (brow.length ?? 1) * layout.length);
  const thickness = Math.max(1, (brow.thickness ?? 1) * layout.thickness);
  const cy = eye.cy - layout.lift - (brow.height ?? 0) - (spec.browHeight ?? 0);
  // `spacing` widens the gap between the two brows: a positive value moves each
  // one away from the nose. `layout.offsetX` is the resting inset from the
  // artwork (which the old code read off the wrong object, so it never applied).
  const cx = eye.cx + eye.towardNose * (layout.offsetX - (brow.spacing ?? 0));
  const angle = (((brow.angle ?? 0) + (spec.browAngle ?? 0)) * eye.towardNose * Math.PI) / 180;
  const half = length / 2;
  const from = { x: cx - Math.cos(angle) * half, y: cy - Math.sin(angle) * half };
  const to = { x: cx + Math.cos(angle) * half, y: cy + Math.sin(angle) * half };
  const bow = (brow.curve ?? layout.curve) * length;
  const points = quadraticPoints(from, { x: cx, y: cy - bow }, to, 16);
  // A brow raised with the height slider used to run off the top of the canvas,
  // where the clamped edge row was copied across the whole plate and the brow
  // smeared upwards. Keep the whole stroke inside the artwork instead.
  const top = limits.top + EDGE_MARGIN;
  const highest = Math.min(...points.map((q) => q.y)) - thickness / 2;
  if (highest < top) for (const q of points) q.y += top - highest;
  // `taper` is how wide the *inner* (nose-side) end is: 1 = a plain stroke, and
  // lower values turn the brow into a wedge that comes to a point at the nose.
  const taper = clamp(brow.taper ?? 1, 0, 1);
  const color = brow.color ?? style.line ?? '#55386e';
  // A tapered wedge has no shape at zero length, so a dot always takes the plain
  // round-capped stroke.
  if (taper >= 0.999 || length < 2) {
    strokePolyline(ctx, points, { color, width: thickness });
    return;
  }
  const innerIsTo = eye.towardNose > 0;
  taperedStroke(
    ctx,
    points,
    thickness * (innerIsTo ? 1 : taper),
    thickness * (innerIsTo ? taper : 1),
    color,
  );
}

/* ------------------------------------------------------- ほっぺ / 頭の模様 */

/**
 * ほっぺ: a soft pink patch crossed by short diagonal hatch strokes, the classic
 * manga blush. Drawn once per cheek; the caller draws these before the eyes.
 */
function drawCheek(ctx, cx, cy, spec) {
  const size = clamp(spec.size ?? 1, 0.2, 3);
  const r = CHEEK_LAYOUT.radius * size;
  const ry = r * CHEEK_LAYOUT.squash;

  ctx.save();
  ctx.beginPath();
  ctx.ellipse(cx, cy, r, ry, 0, 0, TAU);
  ctx.fillStyle = spec.color ?? '#f6a6b8';
  ctx.fill();
  // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge.
  ctx.clip();

  // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a
  // point at both ends and wanders a little. Each one stops short of the patch
  // outline - the margin is measured against the patch's own width at that
  // height, so a stroke on the narrow top or bottom cannot reach the edge.
  const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size);
  ctx.fillStyle = spec.hatchColor ?? '#e0708f';
  for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) {
    const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1
    const dy = t * ry * CHEEK_LAYOUT.spread;
    const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2));
    const half = chord - r * CHEEK_LAYOUT.margin;
    if (half < 4) continue;
    traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9);
  }
  ctx.restore();
}

/**
 * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both
 * ends, with a gentle wobble so it reads as drawn rather than printed.
 */
function traceHandHatch(ctx, cx, cy, half, thickness, seed) {
  const steps = 12;
  const top = [];
  const bottom = [];
  for (let i = 0; i <= steps; i += 1) {
    const u = i / steps;
    const x = cx + (u - 0.5) * 2 * half;
    const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble;
    const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends
    top.push({ x, y: cy + wobble - w });
    bottom.push({ x, y: cy + wobble + w });
  }
  ctx.beginPath();
  ctx.moveTo(top[0].x, top[0].y);
  for (const point of top.slice(1)) ctx.lineTo(point.x, point.y);
  for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y);
  ctx.closePath();
  ctx.fill();
}

/** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */
function drawCheeks(ctx, spec) {
  if (!spec?.enabled) return;
  const drop = CHEEK_LAYOUT.drop + clamp(spec.offsetY ?? 0, -400, 400);
  const outward = CHEEK_LAYOUT.outward + clamp(spec.spacing ?? 0, -400, 400);
  for (const layout of EYE_LAYOUT.eyes) {
    // Outwards is away from the midline, i.e. the opposite of `towardNose`.
    const cx = layout.cx - layout.towardNose * outward;
    drawCheek(ctx, cx, layout.cy + drop, spec);
  }
}

/**
 * 頭の模様: a hair-like covering over the whole head.
 *
 * The face plate is a shell of the front half of the body, so its own edge is
 * the head's silhouette. This fills the plate from its top edge down to an
 * adjustable boundary, which is what makes the covering follow the head outline
 * without knowing the head's shape ahead of time. The boundary style, colour and
 * size are all adjustable, and one style (`split`) divides the head into two
 * colours along a vertical curve - a generic "two-tone head" device. It is not a
 * copy of any particular character's hair.
 */
function drawHeadCover(ctx, spec, limits) {
  const shape = spec?.shape ?? 'off';
  if (shape === 'off' || shape === 'none') return;

  const size = clamp(spec.size ?? 1, 0.02, 3);
  const left = limits.left;
  const right = limits.right;
  const top = limits.top;
  const bottom = limits.bottom;
  const width = right - left;
  const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600);
  const color = spec.color ?? '#8a4fe0';
  const color2 = spec.color2 ?? '#ffffff';

  ctx.save();
  // Stay on the plate: nothing may spill past the artwork's real edges.
  ctx.beginPath();
  ctx.rect(left, top, width, bottom - top);
  ctx.clip();

  if (shape === 'split') {
    // Retired: the two-tone head was dropped, but an old saved state may still
    // carry the shape, so treat it as off rather than drawing a two-tone head.
    ctx.restore();
    return;
  }

  // The boundary curve. `t` runs -1..1 across the plate, so each style is a
  // small shape function; the covering is everything above it.
  const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach
    + clamp(spec.offsetY ?? 0, -600, 600);
  const halfSpan = width / 2;
  const boundary = (x) => {
    const t = clamp((x - cx) / halfSpan, -1, 1);
    if (shape === 'fringe') {
      // Bangs: a soft point dipping in the middle, with side lobes.
      return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI));
    }
    if (shape === 'fringe-up') {
      // The mirror image: a fringe that peaks *up* in the middle (a widow's peak
      // or centre part), so more forehead shows in the middle than at the sides.
      return base - 150 * (1 - Math.abs(t)) + 55 * Math.abs(Math.sin(t * Math.PI));
    }
    if (shape === 'wave') {
      // A single smooth sweep across the forehead.
      return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5);
    }
    if (shape === 'side') {
      // A side part: high on one side, low on the other.
      return base + 260 * t;
    }
    // `cap`: a rounded helmet, lowest in the middle.
    return base - 100 * (1 - t * t);
  };

  const steps = 48;
  ctx.beginPath();
  ctx.moveTo(left, top);
  ctx.lineTo(right, top);
  for (let i = steps; i >= 0; i -= 1) {
    const x = left + (width * i) / steps;
    ctx.lineTo(x, boundary(x));
  }
  ctx.closePath();
  ctx.fillStyle = color;
  ctx.fill();

  if (shape === 'side') {
    // わけめ: cut a real parting in the hair, so the two halves read as combed
    // apart rather than as one smooth sweep.
    const partX = cx - halfSpan * 0.45;
    const gw = Math.max(5, width * 0.02) * (0.6 + size);
    ctx.save();
    ctx.globalCompositeOperation = 'destination-out';
    ctx.beginPath();
    ctx.moveTo(partX - gw * 0.16, top);
    ctx.lineTo(partX + gw * 0.16, top);
    ctx.lineTo(partX + gw * 0.7, boundary(partX) + 4);
    ctx.lineTo(partX - gw * 0.7, boundary(partX) + 4);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
  ctx.restore();
}

/**
 * The shared extras (ほっぺ and 頭の模様) are drawn once per face, before the eyes,
 * so the eyes, brows and glasses win any overlap and the cheeks are not doubled.
 */
function drawFaceExtras(ctx, style, lineOnly) {
  drawCheeks(ctx, style.cheeks);
  // The head covering fills out to the plate's real edges, so it needs `limits`.
  drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS);
  drawBeard(ctx, style.beard, lineOnly, style.beards);
}

/**
 * ひげ: a mustache or beard under the nose. Each kind is a small shape built on
 * the spot; `scotch` and `cat` are drawn with round-capped strokes, `kaiser` is a
 * thick waving band, and `apron` is a filled bib. In a line drawing nothing is
 * filled and only the outlines are inked.
 */
function drawBeard(ctx, spec, lineOnly = false, textures = null) {
  const shape = spec?.shape ?? 'off';
  if (shape === 'off' || shape === 'none') return;
  const size = clamp(spec.size ?? 1, 0.2, 3);
  const cx = BEARD_LAYOUT.cx;
  const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600);
  const w = BEARD_LAYOUT.width * size;

  // A supplied drawing for this kind (assets/beards/<shape>.png) replaces the
  // built-in strokes. The drawings are trimmed to their ink, so `w` is the
  // beard's real width; it is centred on the nose and scaled by `size`.
  const entry = textures?.[shape];
  if (entry?.image) {
    const { image, half } = entry;
    // Slightly wider than the built-in base: a moustache drawn to the same width
    // as a beard's stroke sat mostly behind the nose.
    const drawW = w * 1.4;
    if (half) {
      // One side, mirrored to make the pair. The drawing is the model's own right
      // side, which sits in the artwork's left half; `spacing` opens the middle.
      const halfW = drawW / 2;
      const dh = (halfW * (image.height || 1)) / (image.width || 1);
      const gap = clamp(spec.spacing ?? 0, 0, 800);
      const y = cy - dh / 2;
      ctx.drawImage(image, cx - gap - halfW, y, halfW, dh);
      ctx.save();
      ctx.translate(cx + gap, cy);
      ctx.scale(-1, 1);
      // Draw into [-halfW, 0] so that, mirrored, it lands in [cx+gap, cx+gap+halfW].
      ctx.drawImage(image, -halfW, -dh / 2, halfW, dh);
      ctx.restore();
      return;
    }
    const dh = (drawW * (image.height || 1)) / (image.width || 1);
    ctx.drawImage(image, cx - drawW / 2, cy - dh / 2, drawW, dh);
    return;
  }

  const color = spec.color ?? '#3a2a4a';
  const line = spec.line ?? '#55386e';
  const lw = Math.max(2, 9 * size);
  const stroke = (points, width) => strokePolyline(ctx, points, {
    color: lineOnly ? line : color, width,
  });

  if (shape === 'scotch') {
    // ちょび髭: two short dashes under the nose, angled down and out so their tips
    // clear the nose mesh (which hides anything drawn straight behind it).
    stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
    stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8);
    return;
  }
  if (shape === 'kaiser') {
    // カイゼル: a full, thick mustache whose ends turn up.
    stroke([
      { x: cx - w * 0.5, y: cy - w * 0.02 },
      { x: cx - w * 0.28, y: cy + w * 0.07 },
      { x: cx, y: cy + w * 0.09 },
      { x: cx + w * 0.28, y: cy + w * 0.07 },
      { x: cx + w * 0.5, y: cy - w * 0.02 },
    ], lw * 2.6);
    const curlAt = (dir) => {
      // A little inward-turning spiral at the tip: the Dalí curl.
      const ex = cx + dir * w * 0.5;
      const ey = cy - w * 0.02;
      const r0 = lw * 0.5;
      const r1 = w * 0.17;
      const steps = 22;
      const points = [];
      for (let i = 0; i <= steps; i += 1) {
        const t = i / steps;
        const a = -Math.PI / 2 + dir * Math.PI * 2 * 1.4 * t;
        const r = r0 + (r1 - r0) * t;
        points.push({ x: ex + dir * Math.cos(a) * r, y: ey + Math.sin(a) * r });
      }
      stroke(points, lw * 1.25);
    };
    curlAt(-1);
    curlAt(1);
    return;
  }
  if (shape === 'apron') {
    // エプロン: a bib-shaped beard hanging from the cheeks to a rounded point.
    const pts = [
      { x: cx - w * 0.6, y: cy - w * 0.04 },
      { x: cx - w * 0.5, y: cy + w * 0.35 },
      { x: cx - w * 0.22, y: cy + w * 0.62 },
      { x: cx, y: cy + w * 0.7 },
      { x: cx + w * 0.22, y: cy + w * 0.62 },
      { x: cx + w * 0.5, y: cy + w * 0.35 },
      { x: cx + w * 0.6, y: cy - w * 0.04 },
    ];
    if (lineOnly) {
      strokePolyline(ctx, pts, { color: line, width: lw });
    } else {
      fillPolygon(ctx, pts, color);
      strokePolyline(ctx, pts, { color: line, width: lw * 0.5 });
    }
    return;
  }
  // ねこ: three whiskers a side, growing out of the cheeks with a gap left in the
  // middle (like a cat's). The thickness is fixed, so `size` makes the whiskers
  // longer and wider-spread without making them fatter, and `spacing` opens the
  // gap between the left and right.
  const whisker = 8;
  const spacing = clamp(spec.spacing ?? 0, 0, 800);
  const inner = w * 0.30 + spacing;
  // `length` scales how far each whisker reaches out; `lineGap` opens the spacing
  // between the three lines of one side. Neither changes the line's thickness.
  const length = clamp(spec.length ?? 1, 0.2, 3);
  const gap = w * 0.16 * clamp(spec.lineGap ?? 1, 0.2, 3);
  const outer = inner + w * 0.38 * length;
  for (const dir of [-1, 1]) {
    for (let i = 0; i < 3; i += 1) {
      stroke([
        { x: cx + dir * inner, y: cy + (i - 1) * gap },
        { x: cx + dir * outer, y: cy + (i - 1) * gap * 1.5 },
      ], whisker);
    }
  }
}

/**
 * One lens of a pair of 眼鏡 / サングラス, plus this eye's half of the bridge and its
 * temple.
 *
 * Called once per eye from `drawEyes`, after the eyeball (or the shut-eye artwork)
 * so the lens sits in front of the eye, and before that eye's tear so a teardrop
 * falls past the lens. Nothing about a pair of glasses is per-eye, so the two
 * halves are mirrored from `eye.towardNose` and meet at the face's midline. The
 * kind picks the shape: a round 眼鏡 lens, or a wide angular サングラス one.
 *
 * `style.glasses` is the shared spec. In a line drawing the lens is left empty, so
 * the paper behind shows through exactly as the eyeball's white does (see the note
 * in `drawEyes`); the frames are then stroked in the line colour.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {{cx:number,cy:number,r:number,towardNose:number}} eye
 * @param {boolean} line                 true in 線画 mode
 * @param {object} style                 the bag `drawEyes` received
 * @param {{top:number,bottom:number,left:number,right:number}} limits
 */
function drawGlasses(ctx, eye, line, style, limits) {
  const spec = style.glasses;
  if (!spec?.enabled) return;

  const r = eye.r;
  const scale = clamp(spec.scale ?? 1, 0.2, 3);
  // The two kinds share everything about *where* they sit but not their shape:
  // 眼鏡 is a round lens, サングラス a wide angular one (see the layouts above).
  const shades = spec.kind === 'sunglasses';
  const L = shades ? SUNGLASSES_LAYOUT : GLASSES_LAYOUT;
  const rx = r * L.widthFactor * scale;
  const ry = rx * L.heightFactor;
  const frame = Math.max(
    1,
    (spec.frameWidth ?? 1) * EYE_LAYOUT.lidStroke * (shades ? L.frameFactor : 1),
  );
  const colour = line ? (style.line ?? '#55386e') : (spec.frameColor ?? '#2a1e33');
  // The outward direction (towards the temple) for this eye. The angular lens is
  // built in a local frame whose u runs outwards, so the two sides mirror exactly.
  const out = -eye.towardNose;
  // The pointed outer corner makes the shades taller on that side; clamp against
  // the full height so even that point cannot reach the canvas border.
  const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry;

  // Keep the lens (and so everything that hangs off it) inside the artwork. The
  // rest of the file clamps against `limits` for the same reason: a mark that
  // reaches the canvas border gets the edge row copied across the whole plate.
  const lensCx = clamp(eye.cx, limits.left + EDGE_MARGIN + rx, limits.right - EDGE_MARGIN - rx);
  const lensCy = clamp(
    eye.cy + L.drop + (spec.offsetY ?? 0),
    limits.top + EDGE_MARGIN + halfV,
    limits.bottom - EDGE_MARGIN - halfV,
  );
  // The face's midline: the two eyes are symmetric about it, so it is where the
  // two halves of the bridge meet and what `tilt` turns the pair about.
  const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2;
  // Local (u, v) -> canvas, with u outwards and v downwards.
  const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v });

  ctx.save();
  // `tilt` leans the whole pair at once. Rotating about the midline keeps the
  // bridge centred between the lenses instead of swinging it off to one side.
  if (spec.tilt) {
    ctx.translate(midX, lensCy);
    ctx.rotate((clamp(spec.tilt, -90, 90) * Math.PI) / 180);
    ctx.translate(-midX, -lensCy);
  }

  // --- lens ------------------------------------------------------------
  // The one path is filled and then stroked, so the fill and the frame can never
  // drift apart.
  if (shades) {
    // A long cat-eye, not an ellipse: the outer end starts low, rises to a
    // pointed, lifted outer corner set further out, and the top edge sweeps back
    // towards the nose. The inner end is short, so the lens tapers inwards.
    const lens = [
      at(-rx, -ry * L.innerTop),                          // inner top (near the nose)
      at(rx * L.tipX, -ry * (L.innerTop + L.topSkew)),    // pointed, lifted outer corner
      at(rx * L.outerEndX, ry * L.outerBottom),           // low outer end
      at(-rx, ry * L.innerBottom),                        // inner bottom (pinched towards the nose)
    ];
    traceRoundedPolygon(ctx, lens, ry * L.corner);
    if (!line) {
      ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1);
      ctx.fillStyle = spec.lensColor ?? '#2b2433';
      ctx.fill();
      ctx.globalAlpha = 1;
    }
    ctx.strokeStyle = colour;
    ctx.lineWidth = frame;
    ctx.stroke();
  } else {
    ctx.beginPath();
    ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU);
    ctx.closePath();
    if (!line) {
      ctx.globalAlpha = clamp(spec.lensOpacity ?? 0, 0, 1);
      ctx.fillStyle = spec.lensColor ?? '#2b2433';
      ctx.fill();
      ctx.globalAlpha = 1;
    }
    ctx.strokeStyle = colour;
    ctx.lineWidth = frame;
    ctx.stroke();
  }

  // --- bridge ----------------------------------------------------------
  // Each eye draws the half of the bridge from its own lens to the midline.
  const innerX = eye.towardNose > 0
    ? Math.min(lensCx + rx, midX)
    : Math.max(lensCx - rx, midX);
  if (shades) {
    // A short, thick, level bar high on the lenses: with the two lenses it reads
    // as one continuous visor across the eyes.
    const barY = lensCy - ry * L.bridgeLift;
    strokePolyline(ctx, [{ x: innerX, y: barY }, { x: midX, y: barY }], {
      color: colour,
      width: frame * L.bridgeWidth,
    });
  } else {
    // The two halves meet at the midline with a horizontal tangent, so the join is smooth.
    const noseX = lensCy - L.bridgeRise;
    strokePolyline(ctx, quadraticPoints(
      { x: innerX, y: lensCy },
      { x: (innerX + midX) / 2, y: noseX },
      { x: midX, y: noseX },
      12,
    ), { color: colour, width: frame * L.bridgeWidth });
  }

  // --- temple ----------------------------------------------------------
  // A stub outwards from the outer edge of the lens. It stays short on purpose:
  // the plate only reaches so far, and a temple that ran off it would smear. The
  // round pair's outer edge is a vertical line at the lens centre height. The
  // shades' outer end drops low, so their arm leaves from the *top* edge instead
  // - from the pointed outer corner - which is where a cat-eye's arm attaches.
  const outerX = shades
    ? lensCx - eye.towardNose * rx * L.tipX
    : lensCx - eye.towardNose * rx;
  const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy;
  const reach = clamp(
    outerX - eye.towardNose * r * L.templeLength,
    limits.left + EDGE_MARGIN,
    limits.right - EDGE_MARGIN,
  );
  strokePolyline(ctx, [
    { x: outerX, y: outerY },
    {
      x: eye.towardNose > 0 ? Math.min(reach, outerX) : Math.max(reach, outerX),
      y: clamp(outerY - L.templeRise, limits.top + EDGE_MARGIN, limits.bottom - EDGE_MARGIN),
    },
  ], { color: colour, width: frame });

  ctx.restore();
}

/**
 * Draw the pair of eyes into a 1024 x 380 canvas.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {object} p
 * @param {'paint'|'line'} [p.mode]   paint = filled cartoon eyes, line = outline only
 * @param {object} p.eyes             `{ left, right }`, each `{ open, lookX, lookY, closed }`
 * @param {object} [p.style]          colours and shared shaping parameters
 */
export function drawEyes(ctx, p) {
  const mode = p.mode ?? 'paint';
  const line = mode === 'line';
  const style = p.style ?? {};
  const limits = style.limits ?? WINDOW_LIMITS;
  const white = style.white ?? '#ffffff';
  const irisColor = style.iris ?? '#150e1b';
  const lineColor = style.line ?? '#55386e';
  const lidWidth = style.lidWidth ?? EYE_LAYOUT.lidStroke;
  const irisScale = style.irisScale ?? 1;
  const lookMax = style.lookMax ?? 1;
  const highlightOn = style.highlight !== false;
  const heartScale = style.heartScale ?? 0.85;
  const heartColor = style.heartColor ?? '#e0344f';
  const irisRadius = EYE_LAYOUT.irisRadius * irisScale;
  const travel = Math.max(0, EYE_LAYOUT.radius - irisRadius);
  const heartTravel = EYE_LAYOUT.radius * 0.9;
  const lowerLid = clamp(style.lowerLid ?? 0, 0, 1);
  const lidFlat = (style.lidShape ?? 'curve') === 'flat';

  // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
  // rather than inside the per-eye loop below. 鼻ちょうちん comes along too.
  drawFaceExtras(ctx, style, line);

  for (const layout of EYE_LAYOUT.eyes) {
    const spec = (p.eyes ?? {})[layout.key] ?? {};
    const open = clamp(spec.open ?? 1, 0, 1);
    const eye = {
      // `eyeX` slides this eye - and its lid, brow and tear - sideways on its own.
      cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400),
      cy: layout.cy,
      r: EYE_LAYOUT.radius,
      towardNose: layout.towardNose,
    };
    const lineStyle = { line: lineColor, width: lidWidth };
    const isHeart = (spec.irisShape ?? 'circle') === 'heart';

    drawBrow(ctx, eye, spec, style, limits);

    // --- tear ----------------------------------------------------------
    // A teardrop hangs below the eye, so it is drawn on top of everything else
    // (the opaque eyeball would otherwise cover it) and for a shut eye too. The
    // amount, the height and the tilt are all *per eye*, because one eye crying
    // while the other does not is a real expression.
    const drawTear = () => {
      const amount = clamp(spec.tear ?? 0, 0, 1.6);
      if (amount <= 0.01) return;
      const size = EYE_LAYOUT.tear.size * amount;
      const tearX = eye.cx + clamp(spec.tearX ?? 0, -400, 400) - layout.towardNose * EYE_LAYOUT.tear.offsetX;
      let tearY = eye.cy + EYE_LAYOUT.tear.offsetY + (spec.tearY ?? 0);
      // The drop hangs from `cy - 1.32r` to `cy + 0.78r`. Keep its lower tip
      // inside the artwork: a drop that reached the canvas border used to be
      // smeared down the chin by the clamp.
      const tearBottom = limits.bottom - EDGE_MARGIN;
      if (tearY + size * 0.78 > tearBottom) tearY = tearBottom - size * 0.78;
      ctx.save();
      if (spec.tearTilt) {
        ctx.translate(tearX, tearY);
        ctx.rotate((spec.tearTilt * Math.PI) / 180);
        ctx.translate(-tearX, -tearY);
      }
      dropPath(ctx, tearX, tearY, size);
      if (!line) {
        ctx.fillStyle = style.tearColor ?? '#8fd8ff';
        ctx.fill();
      }
      ctx.strokeStyle = lineColor;
      ctx.lineWidth = lidWidth * 0.55;
      ctx.stroke();
      ctx.restore();
    };

    if (open <= 0.02) {
      drawShutEye(ctx, eye, spec, lineStyle);
      // The glasses sit over the shut-eye artwork too, but under the tear below.
      drawGlasses(ctx, eye, line, style, limits);
      drawTear();
      continue;
    }

    // --- eyeball -------------------------------------------------------
    ctx.save();
    // A heart is not an eyeball, so once the eye is fully open it is not clipped
    // to the eyeball circle - that is what lets a heart be much larger than the
    // iris. While the lid is partly closed the clip is still needed.
    if (!isHeart || open < 0.999) clipEye(ctx, eye, open, lowerLid * (1 - open), lidFlat);

    const eyeTravel = isHeart ? heartTravel * lookMax : travel * lookMax;
    let dx = clamp(spec.lookX ?? 0, -1, 1) * eyeTravel;
    let dy = -clamp(spec.lookY ?? 0, -1, 1) * eyeTravel;
    const dist = Math.hypot(dx, dy);
    if (dist > eyeTravel && dist > 0) {
      dx = (dx / dist) * eyeTravel;
      dy = (dy / dist) * eyeTravel;
    }

    // The eyeball's white is only painted in the shaded styles: in line art the
    // paper behind shows through, which is what a line drawing wants. (The SVG
    // export traces the alpha mask, so a white fill would close the eye up
    // completely.)
    if (!line && !(isHeart && style.heartHideWhite)) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
    const irisX = eye.cx + dx;
    const irisY = eye.cy + dy;
    if (isHeart) {
      // A heart is wider than a circle, so cap it at the plane rather than the
      // eyeball; a red heart has no pupil highlight (it would sit on top of it).
      // A heart is not an eyeball, so it may grow well past the eye: the plate
      // keeps the whole canvas, and the eyes plate is drawn over the mouth, so a
      // big heart has room. The cap only stops it from running off the canvas.
      const heartRadius = Math.min(irisRadius * heartScale, EYE_LAYOUT.radius * 2.1);
      fillHeart(ctx, irisX, irisY, heartRadius, line ? lineColor : heartColor);
    } else {
      fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor);
    }
    if (highlightOn && !isHeart) {
      const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * irisScale;
      const hy = irisY + EYE_LAYOUT.highlightOffset.y * irisScale;
      const hr = EYE_LAYOUT.highlightRadius * irisScale;
      if (line) {
        // Punch the sparkle out of the iris rather than painting it white. On
        // screen the paper behind shows through, and in the SVG export (which
        // only sees alpha) it stays a hole in the pupil - both of which read as
        // the white glint of the original artwork.
        ctx.save();
        ctx.globalCompositeOperation = 'destination-out';
        fillCircle(ctx, hx, hy, hr, '#000000');
        ctx.restore();
      } else {
        fillCircle(ctx, hx, hy, hr, white);
      }
    }
    ctx.restore();

    // Between the eyeball and the tear: the lens covers the eye, and a tear still
    // falls in front of it.
    drawGlasses(ctx, eye, line, style, limits);

    drawTear();

    // --- lid strokes ---------------------------------------------------
    // Faded in as the lid starts to cover the eye, so opening the eye all the
    // way leaves the clean original artwork with no extra line.
    const lidFade = clamp((0.95 - open) / 0.1, 0, 1);
    if (lidFade > 0.01 || lowerLid > 0.01) {
      ctx.save();
      circlePath(ctx, eye.cx, eye.cy, eye.r);
      ctx.clip();
      ctx.globalAlpha = line ? 1 : lidFade;
      ctx.strokeStyle = lineColor;
      ctx.lineWidth = lidWidth;
      if (lidFade > 0.01) {
        lidPath(ctx, eye, open, false, lidFlat);
        ctx.stroke();
      }
      if (lowerLid > 0.01) {
        ctx.globalAlpha = line ? 1 : lowerLid;
        lidPath(ctx, eye, lowerLid, true, lidFlat);
        ctx.stroke();
      }
      ctx.restore();
    }

    // --- outline (line-art mode only) ---------------------------------
    if (line) strokeCircle(ctx, eye.cx, eye.cy, eye.r, lineColor, lidWidth * 0.75);
  }
}

/* -------------------------------------------------------------------- mouth */

/**
 * Draw the mouth into a 1024 x 380 canvas.
 *
 * @param {CanvasRenderingContext2D} ctx
 * @param {object} p
 * @param {'paint'|'line'} [p.mode]
 */
export function drawMouth(ctx, p) {
  const mode = p.mode ?? 'paint';
  const line = mode === 'line';
  const L = MOUTH_LAYOUT;
  const limits = p.limits ?? WINDOW_LIMITS;
  // The artwork's real edges, in the artwork's own coordinates, with a margin
  // kept clear. `top` is negative when the plate reaches above the window, which
  // is the room a moved mouth has to work with.
  const edgeLeft = limits.left + EDGE_MARGIN;
  const edgeRight = limits.right - EDGE_MARGIN;
  const edgeTop = limits.top + EDGE_MARGIN;
  const edgeBottom = limits.bottom - EDGE_MARGIN;

  const openAmount = clamp(p.open ?? 0, 0, 1);
  const thickness = L.thickness * clamp(p.thickness ?? 1, 0.2, 3);
  const cornerAmount = clamp(p.corners ?? 1, 0, 1.6);
  // The corner strokes flick out past the ends of the chord, so the ends cannot
  // use the whole canvas: a very wide mouth used to smear sideways off the plate.
  const cornerReach = L.corner.toX * cornerAmount;
  const halfChord = Math.min(
    L.halfChord * clamp(p.width ?? 1, 0.2, 1.6),
    Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX),
  );

  // A negative smile bulges the line upwards, which reads as a frown.
  const sagRaw = L.sag * clamp(p.smile ?? 1, -0.55, 1.4);
  // A frown's middle is the highest point of the mouth, and that is exactly
  // where the nose is in the way, so slide the whole mouth down until the curve
  // is in the open. The push is worked out from the *base* position and the
  // height slider is added afterwards: adding it first made the push cancel the
  // slider exactly, which is why dragging ‟口の高さ” did nothing at all on a
  // frowning mouth like むっと.
  const base = L.chordY + Math.max(0, L.noseClear - (L.chordY + sagRaw));
  let y0 = base + (p.offsetY ?? 0);
  // The smile flattens slightly as the mouth opens, so the whole mouth keeps
  // fitting inside the band the mouth plane actually shows.
  let sag = sagRaw * (1 - L.openFlatten * openAmount);
  // Keep the whole mark on the canvas. The corner strokes sit *above* the ends of
  // the chord, so the allowance is not symmetric - padding the bottom by the
  // corner's depth used to cost most of the height slider's travel.
  const cornerRise = L.corner.toY * cornerAmount + thickness;
  const bottomEdge = y0 + Math.max(0, sag) + thickness;
  if (bottomEdge > edgeBottom) y0 -= bottomEdge - edgeBottom;
  const topEdge = y0 + Math.min(0, sag) - cornerRise;
  if (topEdge < edgeTop) y0 += edgeTop - topEdge;
  const pivot = { x: L.centreX, y: y0 + sag * 0.5 };
  const tilt = p.tilt ?? 0;

  const strokeColor = line ? (p.line ?? '#3a2a4a') : (p.color ?? '#ff1a44');
  const innerColor = p.innerColor ?? '#4a0f1e';
  const tongueColor = line ? (p.line ?? '#3a2a4a') : (p.tongueColor ?? '#ff2d2d');

  const left = { x: L.centreX - halfChord, y: y0 };
  const right = { x: L.centreX + halfChord, y: y0 };
  const lip = mouthControls(left, right, sag, L.sagBend);
  const arc = cubicPoints(lip.p0, lip.p1, lip.p2, lip.p3, 96);

  const tongueAmount = clamp(p.tongue ?? 1, 0, 1.6);

  // --- a round "O" mouth (surprised, singing) ---------------------------
  const roundAmount = clamp(p.round ?? 0, 0, 1);
  if (roundAmount > 0.004) {
    let ry = L.sag * 0.42 * roundAmount * clamp(p.thickness ?? 1, 0.5, 2);
    // The nose hides the middle of the upper face, so keep the whole oval below
    // it and inside the plate. (`L.safeBottom` is only the round mouth's own size
    // limit; it no longer has to stay clear of the canvas edge.)
    ry = Math.min(ry, (L.safeBottom - thickness / 2 - L.noseClear) / 2.1);
    const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 0.8);
    const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - thickness / 2 - ry * 1.02);
    if (ry > 5) {
      ctx.save();
      ctx.translate(pivot.x, pivot.y);
      ctx.rotate((tilt * Math.PI) / 180);
      ctx.translate(-pivot.x, -pivot.y);
      ctx.beginPath();
      ctx.ellipse(L.centreX, centreY, rx, ry, 0, 0, TAU);
      if (!line) {
        ctx.fillStyle = innerColor;
        ctx.fill();
        if (tongueAmount > 0.01) {
          ctx.beginPath();
          ctx.ellipse(L.centreX, centreY + ry * 0.46, rx * 0.52, ry * 0.4, 0, 0, TAU);
          ctx.fillStyle = tongueColor;
          ctx.fill();
        }
      }
      ctx.strokeStyle = strokeColor;
      ctx.lineWidth = line ? thickness * 0.8 : thickness;
      ctx.stroke();
      ctx.restore();
    }
    return;
  }

  // The smile line is the upper lip. Opening the mouth drops the lower jaw
  // below it, and the room for that is limited by the texture band the mouth
  // plane shows - otherwise the jaw is silently clipped away.
  // Everything has to fit in the part of the plane that faces the camera.
  let rise = L.openRise * openAmount;
  const budget = edgeBottom - thickness / 2 - y0;
  if (sag + rise > budget) {
    rise = Math.max(0, budget - Math.min(sag, budget));
    if (sag + rise > budget) sag = Math.max(-80, budget - rise);
  }
  const jawCubic = mouthControls(left, right, sag + rise, L.sagBend);
  const jaw = cubicPoints(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, 96);
  const jawAt = (t) => cubicPoint(jawCubic.p0, jawCubic.p1, jawCubic.p2, jawCubic.p3, t);
  const lipAt = (t) => cubicPoint(lip.p0, lip.p1, lip.p2, lip.p3, t);
  const openShape = rotate([...arc, ...jaw.slice(1, -1).reverse()], pivot, tilt);

  const tonguePos = clamp(p.tonguePos ?? L.tongue.pos, 0.05, 0.95);
  const tongueWidth = L.tongue.width * tongueAmount;
  const tongueHeight = L.tongue.height * tongueAmount;

  /**
   * The tongue, rising from `curve` to `height` above its middle.
   *
   * `pointAt(t)` is that same curve as a function of t, so the tongue's foot can
   * sit along it without this needing to know what kind of curve it is.
   */
  function drawTongue(pointAt, curve, height) {
    const arcLength = Math.max(1, chordLength(curve));
    const dt = clamp(tongueWidth / 2 / arcLength, 0.01, 0.45);
    const t0 = clamp(tonguePos - dt, 0, 1);
    const t1 = clamp(tonguePos + dt, 0, 1);
    const base = [];
    for (let i = 0; i <= 16; i++) base.push(pointAt(t0 + ((t1 - t0) * i) / 16));
    const mid = pointAt(clamp(tonguePos, 0, 1));
    const half = tongueWidth / 2;
    // The foot runs left to right; the crown comes back right to left, so the two
    // together are already a closed ring.
    const footRight = base[base.length - 1];
    const footLeft = base[0];
    const crown = [];
    const steps = 26;
    for (let i = 0; i <= steps; i++) {
      const f = i / steps;
      const dx = 1 - 2 * f;
      const footY = footRight.y + (footLeft.y - footRight.y) * f;
      crown.push({
        x: mid.x + dx * half,
        y: footY - height * (1 - Math.pow(Math.abs(dx), L.tongue.crown)),
      });
    }
    const shaped = rotate([...base, ...crown], pivot, tilt);
    if (line) {
      strokePolyline(ctx, shaped, { color: strokeColor, width: thickness * 0.8, closed: true });
      return;
    }
    tracePolyline(ctx, shaped, true);
    ctx.fillStyle = tongueColor;
    ctx.fill();
  }

  if (openAmount > 0.004) {
    if (!line) fillPolygon(ctx, openShape, innerColor);
    // The tongue rises from the lower lip *into* the mouth, so it has to arrive
    // with the opening: at a hair's width the jaw curve is a sliver, and the
    // tongue used to burst straight out of it and sit on the chin.
    const tongueOpen = clamp((openAmount - 0.06) / 0.24, 0, 1);
    if (tongueAmount > 0.01 && tongueOpen > 0.01) {
      ctx.save();
      tracePolyline(ctx, openShape, true);
      ctx.clip();
      // Inside an open mouth the tongue sits on the lower jaw.
      drawTongue(jawAt, jaw, tongueHeight * tongueOpen * (1 + openAmount * 0.3));
      ctx.restore();
    }
    // One outline around the whole mouth reads as lips; the smile stroke alone
    // would leave the lower edge as a bare colour change.
    strokePolyline(ctx, openShape, { color: strokeColor, width: thickness, closed: true });
  } else {
    // Closed lips - but the tongue still pokes over the lip. That is what the
    // original artwork does (the mouth reads as a smile with the tongue showing,
    // like ペコちゃん), and it is why a plain line looked wrong there. The lip
    // line goes on afterwards, so the tongue comes out from under it.
    if (tongueAmount > 0.01) {
      drawTongue(lipAt, arc, tongueHeight);
    }
    strokePolyline(ctx, rotate(arc, pivot, tilt), { color: strokeColor, width: thickness });
  }

  // --- corner marks ---------------------------------------------------
  if (cornerAmount > 0.01) {
    const C = L.corner;
    const cornerAngle = p.cornerAngle ?? 0;
    for (const [end, side] of [[left, -1], [right, 1]]) {
      const from = { x: end.x + C.fromX * side * cornerAmount, y: end.y - C.fromY * cornerAmount };
      const to = { x: end.x - C.toX * side * cornerAmount, y: end.y - C.toY * cornerAmount };
      // A smooth curve, not a three-point kink. `mid` is the control point, so it
      // is pulled twice as far as the bow should reach; both corners bow the same
      // way (downwards on screen), which is what the original artwork does.
      const control = {
        x: (from.x + to.x) / 2,
        y: (from.y + to.y) / 2 + (C.curve ?? 26) * cornerAmount,
      };
      const curve = quadraticPoints(from, control, to, 16);
      // `cornerAngle` tilts the whole mark around the mouth corner, mirrored so
      // both sides move together.
      const shaped = cornerAngle ? rotate(curve, end, cornerAngle * side) : curve;
      strokePolyline(ctx, rotate(shaped, pivot, tilt), {
        color: line ? strokeColor : (p.cornerColor ?? '#725497'),
        // The *length* of the corner mark follows `corners`, but its thickness
        // does not: `corners: 0.71` is the length that matches the original, and
        // the original's stroke is the full thickness.
        width: C.width,
      });
    }
  }
}

/** Approximate length of a polyline. */
function chordLength(points) {
  let total = 0;
  for (let i = 1; i < points.length; i++) {
    total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
  }
  return total;
}