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.js245
1 files changed, 154 insertions, 91 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 65205fa..61bfa0d 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -147,17 +147,34 @@ 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.62, // half-height, as a fraction of the half-width
- lines: 4, // hatch strokes across the patch
- lineWidth: 5,
- slant: 58, // hatching angle, degrees
+ 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 = {
- cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline
- cy: 92, // up on the forehead, above the brows
- size: 46, // nominal radius of a shape
- lineWidth: 9,
+ // 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`
};
/**
@@ -212,8 +229,9 @@ const SUNGLASSES_LAYOUT = {
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,
- frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke
- highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width
+ // 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
@@ -666,31 +684,50 @@ function drawCheek(ctx, cx, cy, spec) {
ctx.ellipse(cx, cy, r, ry, 0, 0, TAU);
ctx.fillStyle = spec.color ?? '#f6a6b8';
ctx.fill();
- // Clip the hatching to the patch, so the strokes keep a clean edge.
+ // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge.
ctx.clip();
- const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines));
- if (lines > 0) {
- const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180;
- const dx = Math.cos(angle);
- const dy = -Math.sin(angle);
- const reach = r * 1.3;
- ctx.strokeStyle = spec.hatchColor ?? '#e0708f';
- ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size);
- ctx.lineCap = 'round';
- for (let i = 0; i < lines; i += 1) {
- // Spread the strokes evenly across the patch.
- const t = lines === 1 ? 0 : i / (lines - 1) - 0.5;
- const px = cx + t * r * 1.2;
- ctx.beginPath();
- ctx.moveTo(px - dx * reach, cy - dy * reach);
- ctx.lineTo(px + dx * reach, cy + dy * reach);
- ctx.stroke();
- }
+ // 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;
@@ -704,60 +741,97 @@ function drawCheeks(ctx, spec) {
}
/**
- * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of
- * shapes on purpose - the point is a generic manga device, not a particular
- * character's artwork - and it starts off.
+ * 頭の模様: 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 drawHeadMark(ctx, spec) {
- const shape = spec.shape ?? 'off';
+function drawHeadCover(ctx, spec, limits) {
+ const shape = spec?.shape ?? 'off';
if (shape === 'off' || shape === 'none') return;
+
const size = clamp(spec.size ?? 1, 0.2, 3);
- const s = HEAD_MARK_LAYOUT.size * size;
- const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400);
- const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400);
+ 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();
- ctx.strokeStyle = spec.color ?? '#8a4fe0';
- ctx.fillStyle = spec.color ?? '#8a4fe0';
- ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size);
- ctx.lineCap = 'round';
- ctx.lineJoin = 'round';
+ // 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 === 'hook') {
- // A short curved hook, like a comma.
- ctx.beginPath();
- ctx.moveTo(cx - s * 1.05, cy + s * 0.45);
- ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15);
- ctx.stroke();
- } else if (shape === 'spiral') {
- const steps = 60;
- ctx.beginPath();
- for (let i = 0; i <= steps; i += 1) {
- const t = i / steps;
- const angle = t * TAU * 2.2;
- const radius = s * (0.14 + 0.86 * t);
- const x = cx + Math.cos(angle) * radius;
- const y = cy + Math.sin(angle) * radius;
- if (i === 0) ctx.moveTo(x, y);
- else ctx.lineTo(x, y);
- }
- ctx.stroke();
- } else if (shape === 'strokes') {
- // Three short strokes standing side by side.
- for (let i = -1; i <= 1; i += 1) {
+ if (shape === 'split') {
+ // Two-tone head: a vertical curve divides the head into two colours. `size`
+ // bows the seam, `offsetY` bows it the other way, `offsetX` slides it.
+ const bow = (size - 1) * 220 + clamp(spec.offsetY ?? 0, -600, 600);
+ const seam = (y) => cx + Math.sin(clamp((y - top) / (bottom - top), 0, 1) * Math.PI) * bow;
+ const steps = 32;
+ const trace = (dir) => {
ctx.beginPath();
- ctx.moveTo(cx + i * s * 0.62, cy - s * 0.6);
- ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6);
- ctx.stroke();
+ ctx.moveTo(seam(top), top);
+ for (let i = 1; i <= steps; i += 1) {
+ const y = top + ((bottom - top) * i) / steps;
+ ctx.lineTo(seam(y), y);
+ }
+ ctx.lineTo(dir > 0 ? right : left, bottom);
+ ctx.lineTo(dir > 0 ? right : left, top);
+ ctx.closePath();
+ };
+ trace(-1);
+ ctx.fillStyle = color;
+ ctx.fill();
+ trace(1);
+ ctx.fillStyle = color2;
+ ctx.fill();
+ ctx.restore();
+ return;
+ }
+
+ // The boundary curve. `t` runs -1..1 across the plate, so each style is a
+ // small shape function; the covering is everything above it.
+ const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach
+ + clamp(spec.offsetY ?? 0, -600, 600);
+ const halfSpan = width / 2;
+ const boundary = (x) => {
+ const t = clamp((x - cx) / halfSpan, -1, 1);
+ if (shape === 'fringe') {
+ // Bangs: a soft point dipping in the middle, with side lobes.
+ return base + 150 * (1 - Math.abs(t)) - 55 * Math.abs(Math.sin(t * Math.PI));
}
- } else if (shape === 'dots') {
- const radius = Math.max(1.5, ctx.lineWidth * 0.8);
- for (let i = -1; i <= 1; i += 1) {
- ctx.beginPath();
- ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU);
- ctx.fill();
+ if (shape === 'wave') {
+ // A single smooth sweep across the forehead.
+ return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5);
+ }
+ if (shape === 'side') {
+ // A side part: high on one side, low on the other.
+ return base + 260 * t;
}
+ // `cap`: a rounded helmet, lowest in the middle.
+ return base - 100 * (1 - t * t);
+ };
+
+ const steps = 48;
+ ctx.beginPath();
+ ctx.moveTo(left, top);
+ ctx.lineTo(right, top);
+ for (let i = steps; i >= 0; i -= 1) {
+ const x = left + (width * i) / steps;
+ ctx.lineTo(x, boundary(x));
}
+ ctx.closePath();
+ ctx.fillStyle = color;
+ ctx.fill();
ctx.restore();
}
@@ -767,7 +841,8 @@ function drawHeadMark(ctx, spec) {
*/
function drawFaceExtras(ctx, style) {
drawCheeks(ctx, style.cheeks);
- drawHeadMark(ctx, style.headMark);
+ // The head covering fills out to the plate's real edges, so it needs `limits`.
+ drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS);
}
/**
@@ -861,18 +936,6 @@ function drawGlasses(ctx, eye, line, style, limits) {
ctx.strokeStyle = colour;
ctx.lineWidth = frame;
ctx.stroke();
- // A light streak inside the glass, clipped to the lens so it can never spill
- // over the frame. Shaded drawings only: 線画's lens is empty paper.
- if (!line) {
- ctx.save();
- traceRoundedPolygon(ctx, lens, ry * L.corner);
- ctx.clip();
- strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], {
- color: 'rgba(255, 255, 255, 0.5)',
- width: Math.max(2, rx * L.highlightWidth),
- });
- ctx.restore();
- }
} else {
ctx.beginPath();
ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU);
@@ -915,13 +978,13 @@ function drawGlasses(ctx, eye, line, style, limits) {
// --- 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 sits low, so their stub starts down there instead of
- // floating below the lens.
+ // 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.outerEndX
+ ? lensCx - eye.towardNose * rx * L.tipX
: lensCx - eye.towardNose * rx;
- const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : lensCy;
+ const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy;
const reach = clamp(
outerX - eye.towardNose * r * L.templeLength,
limits.left + EDGE_MARGIN,