From 629dfd09777d1abb3cdecd8fc58d18234d1063cf Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Mon, 5 Oct 2026 00:08:30 +0900 Subject: bluebey-studio: 帽子の高さ・傾き、髪の下地書き出し、目の形メニューの再構成 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/bluebey-studio/src/exporter.js | 51 +++++++- public/bluebey-studio/src/face.js | 2 - public/bluebey-studio/src/faceArt.js | 14 +- public/bluebey-studio/src/main.js | 67 +++++++--- public/bluebey-studio/src/panel.js | 236 +++++++++++++++++++++++----------- public/bluebey-studio/src/presets.js | 17 +-- public/bluebey-studio/src/style.css | 2 + 7 files changed, 277 insertions(+), 112 deletions(-) (limited to 'public/bluebey-studio/src') diff --git a/public/bluebey-studio/src/exporter.js b/public/bluebey-studio/src/exporter.js index 147af1c..b34b6f6 100644 --- a/public/bluebey-studio/src/exporter.js +++ b/public/bluebey-studio/src/exporter.js @@ -1,7 +1,7 @@ import { traceAlphaContours, contoursToPathData } from './trace.js'; import { roughenContours } from './handDrawn.js'; import { captionToSvg } from './caption.js'; -import { EYE_LAYOUT, MOUTH_LAYOUT } from './faceArt.js'; +import { EYE_LAYOUT, MOUTH_LAYOUT, HAIR_WRAP_LAYOUT } from './faceArt.js'; /** * Export helpers. @@ -275,7 +275,54 @@ export function buildFaceMap(face, kind) { return canvas; } -/** One guide segment, kept out of `buildFaceMap` so the placements stay readable. */ +/** + * The 髪の下地 (a base to draw hair on). + * + * The hair plate is a cylinder unrolled: `x` is the angle round the head, with + * the face at the middle of the artwork window and the seam at the back at the + * two edges, and `y` is the height, top of the head first. The current hair is + * copied in as a faint reference, and guides mark the window, the front centre + * and the height the default まえがみ hairline reaches. + * + * @param {import('./face.js').Face} face + * @returns {HTMLCanvasElement|null} + */ +export function buildHairMap(face) { + const layout = face.hairLayout; + const plate = face.hairCanvas; + if (!layout || !plate) return null; + // The plate's UVs were rewritten to fill 0..1, so the whole canvas is the + // texture (unlike the eye/mouth maps, which keep a window inside a wider + // canvas). Every guide is therefore measured straight against the canvas. + const canvas = document.createElement('canvas'); + canvas.width = plate.width; + canvas.height = plate.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(plate, 0, 0); + + // White paper under the copy, so a redraw starts from an opaque base. + ctx.globalCompositeOperation = 'destination-over'; + ctx.fillStyle = '#ffffff'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.globalCompositeOperation = 'source-over'; + + ctx.strokeStyle = FACE_MAP_GUIDE; + ctx.fillStyle = FACE_MAP_GUIDE; + ctx.lineWidth = 3; + + // The front of the head sits at the middle of the artwork window, which is + // where `drawHeadCoverWrap` puts the face, so this vertical line is the + // nose-to-backbone plane. The canvas's own left/right edges are the seam at + // the back of the head. + const fx = (layout.offsetX + layout.window.width * 0.5) * (canvas.width / layout.width); + guideLine(ctx, fx, 0, fx, canvas.height); + // The default hairline (大きさ 1) runs across the whole wrap at this height. + guideLine(ctx, 0, HAIR_WRAP_LAYOUT.base * canvas.height, canvas.width, HAIR_WRAP_LAYOUT.base * canvas.height); + + return canvas; +} + +/** One guide segment, kept out of the map builders so the placements stay readable. */ function guideLine(ctx, x0, y0, x1, y1) { ctx.beginPath(); ctx.moveTo(x0, y0); diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 78b2b8d..b468cd5 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -237,7 +237,6 @@ export class Face { lowerLid: p.eyes.lowerLid, lidShape: p.eyes.lidShape, lidTilt: p.eyes.lidTilt, - hideWhite: p.eyes.hideWhite === true, brow: p.eyes.brow, // ほっぺ and 頭の模様 are shared by the whole face, so they travel with // the other shared eye fields (see `drawEyes` in src/faceArt.js). @@ -256,7 +255,6 @@ export class Face { glasses: p.eyes.glasses, heartScale: p.eyes.heartScale, heartColor: p.eyes.heartColor, - heartHideWhite: p.eyes.heartHideWhite === true, tearColor: p.eyes.tearColor, // Where the artwork's real edges are, so a brow lifted too far or a // tear dropped too low can stop inside the artwork (see `canvasLayout`). diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 1f7c4ee..01e5c31 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -1379,6 +1379,9 @@ export function drawEyes(ctx, p) { }; if (open <= 0.02) { + // A shut eye can still be given a white backing (the "白目" switch), which + // reads as a line drawn across an eyeball rather than a bare line. + if (!line && spec.white === true) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); drawShutEye(ctx, eye, spec, lineStyle); // The glasses sit over the shut-eye artwork too, but under the tear below. drawGlasses(ctx, eye, line, style, limits); @@ -1402,11 +1405,12 @@ export function drawEyes(ctx, p) { dy = (dy / dist) * eyeTravel; } - // The eyeball's white can be hidden: for a heart it stops the white showing - // round the lobes, and `hideWhite` does the same for any style (e.g. a line - // squint) when the user asks for it. - const hideWhite = style.hideWhite === true || (isHeart && style.heartHideWhite); - if (!line && !hideWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); + // The white of the eye. `spec.white` is per eye and tri-state: `null` keeps + // the usual behaviour (an open eye shows its white, a heart does not), while + // true/false force it on or off - so an open eye can lose its white, and a + // shut eye or a heart can gain it. + const showWhite = spec.white == null ? !isHeart : spec.white === true; + if (!line && showWhite) fillCircle(ctx, eye.cx, eye.cy, eye.r, white); const irisX = eye.cx + dx; const irisY = eye.cy + dy; if (isHeart) { diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 01ee85a..d4866c7 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -381,30 +381,50 @@ async function init() { let hatKindCurrent = null; 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 }; /** Set once the outline pass exists, so a hat change can refresh the exclude set. */ let refreshOutlineExclusion = null; function applyHat(kind) { - if (kind === hatKindCurrent) return; - hatKindCurrent = kind; if (!hatMount) return; + if (kind !== hatKindCurrent) { + hatKindCurrent = kind; + if (hatObject) { + hatMount.remove(hatObject); + hatObject.traverse((o) => { + o.geometry?.dispose(); + if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose()); + else o.material?.dispose(); + }); + hatObject = null; + } + hatMeshes.length = 0; + const built = buildHat(kind); + if (built) { + hatMount.add(built); + hatObject = built; + built.traverse((o) => { if (o.isMesh) hatMeshes.push(o); }); + } + refreshOutlineExclusion?.(); + } + + // 高さ and 傾き. The mount is world-aligned (see above), so +y is up, +z is the + // character's front and +x its right: a plain translation and rotation read + // the way the sliders say. + const hat = state.face.hat ?? {}; + const height = hat.height ?? 0; + const tiltX = hat.tiltX ?? 0; + const tiltZ = hat.tiltZ ?? 0; if (hatObject) { - hatMount.remove(hatObject); - hatObject.traverse((o) => { - o.geometry?.dispose(); - if (Array.isArray(o.material)) o.material.forEach((m) => m.dispose()); - else o.material?.dispose(); - }); - hatObject = null; + hatObject.position.y = height; + hatObject.rotation.set((tiltX * Math.PI) / 180, 0, (tiltZ * Math.PI) / 180); } - hatMeshes.length = 0; - const built = buildHat(kind); - if (built) { - hatMount.add(built); - hatObject = built; - built.traverse((o) => { if (o.isMesh) hatMeshes.push(o); }); + if (height !== hatXform.height || tiltX !== hatXform.tiltX || tiltZ !== hatXform.tiltZ) { + hatXform.height = height; + hatXform.tiltX = tiltX; + hatXform.tiltZ = tiltZ; + app.needsRender = true; } - refreshOutlineExclusion?.(); - app.needsRender = true; } applyHat(state.face.hat?.kind ?? 'none'); @@ -1932,14 +1952,23 @@ async function init() { // `face.flush(now, 26)`), so force any pending one through first or the map // would show the expression before last. face.flush(performance.now(), 0); - return exporter.buildFaceMap(face, kind); + return kind === 'hair' ? exporter.buildHairMap(face) : exporter.buildFaceMap(face, kind); } async function saveFaceMap(kind) { const canvas = faceMapCanvas(kind); + if (!canvas) { + toast('このモデルには髪のプレートがありません'); + return; + } const blob = await exporter.canvasToBlob(canvas); - const label = kind === 'eyes' ? '目' : '口'; + const label = kind === 'eyes' ? '目' : kind === 'mouth' ? '口' : '髪'; exporter.downloadBlob(blob, `bluebey-face-${kind}-${exporter.timestamp()}.png`); + if (kind === 'hair') { + toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + + 'この画像に前髪を描いて渡してください'); + return; + } toast(`${label}の下地を書き出しました(${canvas.width}×${canvas.height})。` + `この画像に描いて「${label}の画像を読み込む(PNG)」で読み込めます`); } diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index ed7f766..b2c3519 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -148,6 +148,17 @@ export function buildPanel(app, root) { const sync = () => { for (const fn of syncers) fn(); }; const syncBones = () => { for (const fn of boneSyncers) fn(); }; + /** + * Grey out (and disable) a control whose setting has no effect in the current + * mode - e.g. the brow's angle while the brow is a loaded picture. + */ + const setRowEnabled = (widget, on) => { + const row = widget.el.closest?.('.row') ?? widget.el; + const input = row.querySelector?.('input, select, button'); + if (input) input.disabled = !on; + row.classList?.toggle('is-off', !on); + }; + /** Patch one scope of the state without re-reading the whole UI. */ /** Replace the state and re-read every widget (used by presets and loads). */ const replace = (value, scope = 'all') => app.actions.applyState(value, { scope, sync: true }); @@ -446,6 +457,10 @@ export function buildPanel(app, root) { state.face.eyes.right.open = state.face.eyes.left.open; state.face.eyes.right.closed = state.face.eyes.left.closed; state.face.eyes.right.closedLines = state.face.eyes.left.closedLines; + state.face.eyes.right.irisShape = state.face.eyes.left.irisShape; + state.face.eyes.right.white = state.face.eyes.left.white ?? null; + state.face.eyes.right.lookX = state.face.eyes.left.lookX; + state.face.eyes.right.lookY = state.face.eyes.left.lookY; // The tears mirror as a pair: the size and the height match, while the // sideways position and the tilt flip sign. state.face.eyes.right.tear = state.face.eyes.left.tear ?? 0; @@ -457,6 +472,34 @@ export function buildPanel(app, root) { sync(); }, }); + // The eye "shape" folds the old 閉じ方 (line/3/わらう) and the heart pupil into + // 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) => { + // A shut eye wins over a heart: dragging the 開き slider down leaves a shut + // line (or 3/わらう), even if the heart pupil is still selected underneath. + if ((eye.open ?? 1) <= 0.02) { + if (eye.closed === 'three') return 'three'; + if (eye.closed === 'arch') return 'arch'; + return 'line'; + } + return (eye.irisShape ?? 'circle') === 'heart' ? 'heart' : 'open'; + }; + const applyEyeShape = (eye, value) => { + eye.irisShape = value === 'heart' ? 'heart' : 'circle'; + if (value === 'open' || value === 'heart') { + eye.open = 1; + return; + } + eye.open = 0; + eye.closed = value; + }; + const eyeWhiteOf = (eye) => { + if (eye.white != null) return eye.white === true; + // The usual: an open eye shows its white; a shut eye or a heart does not. + return eyeShapeOf(eye) === 'open'; + }; + for (const key of ['left', 'right']) { const label = key === 'left' ? '左の目' : '右の目'; const open = slider({ @@ -470,23 +513,19 @@ export function buildPanel(app, root) { sync(); }, }); - const closed = segmented({ - label: `${label}の閉じ方`, + const shape = segmented({ + label: `${label}の形`, options: [ + { value: 'open', label: 'ふつう' }, { value: 'line', label: '線' }, { value: 'three', label: '3の目' }, { value: 'arch', label: 'わらう' }, + { value: 'heart', label: 'ハート' }, ], - value: 'line', + value: 'open', onChange: (value) => { - state.face.eyes[key].closed = value; - // 線 and わらう are the shut-eye artwork, so shut the eye for the user. - state.face.eyes[key].open = 0; - if (state.face.eyes.linked) { - const other = state.face.eyes[key === 'left' ? 'right' : 'left']; - other.open = 0; - other.closed = value; - } + applyEyeShape(state.face.eyes[key], value); + if (state.face.eyes.linked) applyEyeShape(state.face.eyes[key === 'left' ? 'right' : 'left'], value); app.actions.refresh('face'); sync(); }, @@ -502,13 +541,11 @@ export function buildPanel(app, root) { hint: '2本以上は端点がつながった形になります', onChange: (value) => { state.face.eyes[key].closedLines = Number(value); - state.face.eyes[key].closed = 'line'; - state.face.eyes[key].open = 0; + applyEyeShape(state.face.eyes[key], 'line'); if (state.face.eyes.linked) { const other = state.face.eyes[key === 'left' ? 'right' : 'left']; - other.open = 0; - other.closed = 'line'; other.closedLines = Number(value); + applyEyeShape(other, 'line'); } app.actions.refresh('face'); sync(); @@ -526,8 +563,7 @@ export function buildPanel(app, root) { hint: '「3の目」のときの向きです。左右で別々に選べます', onChange: (value) => { state.face.eyes[key].threeFlip = value === 'flip'; - state.face.eyes[key].closed = 'three'; - state.face.eyes[key].open = 0; + applyEyeShape(state.face.eyes[key], 'three'); app.actions.refresh('face'); sync(); }, @@ -582,12 +618,26 @@ export function buildPanel(app, root) { label: `${label}の目線(たて)`, min: -1, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (value) => { state.face.eyes[key].lookY = value; app.actions.refresh('face'); }, }); - eyeWidgets[key] = { open, closed, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt, lowerLid: lowerLidEach, lookX, lookY }; + // 白目: whether this eye shows the white of the eyeball. Stored tri-state (a + // null means "the usual" - open eyes show it, shut ones do not), shown as a + // plain on/off. + const white = check({ + label: `${label}の白目`, + value: true, + onChange: (value) => { + state.face.eyes[key].white = value; + if (state.face.eyes.linked) state.face.eyes[key === 'left' ? 'right' : 'left'].white = value; + app.actions.refresh('face'); + sync(); + }, + }); + eyeWidgets[key] = { open, shape, closedLines, threeFlip, eyeX, tear, tearY, tearX, tearTilt, lowerLid: lowerLidEach, lookX, lookY, white }; if (key === 'left') faceSection.add(controlRow(null, linkedToggle.el, { wide: true })); faceSection.add(open.el); - faceSection.add(closed.el); + faceSection.add(shape.el); faceSection.add(closedLines.el); faceSection.add(threeFlip.el); + faceSection.add(controlRow(null, white.el, { wide: true })); faceSection.add(eyeX.el); // 目線 and 下まぶた (per eye) and the tears go into the こまかい設定 box later. tearWidgets.push(lowerLidEach.el, lookX.el, lookY.el, tear.el, tearY.el, tearX.el, tearTilt.el); @@ -609,39 +659,54 @@ export function buildPanel(app, root) { }], })); - const irisShapeSegment = segmented({ - label: '瞳の形', + const linkedNote = hint('「左右を連動させる」を入れると、開き・形・線の数・白目・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。'); + faceSection.add(linkedNote); + + // 光彩 (the white glint in the iris). Kept here beside the eye shape, because it + // is part of the eye's look rather than an advanced extra. + const highlightToggle = check({ + label: '光彩(瞳の白い丸)', + value: true, + onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); }, + }); + faceSection.add(controlRow(null, highlightToggle.el, { wide: true })); + + // Drag the gaze of one eye at a time. `linked` copies the movement onto the + // other eye, so a linked pair still moves together. + let lookTarget = 'left'; + const lookEyeSegment = segmented({ + label: '動かす目', options: [ - { value: 'circle', label: '丸' }, - { value: 'heart', label: 'ハート' }, + { value: 'left', label: '左の目' }, + { value: 'right', label: '右の目' }, ], - value: 'circle', + value: 'left', onChange: (value) => { - state.face.eyes.left.irisShape = value; - state.face.eyes.right.irisShape = value; - app.actions.refresh('face'); + lookTarget = value; + const eye = state.face.eyes[value]; + lookPad.set({ x: eye.lookX ?? 0, y: eye.lookY ?? 0 }); }, }); - faceSection.add(irisShapeSegment.el); - - const linkedNote = hint('「左右を連動させる」を入れると、開き・閉じ方・線の数・涙が両目でそろいます。涙のよこ位置と傾きは左右で反転し、線対称になります。はずすと片目ずつ変えられるので、ウインクも作れます。'); - faceSection.add(linkedNote); + faceSection.add(lookEyeSegment.el); const lookPad = xyPad({ value: { x: 0, y: 0 }, center: 'eye', onChange: ({ x, y }) => { - state.face.eyes.left.lookX = x; - state.face.eyes.left.lookY = y; - state.face.eyes.right.lookX = x; - state.face.eyes.right.lookY = y; + const other = lookTarget === 'left' ? 'right' : 'left'; + state.face.eyes[lookTarget].lookX = x; + state.face.eyes[lookTarget].lookY = y; + if (state.face.eyes.linked) { + state.face.eyes[other].lookX = x; + state.face.eyes[other].lookY = y; + } app.actions.refresh('face'); sync(); }, }); faceSection.add(controlRow('目線', h('div', { class: 'pad-wrap' }, lookPad.el, - h('p', { class: 'hint', text: 'ドラッグで両目が動きます。ダブルクリックで正面に戻ります。' })))); + h('p', { class: 'hint', text: 'ドラッグで「動かす目」を動かします。ダブルクリックで正面に戻ります。' })))); // 目を細める: a one-tap マンガの細目, kept out of the こまかい設定 box so it is // easy to find. @@ -678,15 +743,8 @@ export function buildPanel(app, root) { onInput: (value) => { state.face.eyes.lidTilt = value; app.actions.refresh('face'); }, }); faceSection.add(lidTiltSlider.el); - const hideWhiteToggle = check({ - label: '白目を消す', - value: false, - onChange: (value) => { state.face.eyes.hideWhite = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, hideWhiteToggle.el, { wide: true })); faceSection.add(hint('「まぶたの形」を「直線」にすると、まぶたが平らに下りてきます。' - + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。' - + '「白目を消す」は、細めた目やハートのときに白目を出しません。')); + + '「まぶたの傾き」で、細めた目を左右反転で傾けられます。')); const eyeAdvanced = details(faceSection.body, 'こまかい設定(瞳・色・まぶたの太さ・左右べつの設定・手描き)'); const irisScaleSlider = slider({ @@ -705,16 +763,9 @@ export function buildPanel(app, root) { eyeAdvanced.add(irisScaleSlider.el); eyeAdvanced.add(heartColorField.el); eyeAdvanced.add(lidWidthSlider.el); - eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「目を細める」「白目を消す」は、この上の「目」にあります。' + eyeAdvanced.add(hint('「まぶたの形」「まぶたの傾き」「目を細める」「白目」「光彩」は、この上の「目」にあります。' + 'ハートの大きさは「瞳の大きさ」で変わります。')); - const highlightToggle = check({ - label: '瞳のハイライト', - value: true, - onChange: (value) => { state.face.eyes.highlight = value; app.actions.refresh('face'); }, - }); - eyeAdvanced.add(controlRow(null, highlightToggle.el, { wide: true })); - const eyeColors = { white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), iris: colorField({ label: '瞳', value: '#150e1b', onChange: (v) => { state.face.eyes.iris = v; app.actions.refresh('face'); } }), @@ -846,7 +897,7 @@ export function buildPanel(app, root) { items: [ { id: 'golgo', - label: 'ゴル風', + label: 'ゴル風(画像)', onClick: () => { // ゴルゴ13: a very thick, almost straight black wedge, wide at the outer // end and tapering to a point at the nose, angled steeply down towards @@ -869,7 +920,7 @@ export function buildPanel(app, root) { }, { id: 'ryotsu', - label: '両風', + label: '両風(線の眉)', onClick: () => { // 両津勘吉: thick げじげじ brows that are joined in the middle, sitting // low over the eyes. `spacing` is what closes the gap - at this length @@ -895,8 +946,9 @@ export function buildPanel(app, root) { ], })); - faceSection.add(hint('「ゴル風」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。' - + '「両風」など線で描く眉に切り替えると、この画像は使われません。')); + faceSection.add(hint('「ゴル風(画像)」は読み込んだ画像(assets/brows/gol-right.png)で眉を描きます。' + + '画像のときは角度・太さ・曲がり・鼻側の細さが効かないので、灰色になります。' + + '「両風(線の眉)」など線で描く眉に切り替えると、この画像は使われません。')); // --- glasses / sunglasses (drawn into the same texture as the eyes and brows) faceSection = faceTabSections.glasses; @@ -1627,6 +1679,29 @@ export function buildPanel(app, root) { hatSection.add(hatSegment.el); syncers.push(() => hatSegment.set(state.face.hat?.kind ?? 'none')); + 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), + onInput: (v) => { state.face.hat.height = 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'); }, + }), + tiltZ: slider({ + label: '左右の傾き', min: -45, max: 45, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, + onInput: (v) => { state.face.hat.tiltZ = v; app.actions.refresh('all'); }, + }), + }; + for (const widget of Object.values(hatSliders)) hatBox.add(widget.el); + hatBox.add(hint('「高さ」で帽子を浮かせたり深くかぶせたり、「前後の傾き」「左右の傾き」で斜めにかぶせられます。')); + syncers.push(() => { + hatSliders.height.set(state.face.hat?.height ?? 0); + hatSliders.tiltX.set(state.face.hat?.tiltX ?? 0); + hatSliders.tiltZ.set(state.face.hat?.tiltZ ?? 0); + }); + /* ------------------------------------------------------------- body colour */ const colourSection = section(tabView, '体の色'); @@ -2605,12 +2680,16 @@ export function buildPanel(app, root) { items: [ { id: 'facemap-eyes', label: '目の下地を書き出す', onClick: () => app.actions.saveFaceMap('eyes') }, { id: 'facemap-mouth', label: '口の下地を書き出す', onClick: () => app.actions.saveFaceMap('mouth') }, + { id: 'facemap-hair', label: '髪の下地を書き出す', onClick: () => app.actions.saveFaceMap('hair') }, ], })); faceMapSection.add(hint('書き出したPNGは、スタジオが読み込む大きさ・位置そのものです。' + 'そのまま絵を描いて(たとえば目にゴルゴ風の眉を描く)「目の画像を読み込む(PNG)」や' + '「口の画像を読み込む(PNG)」で読み込むと、描いた場所にそのまま入ります。' - + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。')); + + '青い線は位置合わせの目安です。使う前に消すか、上から塗ってください。' + + '「髪の下地」は、頭をぐるりと巻いたテクスチャを開いたもので、横が頭の角度、' + + 'たてが高さです。左右の端が頭の後ろ(合わせ目)、青いたて線が顔の正面、' + + '青いよこ線が既定の生え際です。')); /* --------------------------------------------------------------- settings */ @@ -2671,31 +2750,34 @@ export function buildPanel(app, root) { syncers.push(() => { for (const key of ['left', 'right']) { - eyeWidgets[key].open.set(state.face.eyes[key].open); - eyeWidgets[key].closed.set(state.face.eyes[key].closed); - eyeWidgets[key].closedLines.set(String(state.face.eyes[key].closedLines ?? 1)); - eyeWidgets[key].threeFlip.set(state.face.eyes[key].threeFlip ? 'flip' : 'normal'); - // The direction only means anything for the "3" eye. - eyeWidgets[key].threeFlip.el.style.display = state.face.eyes[key].closed === 'three' ? '' : 'none'; - eyeWidgets[key].tear.set(state.face.eyes[key].tear ?? 0); - eyeWidgets[key].tearY.set(state.face.eyes[key].tearY ?? 0); - eyeWidgets[key].tearX.set(state.face.eyes[key].tearX ?? 0); - eyeWidgets[key].tearTilt.set(state.face.eyes[key].tearTilt ?? 0); - eyeWidgets[key].eyeX.set(state.face.eyes[key].eyeX ?? 0); - eyeWidgets[key].lookX.set(state.face.eyes[key].lookX ?? 0); - eyeWidgets[key].lookY.set(state.face.eyes[key].lookY ?? 0); - eyeWidgets[key].lowerLid.set(state.face.eyes[key].lowerLid ?? state.face.eyes.lowerLid ?? 0); + const eye = state.face.eyes[key]; + const shapeValue = eyeShapeOf(eye); + eyeWidgets[key].open.set(eye.open); + eyeWidgets[key].shape.set(shapeValue); + eyeWidgets[key].closedLines.set(String(eye.closedLines ?? 1)); + eyeWidgets[key].closedLines.el.style.display = shapeValue === 'line' ? '' : 'none'; + eyeWidgets[key].threeFlip.set(eye.threeFlip ? 'flip' : 'normal'); + // The direction only means anything for the "3" eye; the line count only + // for the plain line. + eyeWidgets[key].threeFlip.el.style.display = shapeValue === 'three' ? '' : 'none'; + eyeWidgets[key].white.set(eyeWhiteOf(eye)); + eyeWidgets[key].tear.set(eye.tear ?? 0); + eyeWidgets[key].tearY.set(eye.tearY ?? 0); + eyeWidgets[key].tearX.set(eye.tearX ?? 0); + eyeWidgets[key].tearTilt.set(eye.tearTilt ?? 0); + eyeWidgets[key].eyeX.set(eye.eyeX ?? 0); + eyeWidgets[key].lookX.set(eye.lookX ?? 0); + eyeWidgets[key].lookY.set(eye.lookY ?? 0); + eyeWidgets[key].lowerLid.set(eye.lowerLid ?? state.face.eyes.lowerLid ?? 0); } - irisShapeSegment.set(state.face.eyes.left.irisShape ?? 'circle'); - lookPad.set({ x: state.face.eyes.left.lookX, y: state.face.eyes.left.lookY }); + lookPad.set({ x: state.face.eyes[lookTarget].lookX ?? 0, y: state.face.eyes[lookTarget].lookY ?? 0 }); highlightToggle.set(state.face.eyes.highlight); irisScaleSlider.set(state.face.eyes.irisScale); heartColorField.set(state.face.eyes.heartColor ?? '#e0344f'); lidWidthSlider.set(state.face.eyes.lidWidth ?? 14); lidShapeSegment.set(state.face.eyes.lidShape ?? 'curve'); lidTiltSlider.set(state.face.eyes.lidTilt ?? 0); - hideWhiteToggle.set(state.face.eyes.hideWhite === true); browToggle.set(state.face.eyes.brow.enabled); browSliders.angle.set(state.face.eyes.brow.angle); browSliders.height.set(state.face.eyes.brow.height); @@ -2704,6 +2786,12 @@ 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); + // A loaded ゴル風 picture ignores the shape sliders, so grey those out rather + // than leave them looking like they should do something. + const browIsImage = state.face.eyes.brow.image === true; + for (const key of ['angle', 'thickness', 'curve', 'taper']) { + setRowEnabled(browSliders[key], !browIsImage); + } 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 7f091f1..5ba46af 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -194,12 +194,14 @@ export function defaultState() { }, face: { // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js. - hat: { kind: 'none' }, + // `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 }, eyes: { source: 'parametric', // parametric | opened | closed | ... (hand-drawn) linked: true, // move both eyes together - left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 }, - right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0 }, + left: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0, white: null }, + right: { open: 1, lookX: 0, lookY: 0, eyeX: 0, closed: 'line', closedLines: 1, irisShape: 'circle', threeFlip: false, tear: 0, tearY: 0, tearX: 0, tearTilt: 0, white: null }, irisScale: 1, lookMax: 1.2, highlight: true, @@ -210,12 +212,8 @@ export function defaultState() { // narrowed eye. See faceArt.js. lidShape: 'curve', lidTilt: 0, - // Hide the eyeball's white everywhere (not only for a heart): a line - // squint then reads as a bare line. - hideWhite: false, heartScale: 2, heartColor: '#e0344f', - heartHideWhite: true, // The tears live on each eye (see `eyes.left.tear`), so one eye can cry // on its own; only the colour is shared. tearColor: '#8fd8ff', @@ -511,11 +509,10 @@ export const FACE_PRESETS = [ label: 'すき', face: { eyes: { - left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' }, - right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart' }, + left: { open: 1, lookX: -0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, + right: { open: 1, lookX: 0.1, lookY: -0.3, closed: 'line', irisShape: 'heart', white: false }, irisScale: 1, // The white behind a heart would poke out around its lobes, so hide it. - hideWhite: true, heartColor: '#e0344f', highlight: false, }, diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css index bb76265..ffcbac3 100644 --- a/public/bluebey-studio/src/style.css +++ b/public/bluebey-studio/src/style.css @@ -336,6 +336,8 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); } .control { display: flex; align-items: center; gap: 7px; min-width: 0; } .hint { font-size: 11px; line-height: 1.55; color: var(--muted); } .hint b { color: #5a4a7d; } +.row.is-off .control, .row.is-off > .label { opacity: 0.4; } +.row.is-off .control input { cursor: default; } .subhead { font-size: 10.5px; font-weight: 700; -- cgit v1.3.1