aboutsummaryrefslogtreecommitdiffhomepage
path: root/public
diff options
context:
space:
mode:
authorYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 14:42:08 +0900
committerYasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com>2026-10-05 14:42:08 +0900
commit1bf7ee115176bc366315a90ab31740e6cd968744 (patch)
tree752b2db6126700c920f7e8b98bc167c95035341a /public
parent88010ce24f3a863518071c4bd5dd5d351d1bad26 (diff)
bluebey-studio: ハートの白目内クリップ・まつげ追従・まばたき修正、帽子の線画と位置、手描き目UI削除
指定場所を見るときもハートが白目内に収まるようクリップ。まつげを上まぶたに連動。閉じ目はまばたきせず、ハート選択後のまばたきで3の目/わらうが一瞬出る不具合を修正。帽子をランナンスタイル登録して線画対応、横・奥行き追加。手描きの目UIを削除。
Diffstat (limited to 'public')
-rw-r--r--public/bluebey-studio/src/faceArt.js71
-rw-r--r--public/bluebey-studio/src/main.js32
-rw-r--r--public/bluebey-studio/src/panel.js92
-rw-r--r--public/bluebey-studio/src/presets.js6
-rw-r--r--public/bluebey-studio/src/styles.js46
5 files changed, 152 insertions, 95 deletions
diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js
index aa1204b..482ddd7 100644
--- a/public/bluebey-studio/src/faceArt.js
+++ b/public/bluebey-studio/src/faceArt.js
@@ -1329,14 +1329,24 @@ function drawGlasses(ctx, eye, line, style, limits) {
* rolls only set `open`/`closed`/`irisShape`, so this falls back to those.
*/
function eyeShapeKey(spec) {
- // A fully lowered upper lid always means a shut artwork, even if a stale
- // `shape` says otherwise - this is what keeps older saved links working.
+ 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 (spec.closed === 'three') return 'three';
- if (spec.closed === 'arch') return 'arch';
- return `line${clamp(Math.round(spec.closedLines ?? 1), 1, 3)}`;
+ 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 (spec.shape) return spec.shape;
+ if (shape) return shape;
return (spec.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
}
@@ -1351,7 +1361,6 @@ export function drawEyes(ctx, p) {
const lookMax = style.lookMax ?? 1;
const heartScale = 2;
const heartColor = style.heartColor ?? '#e0344f';
- const heartTravel = EYE_LAYOUT.radius * 0.9;
// ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here
// rather than inside the per-eye loop below. 鼻ちょうちん comes along too.
@@ -1441,11 +1450,21 @@ export function drawEyes(ctx, p) {
ctx.restore();
};
- // Where the pupil is looking. Every eye shape shares it: the shut artwork
- // shifts with the gaze too, not just the round eyeball.
- const eyeTravel = isHeart
- ? heartTravel * lookMax
- : Math.max(0, EYE_LAYOUT.radius - irisRadius) * lookMax;
+ // 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;
+ else if (isShutShape && showWhite) travelBase = shapeHalfLimit;
+ 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);
@@ -1456,12 +1475,13 @@ export function drawEyes(ctx, p) {
// --- 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), all trimmed by the upper and lower
- // lids. The shut artwork reaches past the eyeball, so it is only lid-clipped.
+ // (iris, heart or the shut line/3/arch), trimmed by the upper and lower lids.
+ // The shut artwork reaches past the eyeball, so it is only lid-clipped - but
+ // once the white is shown it is clipped to the eyeball too, so it stays in.
ctx.save();
- if (isShutShape) {
+ if (isShutShape && !showWhite) {
clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose);
- } else if (!isHeart || open < 0.999) {
+ } else if (!isHeart || open < 0.999 || showWhite) {
clipEye(ctx, eye, open, isHeart ? 0 : lowerLid, lidFlat, lidTilt * eye.towardNose);
}
if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white);
@@ -1469,11 +1489,6 @@ export function drawEyes(ctx, p) {
ctx.translate(dx, dy);
drawShutEye(ctx, eye, shapeSpec, lineStyle, shapeScale, shapeHalfLimit);
} else if (isHeart) {
- // A heart is not an eyeball, so it may grow past the eye up to the plate.
- // With the white shown, its default size is what just fits inside it; the
- // single 大きさ knob can still grow it past the white.
- const heartFit = 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);
fillHeart(ctx, eye.cx + dx, eye.cy + dy, heartRadius, line ? lineColor : heartColor);
} else {
const irisX = eye.cx + dx;
@@ -1547,7 +1562,16 @@ export function drawEyes(ctx, p) {
// 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.
const outDir = -eye.towardNose;
+ const lidDrop = 2 * eye.r * (1 - open);
+ 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 * Math.PI) / 180;
const ux = outDir * Math.cos(t);
@@ -1555,10 +1579,11 @@ export function drawEyes(ctx, p) {
const r0 = eye.r * 0.96;
const r1 = r0 + EYE_LAYOUT.lash.length;
strokePolyline(ctx, [
- { x: eye.cx + ux * r0, y: eye.cy + uy * r0 },
- { x: eye.cx + ux * r1, y: eye.cy + uy * r1 },
+ { x: eye.cx + ux * r0, y: eye.cy + uy * r0 + lidDrop },
+ { x: eye.cx + ux * r1, y: eye.cy + uy * r1 + lidDrop },
], { color: lineColor, width: EYE_LAYOUT.lash.width });
}
+ ctx.restore();
}
// --- outline (line-art mode only) ---------------------------------
diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js
index 7fdc97f..849d3d1 100644
--- a/public/bluebey-studio/src/main.js
+++ b/public/bluebey-studio/src/main.js
@@ -382,14 +382,18 @@ async function init() {
let hatObject = null;
const hatMeshes = [];
/** Last transform applied, so the render loop only wakes when it changes. */
- const hatXform = { height: null, tiltX: null, tiltZ: null };
+ const hatXform = { x: null, z: null, height: null, tiltX: null, tiltZ: null };
/** Set once the outline pass exists, so a hat change can refresh the exclude set. */
let refreshOutlineExclusion = null;
+ /** Set once the style system exists, so a hat can register for styles + outline. */
+ let hatStyleRegister = null;
+ let hatStyleUnregister = null;
function applyHat(kind) {
if (!hatMount) return;
if (kind !== hatKindCurrent) {
hatKindCurrent = kind;
if (hatObject) {
+ hatObject.traverse((o) => { if (o.isMesh) hatStyleUnregister?.(o); });
hatMount.remove(hatObject);
hatObject.traverse((o) => {
o.geometry?.dispose();
@@ -403,7 +407,11 @@ async function init() {
if (built) {
hatMount.add(built);
hatObject = built;
- built.traverse((o) => { if (o.isMesh) hatMeshes.push(o); });
+ built.traverse((o) => {
+ if (!o.isMesh) return;
+ hatMeshes.push(o);
+ hatStyleRegister?.(o);
+ });
}
refreshOutlineExclusion?.();
}
@@ -412,14 +420,18 @@ async function init() {
// character's front and +x its right: a plain translation and rotation read
// the way the sliders say.
const hat = state.face.hat ?? {};
+ const x = hat.x ?? 0;
+ const z = hat.z ?? 0;
const height = hat.height ?? 0;
const tiltX = hat.tiltX ?? 0;
const tiltZ = hat.tiltZ ?? 0;
if (hatObject) {
- hatObject.position.y = height;
+ hatObject.position.set(x, height, z);
hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180);
}
- if (height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) {
+ if (x !== hatXform.x || z !== hatXform.z || height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) {
+ hatXform.x = x;
+ hatXform.z = z;
hatXform.height = height;
hatXform.tiltX = tiltX;
hatXform.tiltZ = tiltZ;
@@ -454,6 +466,12 @@ async function init() {
// ------------------------------------------------------------- controllers
const styles = new Styles({ meshes: model.parts.body });
+ // Hats are built at runtime, so they are not in the GLB's mesh list. Register
+ // them with the style system (and the current hat) so they follow flat/line art
+ // and get an outline hull like every other part.
+ hatStyleRegister = (mesh) => styles.addMesh(mesh);
+ hatStyleUnregister = (mesh) => styles.removeMesh(mesh);
+ for (const mesh of hatMeshes) styles.addMesh(mesh);
const face = new Face({
eyeMesh: model.parts.eyeMesh,
mouthMesh: model.parts.mouthMesh,
@@ -1626,11 +1644,15 @@ async function init() {
const eyeFor = (key) => {
const own = eyes[key];
+ // A shut artwork (1〜3線 / 3の目 / わらう) does not blink: its lid stays where
+ // the user put it, so the blink animation must not scale its `open`.
+ const shutArt = own.shape === 'three' || own.shape === 'arch'
+ || (typeof own.shape === 'string' && own.shape.startsWith('line'));
return {
// Which artwork this eye shows (ふつう / 1〜3線 / 3の目 / わらう / ハート).
// The renderer chooses the shut artwork from this, so it must travel.
shape: own.shape,
- open: clamp01(own.open * openScale),
+ open: clamp01(own.open * (shutArt ? 1 : openScale)),
lookX: clamp(own.lookX + drift.x + aimOffset.x, -1, 1),
lookY: clamp(own.lookY + drift.y + aimOffset.y, -1, 1),
closed: own.closed,
diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js
index b5e4e6e..2b762f1 100644
--- a/public/bluebey-studio/src/panel.js
+++ b/public/bluebey-studio/src/panel.js
@@ -7,7 +7,6 @@ import {
defaultState, applyPatch, BEARD_KIND_DEFAULTS,
} from './presets.js';
import { STYLE_DEFS } from './styles.js';
-import { EYE_VARIANTS } from './model.js';
import { ENVIRONMENTS } from './look.js';
import { BACKGROUND_PRESETS, EFFECT_PRESETS } from './background.js';
import { PROP_LIBRARY, applyPropFaceScale, applyPropText } from './props.js';
@@ -483,13 +482,20 @@ export function buildPanel(app, root) {
// one menu. `open`/`closed`/`irisShape` stay the stored fields; this reads and
// writes them together so the menu always shows the current look.
const eyeShapeOf = (eye) => {
+ const shape = eye.shape;
+ const explicitShut = shape === 'three' || shape === 'arch'
+ || (typeof shape === 'string' && shape.startsWith('line'));
+ if (explicitShut) return shape;
if ((eye.open ?? 1) <= 0.02) {
- if (eye.closed === 'three') return 'three';
- if (eye.closed === 'arch') return 'arch';
- // The shut line's stroke count is part of the shape itself.
- return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`;
+ if (!shape) {
+ if (eye.closed === 'three') return 'three';
+ if (eye.closed === 'arch') return 'arch';
+ return `line${Math.min(3, Math.max(1, Math.round(eye.closedLines ?? 1)))}`;
+ }
+ // An open/heart eye whose lid is all the way down is just a blink.
+ return 'line1';
}
- if (eye.shape) return eye.shape;
+ if (shape) return shape;
return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open';
};
const applyEyeShape = (eye, value) => {
@@ -497,6 +503,10 @@ export function buildPanel(app, root) {
eye.irisShape = value === 'heart' ? 'heart' : 'circle';
if (value === 'open' || value === 'heart') {
eye.open = 1;
+ // Clear any stale shut fields so a blink (open -> 0) shows a plain line and
+ // never the 3 or the arch that was picked before.
+ eye.closed = 'line';
+ eye.closedLines = 1;
return;
}
// A shut artwork is drawn as the eye's content with the lids open; the lid
@@ -796,61 +806,6 @@ export function buildPanel(app, root) {
const tearBox = details(faceSection.body, '横位置(左右べつ)');
for (const el of tearWidgets) tearBox.add(el);
- const eyeSource = selectField({
- label: '目のパーツ',
- // 実際にモデルに入っている手描きパーツだけを一覧に出す(消したものが残ると混乱するため)
- options: [
- { value: 'parametric', label: 'パラメータで作る(おすすめ)' },
- ...EYE_VARIANTS.filter((variant) => model.originals.eyes[variant.key])
- .map((variant) => ({ value: variant.key, label: `手描き:${variant.label}` })),
- ],
- value: 'parametric',
- onChange: (value) => { state.face.eyes.source = value; app.actions.refresh('face'); },
- });
- // 手描きの目(パーツの選択と読み込み)は、大きさやまぶたとは別の箱にまとめる。
- const eyeArtBox = details(faceSection.body, '手描きの目(パーツ・読み込み)');
- eyeArtBox.add(eyeSource.el);
-
- // 手描き画像の読み込み(GIMP などで描いた PNG をそのまま使えます)
- const rebuildEyeOptions = () => {
- const select = eyeSource.el.querySelector('select');
- if (!select) return;
- const current = select.value;
- select.replaceChildren();
- select.append(h('option', { value: 'parametric', text: 'パラメータで作る(おすすめ)' }));
- for (const variant of EYE_VARIANTS) {
- if (model.originals.eyes[variant.key]) {
- select.append(h('option', { value: variant.key, text: `手描き:${variant.label}` }));
- }
- }
- for (const [key, name] of app.face.customNames) {
- select.append(h('option', { value: key, text: `読み込み:${name}` }));
- }
- select.value = current;
- };
- rebuildEyeOptions();
-
- const eyeImagePicker = h('input', { type: 'file', accept: 'image/*', style: { display: 'none' } });
- eyeImagePicker.addEventListener('change', async () => {
- const file = eyeImagePicker.files?.[0];
- eyeImagePicker.value = '';
- if (!file) return;
- try {
- await app.face.loadImage('eyes', file);
- state.face.eyes.source = app.face.eyeSource;
- rebuildEyeOptions();
- sync();
- toast(`目の画像「${file.name}」を読み込みました`);
- } catch (error) {
- console.error(error);
- toast('画像を読み込めませんでした(PNG / JPG をお使いください)');
- }
- });
- eyeArtBox.add(eyeImagePicker);
- eyeArtBox.add(buttons({
- items: [{ id: 'eyeimg', label: '目の画像を読み込む(PNG)', onClick: () => { if (confirmImageLicense()) eyeImagePicker.click(); } }],
- }));
-
// --- eyebrows (the original character has none; this is extra range)
faceSection = faceTabSections.brow;
faceSection.add(hint('ぶるべーには眉がありません。怒った顔など、必要なときだけ出してください。'));
@@ -1732,9 +1687,17 @@ export function buildPanel(app, root) {
const hatBox = details(hatSection.body, '位置と傾き');
const hatSliders = {
height: slider({
- label: '高さ', min: -0.6, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ label: '高さ', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
onInput: (v) => { state.face.hat.height = v; app.actions.refresh('all'); },
}),
+ x: slider({
+ label: 'よこ位置', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.x = v; app.actions.refresh('all'); },
+ }),
+ z: slider({
+ label: 'おくゆき', min: -1.2, max: 1.2, step: 0.01, value: 0, format: (v) => v.toFixed(2),
+ onInput: (v) => { state.face.hat.z = v; app.actions.refresh('all'); },
+ }),
tiltX: slider({
label: '前後の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`,
onInput: (v) => { state.face.hat.tiltX = v; app.actions.refresh('all'); },
@@ -1745,9 +1708,11 @@ export function buildPanel(app, root) {
}),
};
for (const widget of Object.values(hatSliders)) hatBox.add(widget.el);
- hatBox.add(hint('「高さ」で帽子を浮かせたり深くかぶせたり、「前後の傾き」「左右の傾き」で斜めにかぶせられます。'));
+ hatBox.add(hint('「高さ」「よこ位置」「おくゆき」で帽子を動かし、「前後の傾き」「左右の傾き」で斜めにかぶせられます。'));
syncers.push(() => {
hatSliders.height.set(state.face.hat?.height ?? 0);
+ hatSliders.x.set(state.face.hat?.x ?? 0);
+ hatSliders.z.set(state.face.hat?.z ?? 0);
hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0);
hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0);
});
@@ -2836,7 +2801,6 @@ export function buildPanel(app, root) {
eyeColors.white.set(state.face.eyes.white);
eyeColors.iris.set(state.face.eyes.iris);
eyeColors.line.set(state.face.eyes.line);
- eyeSource.set(state.face.eyes.source);
});
syncers.push(() => {
diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js
index 5f1d412..90b249a 100644
--- a/public/bluebey-studio/src/presets.js
+++ b/public/bluebey-studio/src/presets.js
@@ -194,9 +194,9 @@ export function defaultState() {
},
face: {
// 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js.
- // `height` lifts it off the head; `tiltX` leans it forward/back and `tiltZ`
- // leans it sideways.
- hat: { kind: 'none', height: 0, tiltX: 0, tiltZ: 0 },
+ // `x`/`z` slide it sideways / forwards, `height` lifts it off the head;
+ // `tiltX` leans it forward/back and `tiltZ` leans it sideways.
+ hat: { kind: 'none', x: 0, z: 0, height: 0, tiltX: 0, tiltZ: 0 },
eyes: {
source: 'parametric', // parametric | opened | closed | ... (hand-drawn)
linked: true, // move both eyes together
diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js
index d7bd7b9..1b6a08d 100644
--- a/public/bluebey-studio/src/styles.js
+++ b/public/bluebey-studio/src/styles.js
@@ -183,6 +183,52 @@ export class Styles {
return hulls;
}
+ /**
+ * Register a mesh that is built at runtime (a hat), so it follows the render
+ * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it.
+ */
+ addMesh(mesh) {
+ if (!mesh || this.originals.has(mesh)) return;
+ this.originals.set(mesh, mesh.material);
+ this.meshes.push(mesh);
+ const kind = this.kindOf(mesh);
+ if (!OUTLINE_SKIP.has(kind)) {
+ const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind);
+ const hull = new THREE.Mesh(mesh.geometry, this.hullMaterialFor(this.scaleFor(kind), overlay));
+ hull.name = `${mesh.name || 'mesh'}:outline`;
+ hull.position.copy(mesh.position);
+ hull.quaternion.copy(mesh.quaternion);
+ hull.scale.copy(mesh.scale);
+ hull.frustumCulled = false;
+ hull.castShadow = false;
+ hull.receiveShadow = false;
+ if (overlay) hull.renderOrder = -1;
+ hull.userData.kind = kind;
+ hull.userData.source = mesh;
+ hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind);
+ hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind);
+ mesh.parent?.add(hull);
+ this.outlineMeshes.push(hull);
+ mesh.userData.hull = hull;
+ }
+ this.setStyle(this.style);
+ }
+
+ /** Take a runtime mesh (and its outline hull) back out. */
+ removeMesh(mesh) {
+ if (!mesh || !this.originals.has(mesh)) return;
+ const hull = mesh.userData.hull;
+ if (hull) {
+ hull.removeFromParent();
+ const i = this.outlineMeshes.indexOf(hull);
+ if (i >= 0) this.outlineMeshes.splice(i, 1);
+ delete mesh.userData.hull;
+ }
+ this.originals.delete(mesh);
+ const j = this.meshes.indexOf(mesh);
+ if (j >= 0) this.meshes.splice(j, 1);
+ }
+
/** The outline factor for a part in the current style (line art wants more). */
scaleFor(kind) {
const line = LINE_STYLES.has(this.style);