aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio/src/faceArt.js
diff options
context:
space:
mode:
Diffstat (limited to 'public/bluebey-studio/src/faceArt.js')
-rw-r--r--public/bluebey-studio/src/faceArt.js1855
1 files changed, 0 insertions, 1855 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
deleted file mode 100644
index 8537fd8..0000000
--- a/public/bluebey-studio/src/faceArt.js
+++ /dev/null
@@ -1,1855 +0,0 @@
-/**
- * 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 few strokes flicking out from the upper-outer lid (see drawEyes).
- // `raise` lifts them a touch above the lid edge (2% of the lid slider).
- lash: { length: 30, width: 8, angles: [46, 66, 86], raise: 0.04 },
- // "ふつう"の目の瞳を、両目とも少し鼻側に寄せてかわいく見せる(px)。
- irisInward: 7,
-};
-
-/**
- * ほっぺ (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`
-};
-
-/**
- * 頭の模様 on the *hair plate*: the head's cover when the model carries a plate
- * that wraps all the way round (material `hair-plate`, cylindrical UV).
- *
- * The front plate only reaches the head's silhouette, so hair drawn on it stops
- * at the front half. The hair plate's UV is `u` = angle round the head (front at
- * 0.5, back at the seam 0/1) and `v` = height (0 at the model's feet, 1 at the
- * top of the head), so the covering is everything *above* a boundary line: a cap
- * that is visible from behind as well.
- */
-export const HAIR_WRAP_LAYOUT = {
- // The wrap's v runs top-of-head (0) to the model's feet (1) after the GLB's
- // V-flip, so `base` is how far down from the top the hairline sits at size 1.
- // `reach` is how much a unit of `size` moves it; `offsetScale` converts the
- // `offsetY` slider (artwork px) to the same units, so one slider drives both.
- base: 0.30,
- reach: 0.30,
- offsetScale: 0.30 / 190,
-};
-
-/**
- * ひげ: 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,
- // How big the round "O" oval gets at `round: 1`, as a multiple of the smile's
- // own sag. The oval's size comes from this and `round` alone: `thickness` is
- // only the stroke weight, so 口の太さ and 丸く開く no longer move together.
- roundScale: 1.3,
- // 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.
- */
-/** Clip to the upper and lower lids only, without the eyeball circle. */
-function clipLids(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) {
- const tilt = (clamp(tiltDeg, -45, 45) * Math.PI) / 180;
- const rotated = Math.abs(tilt) > 1e-4;
- const spin = () => {
- ctx.translate(eye.cx, eye.cy);
- ctx.rotate(tilt);
- ctx.translate(-eye.cx, -eye.cy);
- };
- if (rotated) spin();
-
- const lower = clamp(lowerLid, 0, 1);
- // The upper and lower lids are independent: 上まぶたの高さ sets where the upper
- // lid sits on its own, so raising 下まぶたの高さ does not drag the upper lid
- // down with it. A blink still shuts the eye because `open` reaching 0 (or the
- // upper lid dropping to the lower one) hands over to the shut-eye drawing
- // before this clip is used.
- 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 = lower;
- 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();
- }
-
- if (rotated) {
- // Undo the rotation for the caller, but keep the clip we just set.
- ctx.translate(eye.cx, eye.cy);
- ctx.rotate(-tilt);
- ctx.translate(-eye.cx, -eye.cy);
- }
-}
-
-/** Clip to the eyeball circle as well (the iris and heart are eyeball content). */
-function clipEye(ctx, eye, open, lowerLid, flat = false, tiltDeg = 0) {
- circlePath(ctx, eye.cx, eye.cy, eye.r);
- ctx.clip();
- clipLids(ctx, eye, open, lowerLid, flat, tiltDeg);
-}
-
-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, shapeScale = 1, maxHalf = Infinity) {
- const layout = EYE_LAYOUT.closedLine;
- const { line, width } = style;
- // 形の大きさ is a multiplier on the shape's *fitting* size. With the white shown
- // that fitting size is what just fits inside the eyeball, so the default (1)
- // sits in the white; raising it may push the shape out past the white, which is
- // allowed. `maxHalf` is Infinity when the white is hidden, so nothing is scaled
- // back there.
- const k = (unitHalf) => shapeScale * (maxHalf === Infinity ? 1 : Math.min(1, maxHalf / unitHalf));
-
- if (spec.closed === 'chevron') {
- // The chevron points *at* the nose, matching the original artwork.
- const scale = k(layout.armX);
- const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale };
- const armX = eye.cx - eye.towardNose * layout.armX * scale;
- const spread = layout.spread * (spec.spread ?? 1) * scale;
- 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.
- const r = 34 * k(1.7 * 34);
- // 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 scale = k(eye.r);
- const edges = { x: eye.r * scale, y: EYE_LAYOUT.arch.edgesUp * scale };
- 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 * scale },
- { 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);
- const scale = k(layout.length / 2);
- if (count >= 2) {
- const vertex = { x: eye.cx + eye.towardNose * layout.vertex * scale, y: eye.cy + layout.offsetY * scale - 8 * scale };
- // 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 * scale;
- const spread = layout.spread * scale;
- const arms = count === 2
- ? [{ x: outX, y: -spread }, { x: outX, y: spread }]
- : [
- { x: outX, y: -spread },
- { x: -eye.towardNose * Math.hypot(layout.armSpan, layout.spread) * scale, y: 0 },
- { x: outX, y: 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) * scale;
- const midX = eye.cx - eye.towardNose * layout.offsetX * scale;
- const midY = eye.cy + layout.offsetY * scale;
- const angle = ((layout.slantDeg * (spec.slant ?? 1) * eye.towardNose) * Math.PI) / 180;
- const bow = (layout.bow ?? 0) * (spec.slant ?? 1) * scale;
- 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));
-
- // A supplied drawing (assets/brows/gol-right.png, the model's right brow only)
- // replaces the strokes: like the textured ひげ it is drawn twice, mirrored, at
- // `cx`, and tinted with the brow colour.
- const entry = style.brows?.gol;
- if (brow.image && entry?.image) {
- const sprite = tintedSprite(entry.image, brow.color ?? '#2a1e33');
- const sw = sprite.width || 1;
- const sh = sprite.height || 1;
- const drawW = Math.max(4, length);
- const drawH = drawW * (sh / sw);
- ctx.save();
- ctx.translate(cx, cy);
- // The drawing is the model's right brow, so the other eye gets it mirrored.
- ctx.scale(eye.towardNose >= 0 ? 1 : -1, 1);
- ctx.drawImage(sprite, -drawW / 2, -drawH / 2, drawW, drawH);
- ctx.restore();
- return;
- }
- 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' || shape === 'wave' || shape === 'side') 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);
- // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front
- // gives the teeth; `teeth` is how far they reach down (its own slider), so
- // the 大きさ slider only moves the hairline up and down.
- if (shape === 'fringe') {
- const teeth = 8;
- const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle
- return base + (spec.teeth ?? 0.12) * 600 * saw;
- }
- // はちわれ: the same V upside down - a widow's peak.
- if (shape === 'fringe-up') return base - 320 * Math.max(0, 1 - Math.abs(t) * 1.5);
- // カーブ: a plain shallow curve, higher (more hair) round the sides.
- if (shape === 'curve') return base + 160 * t * 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();
- ctx.restore();
-}
-
-/**
- * 頭の模様 drawn onto the hair plate, which wraps right round the head.
- *
- * The plate's canvas is the cylindrical UV unrolled: `x` is the angle (front at
- * the middle, the seam at the back at both ends) and `y` is the height. The GLB
- * flips V on export, so the top of the head lands at the top of the canvas and
- * the covering is everything above the boundary line - the same direction as
- * `drawHeadCover`, which fills down from the top edge of the front plate.
- *
- * The same `shape` names are reused, with their front-to-back modulation tied to
- * how far the column leans towards the front (`t`).
- */
-export function drawHeadCoverWrap(ctx, spec, layout) {
- const shape = spec?.shape ?? 'off';
- // `split`, `wave` and `side` are retired front-plate shapes; they have no wrap
- // equal. An old saved state may still carry one, so it simply draws no hair.
- if (shape === 'off' || shape === 'none' || shape === 'split' || shape === 'wave' || shape === 'side') return;
-
- const size = clamp(spec.size ?? 1, 0.02, 3);
- const W = layout.width;
- const H = layout.height;
- // The horizontal axis is the angle. The *front* sits at u = 0.5, which is the
- // middle of the artwork window (1024), not the middle of the canvas: after the
- // seam heal the canvas is wider than the window, so using W/2 put the pattern
- // a good way off centre.
- const span = (layout.window ?? ART_WINDOW).width;
- // After the GLB's V-flip the wrap's v is 0 at the top of the head and 1 at the
- // model's feet, so a *larger* boundary means more hair (it reaches further
- // down) - the same direction as `size` and `offsetY` on the front plate.
- const base = HAIR_WRAP_LAYOUT.base
- + (size - 1) * HAIR_WRAP_LAYOUT.reach
- + clamp(spec.offsetY ?? 0, -600, 600) * HAIR_WRAP_LAYOUT.offsetScale;
-
- ctx.save();
- ctx.beginPath();
- ctx.rect(0, 0, W, H);
- ctx.clip();
-
- // `t` is -1..1 across the front (+-90 deg around the front); everything behind
- // is pinned to +-1, so the hairline runs flat round the back of the head.
- const half = span * 0.25;
- const cx = layout.offsetX + span * 0.5 + clamp(spec.offsetX ?? 0, -600, 600);
- const vBound = (x) => {
- const t = clamp((x - cx) / half, -1, 1);
- // まえがみ: 富士額 / M字 (a widow's peak). The hairline comes down at the
- // centre (the peak) and at the temples, with the corners between receded -
- // the three peaks and two valleys of an "M". `t` is -1..1 across the front.
- if (shape === 'fringe') {
- // ぎざぎざ: a jagged, saw-toothed hairline. A triangle wave across the front
- // gives the teeth; `teeth` is how far they reach down (its own slider), so
- // the 大きさ slider only moves the hairline up and down.
- const teeth = 8;
- const saw = Math.abs((((t * teeth) % 2) + 2) % 2 - 1); // 0..1 triangle
- return base + (spec.teeth ?? 0.12) * saw;
- }
- // はちわれ: the same V upside down - a widow's peak.
- if (shape === 'fringe-up') return base - 0.18 * Math.max(0, 1 - Math.abs(t) * 1.5);
- // カーブ: a deep round cover - low at the sides, high in the middle, like the
- // blue of a certain robot cat's head.
- if (shape === 'curve') return base + 0.42 * t * t;
- return base - 0.07 * (1 - t * t); // `cap`
- };
-
- const steps = Math.max(96, Math.ceil(W / 2));
- ctx.beginPath();
- ctx.moveTo(0, 0);
- for (let i = 0; i <= steps; i += 1) {
- const x = (W * i) / steps;
- ctx.lineTo(x, clamp(vBound(x), 0, 1) * H);
- }
- ctx.lineTo(W, 0);
- ctx.closePath();
- // A white mask: the colour is applied by the hair plate's material, so it goes
- // through the same colour path as the body instead of being baked into the
- // canvas (which shaded differently on iOS).
- ctx.fillStyle = '#ffffff';
- ctx.fill();
- 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);
-}
-
-const BEARD_TINT_IDS = new WeakMap();
-const BEARD_TINT_CACHE = new Map();
-let beardTintSeq = 0;
-
-/**
- * A copy of a beard drawing in the chosen colour.
- *
- * The supplied files are drawn in near-black, so repainting the ink with the
- * beard colour (`source-in` keeps the anti-aliased alpha) is what lets the colour
- * picker work on a drawing. Cached per (sprite, colour).
- */
-function tintedSprite(image, color) {
- let id = BEARD_TINT_IDS.get(image);
- if (!id) {
- beardTintSeq += 1;
- id = beardTintSeq;
- BEARD_TINT_IDS.set(image, id);
- }
- const key = `${id}|${color}`;
- const cached = BEARD_TINT_CACHE.get(key);
- if (cached) return cached;
- const canvas = document.createElement('canvas');
- canvas.width = image.width;
- canvas.height = image.height;
- const ctx = canvas.getContext('2d');
- ctx.drawImage(image, 0, 0);
- ctx.globalCompositeOperation = 'source-in';
- ctx.fillStyle = color;
- ctx.fillRect(0, 0, canvas.width, canvas.height);
- BEARD_TINT_CACHE.set(key, canvas);
- return canvas;
-}
-
-/**
- * ひげ: 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, and `kaiser`
- * is a thick waving band with a curl at each tip. A kind that ships a drawing in
- * `assets/beards/` is drawn from that instead (see `textures`), tinted with the
- * beard colour. 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;
- const color = spec.color ?? '#3a2a4a';
-
- // 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`, and it is
- // tinted with the beard colour (the files are drawn in near-black).
- const entry = textures?.[shape];
- if (entry?.image) {
- const sprite = tintedSprite(entry.image, color);
- // 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 (entry.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
- // (a negative value brings the two halves together).
- const halfW = drawW / 2;
- const dh = (halfW * (sprite.height || 1)) / (sprite.width || 1);
- const gap = clamp(spec.spacing ?? 0, -400, 800);
- const y = cy - dh / 2;
- ctx.drawImage(sprite, 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(sprite, -halfW, -dh / 2, halfW, dh);
- ctx.restore();
- return;
- }
- const dh = (drawW * (sprite.height || 1)) / (sprite.width || 1);
- ctx.drawImage(sprite, cx - drawW / 2, cy - dh / 2, drawW, dh);
- return;
- }
-
- 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 filled bib was once a kind here; kept as a no-op so a saved state that
- // still names it simply shows nothing rather than throwing.
- 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;
-
- // 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;
- // `lensGap` slides the two lenses apart (positive) or together (negative) along
- // the face, symmetrically about the midline. It is clamped so the inner edges may
- // meet at the midline but the two lenses can never cross each other.
- const inwardRoom = Math.max(0, Math.abs(midX - eye.cx) - rx);
- const gap = clamp(spec.lensGap ?? 0, -inwardRoom, HUGE);
- // 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 + out * gap,
- 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,
- );
- // 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
- */
-/**
- * Which artwork an eye shows: 'open', 'heart', 'line1'..'line3', 'three' or
- * 'arch'. `shape` is the explicit choice; older saved states and the おまかせ
- * rolls only set `open`/`closed`/`irisShape`, so this falls back to those.
- */
-function eyeShapeKey(spec) {
- const shape = spec.shape;
- const explicitShut = shape === 'three' || shape === 'arch'
- || (typeof shape === 'string' && shape.startsWith('line'));
- // An explicit shut artwork is used as-is; the lid height only trims it.
- if (explicitShut) return shape;
- // A lowered lid on an open or heart eye reads as a shut line - a blink. It must
- // NOT borrow a 3 or an arch from a stale `closed`, which used to flash on screen
- // for a frame when blinking after picking 3/わらう and then switching to heart.
- if ((spec.open ?? 1) <= 0.02) {
- if (!shape) {
- // Older saved states have no `shape`; derive the shut artwork from `closed`.
- if (spec.closed === 'three') return 'three';
- if (spec.closed === 'arch') return 'arch';
- return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`;
- }
- return 'line1';
- }
- if (shape) return shape;
- return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
-}
-
-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 lookMax = style.lookMax ?? 1;
- const heartScale = 2;
- const heartColor = style.heartColor ?? '#e0344f';
- // A lens that is effectively opaque hides the eye completely, so a blink would
- // show only as a flicker of the shut artwork through the lens. While such a
- // lens is on, the eyes are drawn open and no blink is visible at all. This is a
- // rendering decision, not an animation one - the blink still runs underneath.
- const lensOpaque = style.glasses?.enabled === true
- && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99;
-
- // ほっぺ 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 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,
- };
- // Each lid can be set per eye, falling back to the shared value.
- const lidWidth = spec.lidWidth ?? style.lidWidth ?? EYE_LAYOUT.lidStroke;
- const lidFlat = (spec.lidShape ?? style.lidShape ?? 'curve') === 'flat';
- // How much the whole narrowed eye is tilted (e.g. an angry or gentle squint).
- const lidTilt = clamp(spec.lidTilt ?? style.lidTilt ?? 0, -45, 45);
- const lineStyle = { line: lineColor, width: lidWidth };
- // Which artwork this eye shows. `shape` is the explicit choice; older saved
- // states and the おまかせ rolls only set `open`/`closed`, so fall back.
- const shapeKey = eyeShapeKey(lensOpaque ? { ...spec, open: 1 } : spec);
- const explicitShut = typeof spec.shape === 'string'
- && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line'));
- const isHeart = shapeKey === 'heart';
- const isShutShape = shapeKey === 'three' || shapeKey === 'arch' || shapeKey.startsWith('line');
- // The shut artwork reads `closed`/`closedLines`; build them from `shape` so a
- // line, the 3 and the arch share one path with the older fields.
- const shapeSpec = isShutShape
- ? {
- ...spec,
- closed: shapeKey === 'three' ? 'three' : (shapeKey === 'arch' ? 'arch' : 'line'),
- closedLines: shapeKey.startsWith('line') ? Number(shapeKey.slice(4)) : (spec.closedLines ?? 1),
- }
- : spec;
- // The upper lid (`open`) and the lower lid are independent, and both shape
- // every eye now: the shut artwork is drawn as the eye's content and is
- // trimmed by them. A shut eye saved the old way stored open = 0 meaning
- // "shut"; lift it or the artwork would be clipped away. An explicit shut
- // `shape` keeps its own lid height, so the lid can be lowered onto it.
- const rawOpen = spec.open ?? 1;
- const open = lensOpaque
- ? 1
- : clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1);
- const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1);
- // 大きさ: one knob for the iris, the heart and the shut artwork.
- const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2);
- const irisRadius = EYE_LAYOUT.irisRadius * shapeScale;
- // `white` is tri-state: only the plain open eye shows it by default.
- const showWhite = spec.white != null ? spec.white === true : shapeKey === 'open';
- // While the white is shown, a shape that would poke out of the eyeball is
- // scaled back inside it; `shapeHalfLimit` is the half-size it may reach.
- const shapeHalfLimit = showWhite ? eye.r * 0.92 : Infinity;
-
- 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 = () => {
- if (spec.tearOn !== true) return;
- const amount = clamp(spec.tear ?? 0.3, 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();
- };
-
- // How far the gaze can push the content. A heart (or a shut line shown on the
- // white) is much bigger than the iris, so it gets a smaller travel - and with
- // the white shown the content is also clipped to the eyeball - so it never
- // pokes outside the white.
- const heartFit = isHeart && showWhite
- ? Math.min(1, shapeHalfLimit / (EYE_LAYOUT.irisRadius * heartScale))
- : 1;
- const heartRadius = Math.min(
- EYE_LAYOUT.irisRadius * heartScale * shapeScale * heartFit,
- EYE_LAYOUT.radius * 2.1,
- );
- let travelBase = irisRadius;
- if (isHeart) travelBase = heartRadius;
- const eyeTravel = Math.max(0, EYE_LAYOUT.radius - travelBase) * 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 eye's content, clipped by the lids -------------------------
- // Every shape is drawn the same way now: a white backing, then the artwork
- // (iris, heart or the shut line/3/arch), trimmed by the upper and lower lids.
- // The shut artwork and a heart both reach past the eyeball, so they are only
- // lid-clipped - but once the white is shown they are clipped to the eyeball
- // too, so they stay inside it. Clipping the heart by its *lids* rather than by
- // the eyeball is also what lets a raised lower lid cover it from below: the
- // heart replaces the eyeball, but it must still sit behind both lids.
- ctx.save();
- if ((isShutShape || isHeart) && !showWhite) {
- clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
- } else if (!isHeart || open < 0.999 || showWhite) {
- clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
- }
- if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
- if (isShutShape) {
- ctx.translate(dx, dy);
- drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit);
- } else if (isHeart) {
- fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor);
- } else {
- const irisX = eye.cx + dx + eye.towardNose * EYE_LAYOUT.irisInward;
- const irisY = eye.cy + dy;
- fillCircle(ctx, irisX, irisY, irisRadius, line ? lineColor : irisColor);
- // 光彩 (the white glint) is per eye, falling back to the shared switch.
- const highlightOn = spec.highlight != null ? spec.highlight === true : style.highlight !== false;
- if (highlightOn) {
- const hx = irisX + layout.towardNose * EYE_LAYOUT.highlightOffset.x * shapeScale;
- const hy = irisY + EYE_LAYOUT.highlightOffset.y * shapeScale;
- const hr = EYE_LAYOUT.highlightRadius * shapeScale;
- 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.
- 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);
- // The tilt is mirrored between the eyes, so a positive value reads the same
- // way on both (an inward, angry-looking squint).
- const eyeTilt = lidTilt * eye.towardNose;
- // The upper lid line has to sit where the clip put it - and that is set by
- // `open` alone, so it stays put when the lower lid moves.
- const upperAmt = clamp(open, 0, 1);
- // A heart is a replacement for the eyeball, not an eye behind a lid: it keeps
- // no lower-lid line, which used to cut across the heart.
- const showLowerLid = lowerLid > 0.01;
- if (lidFade > 0.01 || showLowerLid) {
- ctx.save();
- if (eyeTilt) {
- ctx.translate(eye.cx, eye.cy);
- ctx.rotate((eyeTilt * Math.PI) / 180);
- ctx.translate(-eye.cx, -eye.cy);
- }
- 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, upperAmt, false, lidFlat);
- ctx.stroke();
- }
- if (showLowerLid) {
- ctx.globalAlpha = line ? 1 : lowerLid;
- lidPath(ctx, eye, lowerLid, true, lidFlat);
- ctx.stroke();
- }
- ctx.restore();
- }
-
- // --- まつげ --------------------------------------------------------
- // Short strokes flicking outwards from the upper-outer lid. They sit outside
- // the eyeball, so they are drawn after the lid block and are not clipped.
- if (spec.lashes ?? style.lashes) {
- // The lashes ride the upper lid: they drop as the lid lowers (2r per unit of
- // open) and tilt with まぶたの傾き, so they stay on the lid edge. まつげの位置
- // slides them along the lid, まつげの角度 tilts the strokes themselves.
- const outDir = -eye.towardNose;
- const lidDrop = 2 * eye.r * (1 - open);
- const lashPos = clamp(spec.lashPos ?? style.lashPos ?? 0, -80, 80);
- const lashAngle = clamp(spec.lashAngle ?? style.lashAngle ?? 0, -80, 80) * outDir;
- const a = (lashAngle * Math.PI) / 180;
- const ca = Math.cos(a);
- const sa = Math.sin(a);
- ctx.save();
- if (eyeTilt) {
- ctx.translate(eye.cx, eye.cy);
- ctx.rotate((eyeTilt * Math.PI) / 180);
- ctx.translate(-eye.cx, -eye.cy);
- }
- for (const deg of EYE_LAYOUT.lash.angles) {
- const t = ((deg + lashPos) * Math.PI) / 180;
- const ux = outDir * Math.cos(t);
- const uy = -Math.sin(t);
- // The stroke points along the radial direction, tilted by まつげの角度.
- const dx = ux * ca - uy * sa;
- const dy = ux * sa + uy * ca;
- const r0 = eye.r * 0.96;
- const bx = eye.cx + ux * r0;
- const by = eye.cy + uy * r0 + lidDrop - eye.r * EYE_LAYOUT.lash.raise;
- strokePolyline(ctx, [
- { x: bx, y: by },
- { x: bx + dx * EYE_LAYOUT.lash.length, y: by + dy * EYE_LAYOUT.lash.length },
- ], { color: lineColor, width: EYE_LAYOUT.lash.width });
- }
- 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 * 1.05 * L.roundScale * roundAmount;
- // Keep the oval below the nose and inside the plate, but allow it to grow to
- // roughly half the head. The stroke's share is a fixed margin, not the live
- // `thickness`: otherwise 口の太さ would quietly resize the oval too.
- ry = Math.min(ry, (edgeBottom - L.thickness / 2 - L.noseClear) / 2.1);
- const rx = Math.min(ry * 1.15 * clamp(p.width ?? 1, 0.2, 1.6), L.halfChord * 1.4);
- const centreY = clamp(y0 + sag * 0.5, L.noseClear + ry * 1.02, edgeBottom - L.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;
-}