aboutsummaryrefslogtreecommitdiffhomepage
path: root/public/bluebey-studio
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 01:09:24 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-04 01:09:24 +0900
commitb939182d5a51b8535dd411a754c2088f7f1bafff (patch)
treefd9cf4c50cd9902552632efc9ef6792adf363f45 /public/bluebey-studio
parente9b8f3194c8604f5d0826df1b1e6a17cf27f68f7 (diff)
bluebey-studio: ゴル風の眉画像と口の長さを追加
髭と同じ仕組みで、右側だけの眉画像(assets/brows/gol-right.png)を左右に描けるようにした(「ゴル風」で自動適用、チェックで切替)。口に「口の長さ」スライダーを追加。
Diffstat (limited to 'public/bluebey-studio')
-rw-r--r--public/bluebey-studio/assets/brows/gol-right.pngbin0 -> 2242 bytes
-rw-r--r--public/bluebey-studio/src/face.js12
-rw-r--r--public/bluebey-studio/src/faceArt.js21
-rw-r--r--public/bluebey-studio/src/main.js28
-rw-r--r--public/bluebey-studio/src/panel.js14
-rw-r--r--public/bluebey-studio/src/presets.js4
6 files changed, 77 insertions, 2 deletions
diff --git a/public/bluebey-studio/assets/brows/gol-right.png b/public/bluebey-studio/assets/brows/gol-right.png
new file mode 100644
index 0000000..857f357
--- /dev/null
+++ b/public/bluebey-studio/assets/brows/gol-right.png
Binary files differ
diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js
index f2440d3..0a00399 100644
--- a/public/bluebey-studio/src/face.js
+++ b/public/bluebey-studio/src/face.js
@@ -52,6 +52,8 @@ export class Face {
// Drawings supplied for a beard kind (assets/beards/<shape>.png). Loaded by the
// app; a kind without one falls back to the built-in drawing (see drawBeard).
this.beardImages = {};
+ // Drawings for the brows (assets/brows/<kind>.png), loaded the same way.
+ this.browImages = {};
this.eyeCanvas = makeCanvas(this.eyeLayout);
this.mouthCanvas = makeCanvas(this.mouthLayout);
this.hairCanvas = this.hairLayout ? makeCanvas(this.hairLayout, HAIR_SCALE) : null;
@@ -163,6 +165,15 @@ export class Face {
this.dirty = true;
}
+ /**
+ * Set the drawings used for the brows, keyed by brow kind (`gol`). Called by the
+ * app once the files are loaded; the next redraw picks them up.
+ */
+ setBrowImages(map) {
+ this.browImages = map ?? {};
+ this.dirty = true;
+ }
+
/** `kind` is `'eyes'` or `'mouth'`; `source` is `'parametric'` or a variant key. */
setSource(kind, source) {
if (kind === 'eyes') {
@@ -237,6 +248,7 @@ export class Face {
// object hung off the nose - see src/main.js - so it is not drawn here.)
beard: p.eyes.beard,
beards: this.beardImages,
+ brows: this.browImages,
// 眼鏡 / サングラス are shared by both eyes and drawn into this same
// texture, so they travel with the other shared eye fields.
glasses: p.eyes.glasses,
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index 9d5e20d..671ed1d 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -679,6 +679,25 @@ function drawBrow(ctx, eye, spec, style, limits = WINDOW_LIMITS) {
// 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 };
@@ -1486,7 +1505,7 @@ export function drawMouth(ctx, p) {
// 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),
+ L.halfChord * clamp(p.width ?? 1, 0.2, 1.6) * clamp(p.length ?? 1, 0.2, 2.5),
Math.min(L.centreX - (edgeLeft + cornerReach), (edgeRight - cornerReach) - L.centreX),
);
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 548be76..3f61ad5 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -54,6 +54,10 @@ function assetUrl(file, kind) {
const beards = globalThis.__BLUEBEY_BEARDS__ ?? {};
return beards[file] ?? `assets/beards/${file}`;
}
+ if (kind === 'brow') {
+ const brows = globalThis.__BLUEBEY_BROWS__ ?? {};
+ return brows[file] ?? `assets/brows/${file}`;
+ }
return inlined[file] ?? `assets/fonts/${file}`;
}
@@ -420,6 +424,30 @@ async function init() {
}
})();
+ // Brow drawings supplied as files (`assets/brows/<kind>.png`). A file ending
+ // `-right.png` is one side; it is drawn twice (mirrored) at each eye. Missing
+ // files are skipped, so the built-in ゴル風 strokes are the fallback.
+ void (async () => {
+ const files = [{ kind: 'gol', file: 'gol-right.png' }];
+ const loaded = {};
+ await Promise.all(files.map(async ({ kind, file }) => {
+ try {
+ const response = await fetch(assetUrl(file, 'brow'));
+ if (!response.ok) return;
+ loaded[kind] = {
+ image: await createImageBitmap(await response.blob()),
+ half: file.endsWith('-right.png'),
+ };
+ } catch {
+ /* a missing drawing falls back to the built-in strokes */
+ }
+ }));
+ if (Object.keys(loaded).length) {
+ face.setBrowImages(loaded);
+ app.needsRender = true;
+ }
+ })();
+
// ------------------------------------------------------------------- look
const look = createLook({
renderer,
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index 237d6c8..406bbfb 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -836,6 +836,7 @@ export function buildPanel(app, root) {
// it, with the two brows sitting close together.
Object.assign(state.face.eyes.brow, {
enabled: true,
+ image: true,
angle: 34,
height: 2,
length: 1.7,
@@ -874,6 +875,15 @@ export function buildPanel(app, root) {
],
}));
+ const browImageToggle = check({
+ label: 'ゴル風の画像を使う',
+ value: false,
+ onChange: (value) => { state.face.eyes.brow.image = value; app.actions.refresh('face'); },
+ });
+ faceSection.add(controlRow(null, browImageToggle.el, { wide: true }));
+ faceSection.add(hint('読み込んだ画像(assets/brows/gol-right.png)を眉として左右に描きます。'
+ + '「ゴル風」を押すと自動で入ります。画像が無いときは今までの線で描きます。'));
+
// --- glasses / sunglasses (drawn into the same texture as the eyes and brows)
faceSection = faceTabSections.glasses;
faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。'
@@ -1226,6 +1236,7 @@ export function buildPanel(app, root) {
open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }),
round: slider({ label: '丸く開く(Oの口)', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }),
width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }),
+ length: slider({ label: '口の長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }),
thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }),
tilt: slider({ label: '口の傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }),
offsetY: slider({ label: '口の高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }),
@@ -1234,7 +1245,7 @@ export function buildPanel(app, root) {
corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }),
cornerAngle: slider({ label: '口角の角度', min: -70, max: 70, step: 1, value: -8, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.cornerAngle = v; app.actions.refresh('face'); } }),
};
- const mouthVisible = ['smile', 'round', 'width', 'thickness', 'tilt', 'offsetY'];
+ const mouthVisible = ['smile', 'round', 'width', 'length', 'thickness', 'tilt', 'offsetY'];
for (const key of mouthVisible) faceSection.add(mouthSliders[key].el);
const mouthBox = details(faceSection.body, 'こまかい設定(開き・舌・口角)');
for (const key of ['open', 'tongue', 'tonguePos', 'corners', 'cornerAngle']) mouthBox.add(mouthSliders[key].el);
@@ -2695,6 +2706,7 @@ export function buildPanel(app, root) {
browSliders.spacing.set(state.face.eyes.brow.spacing ?? 0);
browSliders.curve.set(state.face.eyes.brow.curve);
browSliders.taper.set(state.face.eyes.brow.taper ?? 1);
+ browImageToggle.set(state.face.eyes.brow.image === true);
browColor.set(state.face.eyes.brow.color);
glassesToggle.set(state.face.eyes.glasses.enabled === true);
glassesKindSegment.set(state.face.eyes.glasses.kind ?? 'glasses');
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 40dc377..4ae17b3 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -263,6 +263,9 @@ export function defaultState() {
// original character has no eyebrows.
brow: {
enabled: false,
+ // When true and a drawing (assets/brows/gol-right.png) was loaded, the
+ // brow is painted from that file (mirrored) instead of the strokes.
+ image: false,
angle: 0,
height: 0,
length: 1,
@@ -299,6 +302,7 @@ export function defaultState() {
visible: true,
smile: 1, // 0 = straight line, 1 = the original smile, <0 = frown
width: 1,
+ length: 1, // extra multiplier on the smile line's length
thickness: 1,
open: 0, // 0 = closed lips, 1 = wide open
tilt: 0, // degrees