From 1bf7ee115176bc366315a90ab31740e6cd968744 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:42:08 +0900 Subject: bluebey-studio: ハートの白目内クリップ・まつげ追従・まばたき修正、帽子の線画と位置、手描き目UI削除 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 指定場所を見るときもハートが白目内に収まるようクリップ。まつげを上まぶたに連動。閉じ目はまばたきせず、ハート選択後のまばたきで3の目/わらうが一瞬出る不具合を修正。帽子をランナンスタイル登録して線画対応、横・奥行き追加。手描きの目UIを削除。 --- public/bluebey-studio/src/faceArt.js | 71 +++++++++++++++++++--------- public/bluebey-studio/src/main.js | 32 +++++++++++-- public/bluebey-studio/src/panel.js | 92 +++++++++++------------------------- public/bluebey-studio/src/presets.js | 6 +-- public/bluebey-studio/src/styles.js | 46 ++++++++++++++++++ 5 files changed, 152 insertions(+), 95 deletions(-) (limited to 'public/bluebey-studio/src') 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); -- cgit v1.3.1