aboutsummaryrefslogtreecommitdiffhomepage
path: root/bluebey-studio/src/faceArt.js
diff options
context:
space:
mode:
Diffstat (limited to 'bluebey-studio/src/faceArt.js')
-rw-r--r--bluebey-studio/src/faceArt.js576
1 files changed, 2 insertions, 574 deletions
diff --git a/bluebey-studio/src/faceArt.js b/bluebey-studio/src/faceArt.js
index 8537fd8..4aaa2d6 100644
--- a/bluebey-studio/src/faceArt.js
+++ b/bluebey-studio/src/faceArt.js
@@ -98,16 +98,6 @@ export const EYE_LAYOUT = {
],
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 },
@@ -137,90 +127,13 @@ export const EYE_LAYOUT = {
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.
+ * covers.
*
* 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
@@ -679,489 +592,10 @@ function drawShutEye(ctx, eye, spec, style, shapeScale = 1, maxHalf = Infinity)
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
@@ -1382,14 +816,10 @@ export function drawEyes(ctx, p) {
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.
+ // `eyeX` slides this eye - and its lid and tear - sideways on its own.
cx: layout.cx + clamp(spec.eyeX ?? 0, -400, 400),
cy: layout.cy,
r: EYE_LAYOUT.radius,
@@ -1436,8 +866,6 @@ export function drawEyes(ctx, p) {
// 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