From a1337fab526a9babd5c492537bd7aee4cc5e7d26 Mon Sep 17 00:00:00 2001 From: Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:24 +0900 Subject: public/bluebey-studio: 立体の鼻ちょうちん・仰向けポーズ・ひげなどを追加 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 鼻ちょうちんをテクスチャ描画から3Dオブジェクトに変更。鼻の下に泡を 出し、master ボーンに親子付けて体の傾きに追従する - 寝ているポーズを仰向けにし、背中が地面につく高さに調整 - ポーズを変えたら「てをふる」の動きを「とまる」に戻す - スマホでメニュー表示中に画面外をタップするとメニューを閉じる - 眉の長さを、左右がつながるくらいまで伸ばせるように - 眼鏡・サングラスの「種類」を選ぶと「眼鏡をかける」を自動でオンに - マウス操作からボーン選択を外し、ドラッグで移動・Shift+ドラッグで 向き変更にした(ボーンの操作はメニューの一覧から) - 頭の模様: 大きさをより小さくでき、ツートンを廃止、「わけめ」に分け目 を入れ、「まえがみ(逆)」(上に凸)を追加 - ひげ(ちょびひげ・カイゼル・エプロン・ねこ)を追加 --- public/bluebey-studio/src/face.js | 5 +- public/bluebey-studio/src/faceArt.js | 187 ++++++++++++++++++----------------- public/bluebey-studio/src/main.js | 169 +++++++++++++++++++++++++++++++ public/bluebey-studio/src/panel.js | 69 ++++++++++++- public/bluebey-studio/src/presets.js | 21 +++- public/bluebey-studio/src/rig.js | 54 +--------- 6 files changed, 350 insertions(+), 155 deletions(-) (limited to 'public/bluebey-studio') diff --git a/public/bluebey-studio/src/face.js b/public/bluebey-studio/src/face.js index 196ff84..6898faf 100644 --- a/public/bluebey-studio/src/face.js +++ b/public/bluebey-studio/src/face.js @@ -197,8 +197,9 @@ export class Face { // the other shared eye fields (see `drawEyes` in src/faceArt.js). cheeks: p.eyes.cheeks, headMark: p.eyes.headMark, - // 鼻ちょうちん is shared by the whole face as well. - snot: p.eyes.snot, + // ひげ is shared by the whole face as well. (鼻ちょうちん is a real 3D + // object hung off the nose - see src/main.js - so it is not drawn here.) + beard: p.eyes.beard, // 眼鏡 / サングラス 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 1beedad..a92482a 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -178,19 +178,14 @@ export const HEAD_MARK_LAYOUT = { }; /** - * 鼻ちょうちん: the little snot bubble that grows and shrinks while someone sleeps. - * - * It hangs off the nose, which is a *separate mesh* in front of the plate, so the - * bubble sits out to the side of it (a bubble drawn straight below would run off - * the plate's bottom edge). The neck starts at the nose and reaches out to the - * bubble, one thin wisp, the way manga draws it. + * ひげ: the mustaches and beards that hang under the nose. Drawn on the eye plate + * at the nose, the same spot the snot bubble uses. `shape` picks a well-known + * kind; `size` scales it about the nose, and the colours are adjustable. */ -export const SNOT_LAYOUT = { - cx: 511.5, // the face midline, the same centre the mouth uses - cy: 336, // just below the eye row, level with the nose - offset: 122, // how far out to the side the bubble hangs, clear of the nose - radius: 48, // bubble radius at size 1 - lineWidth: 6, +export const BEARD_LAYOUT = { + cx: 511.5, + cy: 352, // just under the nose + width: 240, // base width of the widest beard at size 1 }; /** @@ -771,7 +766,7 @@ 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 size = clamp(spec.size ?? 1, 0.02, 3); const left = limits.left; const right = limits.right; const top = limits.top; @@ -788,28 +783,8 @@ function drawHeadCover(ctx, spec, limits) { ctx.clip(); 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(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(); + // Retired: the two-tone head was dropped, but an old saved state may still + // carry the shape, so treat it as off rather than drawing a two-tone head. ctx.restore(); return; } @@ -825,6 +800,11 @@ function drawHeadCover(ctx, spec, limits) { // 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)); } + if (shape === 'fringe-up') { + // The mirror image: a fringe that peaks *up* in the middle (a widow's peak + // or centre part), so more forehead shows in the middle than at the sides. + return base - 150 * (1 - Math.abs(t)) + 55 * Math.abs(Math.sin(t * Math.PI)); + } if (shape === 'wave') { // A single smooth sweep across the forehead. return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5); @@ -848,6 +828,23 @@ function drawHeadCover(ctx, spec, limits) { ctx.closePath(); ctx.fillStyle = color; ctx.fill(); + + if (shape === 'side') { + // わけめ: cut a real parting in the hair, so the two halves read as combed + // apart rather than as one smooth sweep. + const partX = cx - halfSpan * 0.45; + const gw = Math.max(5, width * 0.02) * (0.6 + size); + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + ctx.beginPath(); + ctx.moveTo(partX - gw * 0.16, top); + ctx.lineTo(partX + gw * 0.16, top); + ctx.lineTo(partX + gw * 0.7, boundary(partX) + 4); + ctx.lineTo(partX - gw * 0.7, boundary(partX) + 4); + ctx.closePath(); + ctx.fill(); + ctx.restore(); + } ctx.restore(); } @@ -859,73 +856,77 @@ function drawFaceExtras(ctx, style, lineOnly) { drawCheeks(ctx, style.cheeks); // The head covering fills out to the plate's real edges, so it needs `limits`. drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS); - drawSnotBubble(ctx, style.snot, lineOnly); + drawBeard(ctx, style.beard, lineOnly); } /** - * 鼻ちょうちん: a bubble hanging off the nose, joined to it by a thin neck. The - * neck starts at the nose (which the nose mesh hides, so it reads as growing out - * from under it) and swells into the round bubble out to the side. In a line - * drawing the bubble is left empty and only its outline is inked. + * ひげ: a mustache or beard under the nose. Each kind is a small shape built on + * the spot; `scotch` and `cat` are drawn with round-capped strokes, `kaiser` is a + * thick waving band, and `apron` is a filled bib. In a line drawing nothing is + * filled and only the outlines are inked. */ -function drawSnotBubble(ctx, spec, lineOnly) { - if (!spec?.enabled) return; +function drawBeard(ctx, spec, lineOnly = false) { + const shape = spec?.shape ?? 'off'; + if (shape === 'off' || shape === 'none') return; const size = clamp(spec.size ?? 1, 0.2, 3); - const dx = spec.offsetX ?? 0; - const dy = spec.offsetY ?? 0; - const r = SNOT_LAYOUT.radius * size; - const cx = SNOT_LAYOUT.cx + SNOT_LAYOUT.offset + dx; - const cy = SNOT_LAYOUT.cy + dy; - const color = spec.color ?? '#e3ecff'; + const cx = BEARD_LAYOUT.cx + clamp(spec.offsetX ?? 0, -600, 600); + const cy = BEARD_LAYOUT.cy + clamp(spec.offsetY ?? 0, -600, 600); + const w = BEARD_LAYOUT.width * size; + const color = spec.color ?? '#3a2a4a'; const line = spec.line ?? '#55386e'; - const lw = Math.max(1, SNOT_LAYOUT.lineWidth * size); - - // The nose end sits on the midline; the bubble hangs out to one side. - const nx = SNOT_LAYOUT.cx + dx; - const ny = SNOT_LAYOUT.cy + dy - r * 0.1; - const dist = Math.hypot(cx - nx, cy - ny) || 1; - const ux = (cx - nx) / dist; - const uy = (cy - ny) / dist; - // Where the neck meets the bubble: a little inside its edge, so the two merge. - const reach = Math.max(0, dist - r * 0.72); - const ex = nx + ux * reach; - const ey = ny + uy * reach; + const lw = Math.max(2, 9 * size); + const stroke = (points, width) => strokePolyline(ctx, points, { + color: lineOnly ? line : color, width, + }); - ctx.save(); - ctx.lineJoin = 'round'; - ctx.lineCap = 'round'; - // The neck: a wedge from a narrow end at the nose to a wide end at the bubble. - const px = -uy; - const py = ux; - const nW = lw * 0.55; - const bW = r * 0.5; - ctx.beginPath(); - ctx.moveTo(nx + px * nW, ny + py * nW); - ctx.lineTo(ex + px * bW, ey + py * bW); - ctx.lineTo(ex - px * bW, ey - py * bW); - ctx.lineTo(nx - px * nW, ny - py * nW); - ctx.closePath(); - if (lineOnly) { - ctx.strokeStyle = line; - ctx.lineWidth = lw; - ctx.stroke(); - } else { - ctx.fillStyle = color; - ctx.fill(); - ctx.strokeStyle = line; - ctx.lineWidth = lw * 0.7; - ctx.stroke(); + if (shape === 'scotch') { + // ちょび髭: two short dashes under the nose, angled down and out so their tips + // clear the nose mesh (which hides anything drawn straight behind it). + stroke([{ x: cx - w * 0.07, y: cy + w * 0.02 }, { x: cx - w * 0.5, y: cy + w * 0.17 }], lw * 1.8); + stroke([{ x: cx + w * 0.07, y: cy + w * 0.02 }, { x: cx + w * 0.5, y: cy + w * 0.17 }], lw * 1.8); + return; } - // The bubble itself, over the neck's wide end. - circlePath(ctx, cx, cy, r); - if (!lineOnly) { - ctx.fillStyle = color; - ctx.fill(); + if (shape === 'kaiser') { + // カイゼル: a full, thick mustache whose ends turn up. + stroke([ + { x: cx - w * 0.5, y: cy - w * 0.02 }, + { x: cx - w * 0.28, y: cy + w * 0.07 }, + { x: cx, y: cy + w * 0.09 }, + { x: cx + w * 0.28, y: cy + w * 0.07 }, + { x: cx + w * 0.5, y: cy - w * 0.02 }, + ], lw * 2.6); + stroke([{ x: cx - w * 0.5, y: cy - w * 0.02 }, { x: cx - w * 0.62, y: cy - w * 0.16 }], lw * 1.4); + stroke([{ x: cx + w * 0.5, y: cy - w * 0.02 }, { x: cx + w * 0.62, y: cy - w * 0.16 }], lw * 1.4); + return; + } + if (shape === 'apron') { + // エプロン: a bib-shaped beard hanging from the cheeks to a rounded point. + const pts = [ + { x: cx - w * 0.6, y: cy - w * 0.04 }, + { x: cx - w * 0.5, y: cy + w * 0.35 }, + { x: cx - w * 0.22, y: cy + w * 0.62 }, + { x: cx, y: cy + w * 0.7 }, + { x: cx + w * 0.22, y: cy + w * 0.62 }, + { x: cx + w * 0.5, y: cy + w * 0.35 }, + { x: cx + w * 0.6, y: cy - w * 0.04 }, + ]; + if (lineOnly) { + strokePolyline(ctx, pts, { color: line, width: lw }); + } else { + fillPolygon(ctx, pts, color); + strokePolyline(ctx, pts, { color: line, width: lw * 0.5 }); + } + return; + } + // ねこ: three whiskers fanned out on each side of the muzzle. + for (const dir of [-1, 1]) { + for (let i = 0; i < 3; i += 1) { + stroke([ + { x: cx + dir * w * 0.08, y: cy + w * 0.04 }, + { x: cx + dir * w * 0.62, y: cy + w * 0.04 + (i - 1) * w * 0.18 }, + ], lw * 0.9); + } } - ctx.strokeStyle = line; - ctx.lineWidth = lw; - ctx.stroke(); - ctx.restore(); } /** diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 9c0b98d..68df32c 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -265,6 +265,79 @@ async function init() { const propRoot = new THREE.Group(); scene.add(propRoot); + // --- 鼻ちょうちん: a modelled bubble hung under the nose -------------------- + // The nose is a *skinned* part of the body, so a child of the nose mesh would + // sit at the origin instead of on the face. The whole body (nose included) + // rides the `master` bone, so the bubble is parented there, placed at the nose's + // rest position in that bone's local frame, and read from the face state each + // frame in the render loop. + function applySnotBubble(group, snot) { + if (!group) return; + group.visible = snot?.enabled === true; + if (!group.visible) return; + group.scale.setScalar(Number.isFinite(snot.size) ? snot.size : 1); + const base = group.userData.base; + const k = 0.01; // artwork px -> world units, enough to nudge the bubble + group.position.set( + base.x + (snot.offsetX ?? 0) * k, + base.y + (snot.offsetY ?? 0) * k, + base.z, + ); + group.userData.material.color.set(snot.color ?? '#dfe8ff'); + } + + const snotBubble = (() => { + const nose = model.parts.noseMesh; + const master = model.bones.find((entry) => entry.name.replace(/[.\s]/g, '').toLowerCase() === 'master'); + if (!nose || !master || !nose.isSkinnedMesh) return null; + model.root.updateMatrixWorld(true); + // The nose is a *skinned* mesh, so its raw geometry sits in bind space (at the + // back of the model, in fact) - sample the skinned positions instead, which is + // where the nose actually ends up. + const attr = nose.geometry.attributes.position; + const step = Math.max(1, Math.floor(attr.count / 400)); + const point = new THREE.Vector3(); + const centre = new THREE.Vector3(); + let samples = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + centre.add(point); + samples += 1; + } + if (!samples) return null; + centre.multiplyScalar(1 / samples); + nose.localToWorld(centre); + let radius = 0; + for (let i = 0; i < attr.count; i += step) { + point.fromBufferAttribute(attr, i); + nose.applyBoneTransform(i, point); + nose.localToWorld(point); + radius = Math.max(radius, point.distanceTo(centre)); + } + if (!Number.isFinite(radius) || radius <= 0) radius = 0.25; + const material = new THREE.MeshStandardMaterial({ + color: 0xdfe8ff, roughness: 0.45, metalness: 0, transparent: true, opacity: 0.92, + }); + const group = new THREE.Group(); + group.name = 'snot-bubble'; + const bubble = new THREE.Mesh(new THREE.SphereGeometry(radius * 1.05, 28, 20), material); + const neck = new THREE.Mesh(new THREE.SphereGeometry(radius * 0.42, 20, 14), material); + group.add(bubble, neck); + const bone = master.bone; + const bubbleLocal = bone.worldToLocal(centre.clone().add(new THREE.Vector3(radius * 1.9, -radius * 0.85, radius * 0.7))); + const noseLocal = bone.worldToLocal(centre.clone()); + group.position.copy(bubbleLocal); + // The neck lump sits partway back towards the nose, so the two read as joined. + neck.position.copy(noseLocal).sub(bubbleLocal).multiplyScalar(0.45); + group.userData = { bubble, neck, material, base: bubbleLocal.clone() }; + group.visible = false; + bone.add(group); + return group; + })(); + const snotMeshes = snotBubble ? [...snotBubble.children] : []; + applySnotBubble(snotBubble, state.face.eyes.snot); + const halfHeight = model.size.y * 0.5; view.frame(model.size, halfHeight); @@ -975,6 +1048,7 @@ async function init() { rig.helper, model.parts.eyeMesh, model.parts.mouthMesh, + ...snotMeshes, ...styles.outlineMeshes, ]; } @@ -1156,6 +1230,7 @@ async function init() { let fpsAt = performance.now(); const warmupUntil = performance.now() + 2500; + let snotSignature = null; const loop = (now) => { const rawDelta = now - last; const dt = Math.min(0.05, rawDelta / 1000); @@ -1167,6 +1242,16 @@ async function init() { if (!rig.controls.dragging) rig.applyPose(posed); container.position.set(posed.root[0] ?? 0, posed.root[1] ?? 0, posed.root[2] ?? 0); + if (snotBubble) { + const snot = state.face.eyes.snot; + const sig = `${snot.enabled}|${snot.size}|${snot.offsetX}|${snot.offsetY}|${snot.color}`; + if (sig !== snotSignature) { + snotSignature = sig; + applySnotBubble(snotBubble, snot); + app.needsRender = true; + } + } + const params = buildFaceParams(); const signature = JSON.stringify(params); if (signature !== faceSignature) { @@ -2069,6 +2154,90 @@ async function init() { document.getElementById('help-close')?.addEventListener('click', () => { toggleHelp(false); }); const help = document.getElementById('help'); help?.addEventListener('click', (event) => { if (event.target === help) toggleHelp(false); }); + + // --- the mouse on the character ---------------------------------------- + // Dragging the model slides it across the view; holding Shift and dragging + // turns it around. Bone posing lives in the panel, so the mouse never grabs a + // bone (see `Rig` in src/rig.js). On a small screen the panel is a sheet over + // the view, so a tap on the view first just dismisses the sheet. + const dragRay = new THREE.Raycaster(); + const dragNdc = new THREE.Vector2(); + const dragHitAt = (event) => { + const rect = canvas.getBoundingClientRect(); + dragNdc.set( + ((event.clientX - rect.left) / Math.max(1, rect.width)) * 2 - 1, + -((event.clientY - rect.top) / Math.max(1, rect.height)) * 2 + 1, + ); + dragRay.setFromCamera(dragNdc, view.camera); + const targets = [model.parts.eyeMesh, model.parts.mouthMesh, ...model.parts.body] + .filter((mesh) => mesh?.visible); + return dragRay.intersectObjects(targets, false)[0] ?? null; + }; + // World units per screen pixel, so the model keeps up with the cursor. + const dragScale = () => { + const camera = view.camera; + const height = Math.max(1, canvas.clientHeight); + if (camera.isOrthographicCamera) return (camera.top - camera.bottom) / camera.zoom / height; + const distance = camera.position.distanceTo(view.controls.target); + return (2 * distance * Math.tan((camera.fov * Math.PI) / 360)) / height; + }; + let modelDrag = null; + + canvas.addEventListener('pointerdown', (event) => { + if (event.button !== 0 || event.target !== canvas) return; + if (narrowScreenLayout.matches && !document.body.classList.contains('panel-hidden')) { + togglePanel(); + return; + } + if (!dragHitAt(event)) return; + const root = state.pose.root ?? [0, 0, 0]; + const master = state.pose.bones?.master ?? [0, 0, 0]; + modelDrag = { + pointerId: event.pointerId, + spin: event.shiftKey, + x: event.clientX, + y: event.clientY, + root: [...root], + masterY: master[1] ?? 0, + }; + view.controls.enabled = false; + stage.style.cursor = 'grabbing'; + try { canvas.setPointerCapture(event.pointerId); } catch { /* ignore */ } + }); + + canvas.addEventListener('pointermove', (event) => { + if (!modelDrag || event.pointerId !== modelDrag.pointerId) return; + const dx = event.clientX - modelDrag.x; + const dy = event.clientY - modelDrag.y; + if (modelDrag.spin) { + const bones = { ...(state.pose.bones ?? {}) }; + const master = bones.master ?? [0, 0, 0]; + bones.master = [master[0], round2(modelDrag.masterY + dx * 0.7), master[2]]; + state.pose.bones = bones; + } else { + const k = dragScale(); + state.pose.root = [ + round2(modelDrag.root[0] + dx * k), + round2(modelDrag.root[1] - dy * k), + modelDrag.root[2], + ]; + } + app.panel?.onRigChanged?.(); + app.needsRender = true; + }); + + const endModelDrag = (event) => { + if (!modelDrag || (event && event.pointerId !== modelDrag.pointerId)) return; + modelDrag = null; + view.controls.enabled = true; + stage.style.cursor = ''; + if (event) { try { canvas.releasePointerCapture(event.pointerId); } catch { /* ignore */ } } + capturePose(); + app.panel?.onRigChanged?.(); + app.needsRender = true; + }; + canvas.addEventListener('pointerup', endModelDrag); + canvas.addEventListener('pointercancel', endModelDrag); } function togglePanel() { diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index bad4059..c26c88c 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -201,7 +201,8 @@ export function buildPanel(app, root) { app.needsRender = true; } - poseSection.add(hint('モデルをクリックすると、その場所にいちばん近いボーンが選ばれます。スライダーは「休めの姿勢からの差」です。')); + poseSection.add(hint('ボーンは上の一覧から選びます。スライダーは「休めの姿勢からの差」です。' + + 'ビューポートのぶるべーは、ドラッグで移動、Shift+ドラッグで向きを変えられます。')); // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the // main way in, so they stay folded away inside the bone section. @@ -246,10 +247,19 @@ export function buildPanel(app, root) { ? POSE_PRESETS.find((item) => item.id === presetOrId) : presetOrId; if (!preset) return; + // Some poses already raise the arms; leaving the てをふる motion running would + // add to the arm the pose just set and fold the flipper over the face, so + // switching to any pose stops that motion. + if (state.anim.mode === 'wave') { + state.anim.mode = 'off'; + app.animator.reset(); + app.needsRender = true; + } state.pose = { bones: {}, root: [0, 0, 0] }; applyPatch(state.pose, preset.pose ?? {}); if (!state.pose.root) state.pose.root = [0, 0, 0]; replace({}, 'pose'); + sync(); } function randomPose() { @@ -622,7 +632,7 @@ export function buildPanel(app, root) { onInput: (v) => { state.face.eyes.brow.height = v; app.actions.refresh('face'); }, }), length: slider({ - label: '長さ', min: 0, max: 1.6, step: 0.01, value: 1, + label: '長さ', min: 0, max: 4, step: 0.01, value: 1, format: (v) => (v < 0.02 ? '点' : v.toFixed(2)), onInput: (v) => { state.face.eyes.brow.length = v; app.actions.refresh('face'); }, }), @@ -763,6 +773,10 @@ export function buildPanel(app, root) { value: 'glasses', onChange: (value) => { setGlassesKind(value); + // Picking a kind means you mean to wear it, so put the glasses on rather + // than leaving the checkbox off and nothing showing on the face. + state.face.eyes.glasses.enabled = true; + glassesToggle.set(true); app.actions.refresh('face'); sync(); }, @@ -884,9 +898,9 @@ export function buildPanel(app, root) { { value: 'off', label: 'なし' }, { value: 'cap', label: 'かぶせ' }, { value: 'fringe', label: 'まえがみ' }, + { value: 'fringe-up', label: 'まえがみ(逆)' }, { value: 'wave', label: 'なみ' }, { value: 'side', label: 'わけめ' }, - { value: 'split', label: 'ツートン' }, ], value: 'off', onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); }, @@ -895,7 +909,7 @@ export function buildPanel(app, root) { const headMarkSliders = { size: slider({ - label: '大きさ', min: 0.4, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), + label: '大きさ', min: 0.05, max: 2.2, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.eyes.headMark.size = v; app.actions.refresh('face'); }, }), offsetX: slider({ @@ -924,7 +938,8 @@ export function buildPanel(app, root) { headMarkBox.add(headMarkColor.el); headMarkBox.add(headMarkColor2.el); headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。' - + '「ツートン」は頭を左右2色に分けます(2色目を使います)。' + + '「まえがみ(逆)」は、まえがみを上下さかさまにした形です。' + + '「わけめ」は、髪を分けた筋が入ります。' + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。')); // --- 鼻ちょうちん: the snot bubble of a sleeping face. @@ -962,6 +977,47 @@ export function buildPanel(app, root) { + '「ねている」ポーズと組み合わせると、いねむりしているように見えます。' + '泡は鼻の横に出るので、位置は「よこの位置」「たかさ」で調整できます。')); + // --- ひげ: mustaches and beards, hung under the nose. + faceSection = section(tabFace, 'ひげ'); + const beardShape = segmented({ + label: 'ひげ', + options: [ + { value: 'off', label: 'なし' }, + { value: 'scotch', label: 'ちょびひげ' }, + { value: 'kaiser', label: 'カイゼル' }, + { value: 'apron', label: 'エプロン' }, + { value: 'cat', label: 'ねこ' }, + ], + value: 'off', + onChange: (value) => { state.face.eyes.beard.shape = value; app.actions.refresh('face'); }, + }); + faceSection.add(beardShape.el); + const beardSliders = { + size: slider({ + label: '大きさ', min: 0.4, max: 2.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), + onInput: (v) => { state.face.eyes.beard.size = v; app.actions.refresh('face'); }, + }), + offsetX: slider({ + label: 'よこの位置', min: -200, max: 200, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.beard.offsetX = v; app.actions.refresh('face'); }, + }), + offsetY: slider({ + label: 'たかさ', min: -160, max: 160, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.beard.offsetY = v; app.actions.refresh('face'); }, + }), + }; + faceSection.add(beardSliders.size.el); + const beardBox = details(faceSection.body, 'こまかい設定(位置・色)'); + beardBox.add(beardSliders.offsetX.el); + beardBox.add(beardSliders.offsetY.el); + const beardColor = colorField({ + label: 'ひげの色', value: '#3a2a4a', + swatches: ['#3a2a4a', '#1c1624', '#6b4a2a', '#8a8a94'], + onChange: (v) => { state.face.eyes.beard.color = v; app.actions.refresh('face'); }, + }); + beardBox.add(beardColor.el); + beardBox.add(hint('鼻の下に、ちょびひげ・カイゼル・エプロン・ねこ、の髭を描きます。')); + // --- mouth faceSection = section(tabFace, '口'); @@ -2394,6 +2450,9 @@ export function buildPanel(app, root) { snotSize.set(state.face.eyes.snot.size); for (const [key, widget] of Object.entries(snotSliders)) widget.set(state.face.eyes.snot[key]); snotColor.set(state.face.eyes.snot.color); + beardShape.set(state.face.eyes.beard.shape ?? 'off'); + for (const [key, widget] of Object.entries(beardSliders)) widget.set(state.face.eyes.beard[key]); + beardColor.set(state.face.eyes.beard.color); }); syncers.push(() => { diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index d967af2..a450eb2 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -219,6 +219,15 @@ export function defaultState() { color: '#e3ecff', line: '#55386e', }, + // ひげ: a mustache or beard worn under the nose. `shape` is one of `off`, + // `scotch`, `kaiser`, `apron` or `cat`. Starts off. + beard: { + shape: 'off', + size: 1, + offsetX: 0, + offsetY: 0, + color: '#3a2a4a', + }, // Eyebrows are drawn into the eye texture. Off by default, because the // original character has no eyebrows. brow: { @@ -638,13 +647,15 @@ export const POSE_PRESETS = [ label: 'ねている', pose: { bones: { - // Rolled right over onto its side (Z is the sideways roll) - the body lies - // along the ground with the face turned up towards the camera. - master: [0, 0, 88], + // Flat on its back: X tips the body backwards all the way over, so the + // face looks up. The root lift keeps the back resting on the ground + // rather than sinking through it (the body's depth becomes its height). + master: [-88, 0, 0], // Both flippers rest against the body, loose. - 'arm.l': [-30, 0, 0], - 'arm.r': [-30, 0, 0], + 'arm.l': [-18, 0, 0], + 'arm.r': [-18, 0, 0], }, + root: [0, 1.5, 0], }, }, { id: 'side', label: 'よこむき', pose: { bones: { master: [0, 92, 0] } } }, diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js index 2922dfa..e06c337 100644 --- a/public/bluebey-studio/src/rig.js +++ b/public/bluebey-studio/src/rig.js @@ -51,14 +51,6 @@ export class Rig { this.controls.addEventListener('objectChange', () => { if (this.selected) this.onChange?.(this.selected); }); - - this.raycaster = new THREE.Raycaster(); - this.pointer = new THREE.Vector2(); - this.pressAt = null; - this.onPointerDown = this.handlePointerDown.bind(this); - this.onPointerUp = this.handlePointerUp.bind(this); - domElement.addEventListener('pointerdown', this.onPointerDown); - domElement.addEventListener('pointerup', this.onPointerUp); } /* ------------------------------------------------------------ selection */ @@ -76,46 +68,10 @@ export class Rig { if (!silent) this.onChange?.(this.selected); } - selectAt(event) { - const rect = this.domElement.getBoundingClientRect(); - this.pointer.set( - ((event.clientX - rect.left) / rect.width) * 2 - 1, - -((event.clientY - rect.top) / rect.height) * 2 + 1, - ); - this.raycaster.setFromCamera(this.pointer, this.controls.camera); - - const targets = this.pickTargets().filter((mesh) => mesh.visible); - const hits = this.raycaster.intersectObjects(targets, false); - if (!hits.length) return null; - - const point = hits[0].point; - let best = null; - let bestDistance = Infinity; - const world = new THREE.Vector3(); - for (const entry of this.bones) { - entry.bone.getWorldPosition(world); - const distance = world.distanceToSquared(point); - if (distance < bestDistance) { - bestDistance = distance; - best = entry.name; - } - } - if (best) this.select(best); - return best; - } - - handlePointerDown(event) { - // Remember where the press started so a drag never changes the selection. - this.pressAt = { x: event.clientX, y: event.clientY, axis: this.controls.axis }; - } - - handlePointerUp(event) { - const press = this.pressAt; - this.pressAt = null; - if (!press || press.axis) return; // pressed on the gizmo, not the model - if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 4) return; - this.selectAt(event); - } + // Clicking the model no longer reaches for a bone: the mouse is for composing + // (see the drag handling in src/main.js), and bones are picked from the panel's + // bone list, which calls `select()` directly. So there is no pointer handler on + // the canvas here at all. /* ----------------------------------------------------------------- pose */ @@ -185,8 +141,6 @@ export class Rig { } dispose() { - this.domElement.removeEventListener('pointerdown', this.onPointerDown); - this.domElement.removeEventListener('pointerup', this.onPointerUp); this.controls.detach(); this.controls.dispose(); this.helper.removeFromParent(); -- cgit v1.3.1