diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-06 09:56:21 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-06 09:56:21 +0900 |
| commit | 8f39a174d6c4415c7a4df418187090fb5e061983 (patch) | |
| tree | dbd3cf1e4f67393f029a455c4226479fbefe4804 /public/bluebey-studio/src | |
| parent | 63c1a9dfa8b04ab9725a9a49ef34cd3fc6ade56c (diff) | |
bluebey-studio: ボーンのたて回転・縦書きと吹き出しの形・線画の帽子小物・光の色
ボーンのたてスライダーが±180°で暴れる問題を修正。セリフの縦書きを禁則込みで直し、「ささやき」「思考」の吹き出しを追加。帽子・小物の輪郭線を線画のみにし、線画で帯や芯の境目が読めるよう調整。眼鏡の左右レンズ間隔、光と環境光の色を追加。ハートの下まぶた、レンズが濃いときのまばたきを修正。口メニューの項目名を整理。「顔/すべてをランダム」に眼鏡・Oの口・ひげの大きさ・目の左右位置などを追加。
Diffstat (limited to 'public/bluebey-studio/src')
| -rw-r--r-- | public/bluebey-studio/src/caption.js | 239 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 38 | ||||
| -rw-r--r-- | public/bluebey-studio/src/hats.js | 29 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 11 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 104 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 17 | ||||
| -rw-r--r-- | public/bluebey-studio/src/props.js | 34 | ||||
| -rw-r--r-- | public/bluebey-studio/src/rig.js | 56 | ||||
| -rw-r--r-- | public/bluebey-studio/src/styles.js | 60 |
9 files changed, 506 insertions, 82 deletions
diff --git a/public/bluebey-studio/src/caption.js b/public/bluebey-studio/src/caption.js index a315971..fd418da 100644 --- a/public/bluebey-studio/src/caption.js +++ b/public/bluebey-studio/src/caption.js @@ -71,6 +71,23 @@ const HANGING_PUNCTUATION = new Set(['、', '。', ',', '.', ',', '.']); const SMALL_KANA = 'ぁぃぅぇぉっゃゅょゎァィゥェォッャュョヮ'; /** + * Characters that may not open a column (行頭禁則). + * + * A closing mark such as `、` simply hangs off the previous column; `ー`, small + * kana and closing brackets are kept company by handing the preceding + * character over to the next column as well, so a column never starts with + * something that has nothing to attach to. + */ +const COLUMN_START_FORBIDDEN = new Set([ + '」', '』', ')', '〕', '】', '〉', '》', '”', '’', '・', 'ー', + ...SMALL_KANA, + ...HANGING_PUNCTUATION, +]); + +/** Characters that may not close a column (行末禁則). */ +const COLUMN_END_FORBIDDEN = new Set(['「', '『', '(', '〔', '【', '〈', '《', '“', '‘']); + +/** * Per-character offset for vertical (縦書き) text, in em. * * A column draws one glyph per cell, so a few characters need a nudge to read @@ -270,6 +287,10 @@ export function drawCaption(ctx, caption, { width, height, scale = 1, font = nul ctx.lineWidth = borderWidth; ctx.lineJoin = 'round'; ctx.lineCap = 'round'; + // A whisper is the rounded bubble with a broken outline. + if (bubbleOf(state) === 'whisper' && typeof ctx.setLineDash === 'function') { + ctx.setLineDash([borderWidth * 3, borderWidth * 2]); + } ctx.stroke(); } } @@ -332,6 +353,9 @@ export function captionToSvg(caption, { width, height, scale = 1, font = null, r const stroke = borderWidth > 0 ? ` stroke="${escapeAttribute(colorOf(state.borderColor, DEFAULT_BORDER_COLOR))}"` + ` stroke-width="${formatNumber(borderWidth, places)}" stroke-linejoin="round"` + + (bubbleOf(state) === 'whisper' + ? ` stroke-dasharray="${formatNumber(borderWidth * 3, places)} ${formatNumber(borderWidth * 2, places)}"` + : '') : ''; parts.push(`<path d="${bubble}" fill="${fill}"${stroke}/>`); } @@ -528,6 +552,10 @@ function verticalOffsetOf(ch) { * the column's length, so it never makes the block a cell taller or adds a * column of its own. `lines[i].hang` reports how many characters on that line * are hanging. + * + * The other 禁則 cases are repaired too: ー, small kana and closing brackets are + * kept off the top of a column by handing the preceding character down with + * them (追い出し), and an opening bracket is never left at the bottom of one. */ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { const limit = Number.isFinite(maxHeight) && maxHeight > 0 @@ -540,23 +568,36 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { columns.push({ text: '', count: 0 }); continue; } - let current = ''; - let count = 0; - for (const ch of chars) { - if (count >= limit) { - // A closing mark that would start a column hangs off the previous one. - if (HANGING_PUNCTUATION.has(ch)) { - current += ch; - continue; - } - columns.push({ text: current, count }); - current = ''; - count = 0; + let i = 0; + while (i < chars.length) { + // Fill the column cell by cell, then repair its two ends for 禁則処理. + const cells = []; + const hanging = []; + while (i < chars.length && cells.length < limit) { + cells.push(chars[i]); + i += 1; + } + // 行末禁則: an opening bracket may not close a column, so hand it over to + // the next one (it will open that column instead). + while (cells.length > 1 && COLUMN_END_FORBIDDEN.has(cells[cells.length - 1])) { + cells.pop(); + i -= 1; + } + // 行頭禁則 (追い出し): ー, small kana and closing brackets may not open a + // column; move the preceding character down to keep them company. + if (i < chars.length && cells.length > 1 + && COLUMN_START_FORBIDDEN.has(chars[i]) && !HANGING_PUNCTUATION.has(chars[i])) { + cells.pop(); + i -= 1; + } + // 行頭禁則 (ぶら下げ): a closing mark that would open the next column + // stays on this one, drawn just below its last cell. + while (i < chars.length && HANGING_PUNCTUATION.has(chars[i])) { + hanging.push(chars[i]); + i += 1; } - current += ch; - count += 1; + columns.push({ text: cells.join('') + hanging.join(''), count: cells.length }); } - columns.push({ text: current, count }); } const longest = columns.reduce((max, column) => Math.max(max, column.count), 0); return { @@ -565,7 +606,12 @@ function layoutVertical(text, { fontSize, lineHeight, maxHeight }) { width: fontSize, hang: [...column.text].length - column.count, })), - width: columns.length * fontSize * lineHeight, + // The block is exactly as wide as the columns that are drawn: `step` apart, + // each cell one em wide (not one whole `step`, which would leave a leading + // of dead space on the right and push the text off-centre). + width: columns.length === 0 + ? 0 + : (columns.length - 1) * fontSize * lineHeight + fontSize, height: longest * fontSize, vertical: true, }; @@ -644,6 +690,8 @@ function bubbleCommands(box, state, scale) { }; if (shape === 'none' || !(rect.w > 0) || !(rect.h > 0)) return []; + if (shape === 'think') return thinkCommands(rect, state, scale); + const radius = toNonNegative(state.radius, DEFAULT_RADIUS) * scale; const edges = shape === 'shout' ? shoutEdges(rect, scale) @@ -729,6 +777,162 @@ function shoutEdges(box, scale) { } /** + * Cloud outline for a thought bubble, plus how far its scallops bulge out. + * + * The four sides of the box are replaced by a ring of outward scallops: a + * rounded rectangle is sampled evenly by arc length and each pair of samples is + * joined by a half-ellipse bulging away from the centre. + */ +function thinkEdges(box, scale) { + const { x, y, w, h } = box; + const cx = x + w / 2; + const cy = y + h / 2; + const ring = roundedRectRing(box, Math.min(w, h) * 0.28); + const perimeter = ringLength(ring); + const pitch = Math.max(scale * 8, Math.min(w, h) * 0.42); + const count = Math.max(6, Math.round(perimeter / pitch)); + const points = sampleRing(ring, count); + const amp = Math.min((perimeter / count) * 0.5, Math.min(w, h) * 0.18); + + const edges = []; + for (let i = 0; i < points.length; i++) { + const a = points[i]; + const b = points[(i + 1) % points.length]; + const mid = { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 }; + const nx = mid.x - cx; + const ny = mid.y - cy; + const length = Math.hypot(nx, ny) || 1; + edges.push(scallopEdge(a, b, { x: nx / length, y: ny / length }, amp)); + } + return { edges, amp }; +} + +/** A closed cloud, followed by the tail: two or three shrinking puffs. */ +function thinkCommands(box, state, scale) { + const cloud = thinkEdges(box, scale); + const commands = edgesToCommands(cloud.edges); + for (const puff of thoughtTrail(box, state, scale, cloud.amp)) { + commands.push(...circleCommands(puff.x, puff.y, puff.r)); + } + return commands; +} + +/** + * The circles that trail away from a thought bubble instead of a pointed tail. + * + * They start just outside the cloud (`outer`) and shrink as they go, ending + * inside the room `layoutCaption` reserves for the tail. + */ +function thoughtTrail(box, state, scale, outer) { + const tail = tailOf(state); + if (tail === 'none') return []; + const dir = tailDirection(tail); + const diagonal = dir.x !== 0 && dir.y !== 0; + const nx = dir.x * (diagonal ? Math.SQRT1_2 : 1); + const ny = dir.y * (diagonal ? Math.SQRT1_2 : 1); + const anchor = { + x: dir.x < 0 ? box.x : dir.x > 0 ? box.x + box.w : box.x + box.w / 2, + y: dir.y < 0 ? box.y : dir.y > 0 ? box.y + box.h : box.y + box.h / 2, + }; + const span = Math.min(resolveFontSize(state, scale) * 0.9, Math.min(box.w, box.h) * 0.5); + const room = span - outer; + if (!(room > 0)) return []; + + const r1 = room * 0.22; + const r2 = room * 0.13; + const r3 = room * 0.07; + const gap = room * 0.05; + const d1 = outer + r1; + const d2 = d1 + r1 + gap + r2; + const d3 = d2 + r2 + gap + r3; + return [ + { x: anchor.x + nx * d1, y: anchor.y + ny * d1, r: r1 }, + { x: anchor.x + nx * d2, y: anchor.y + ny * d2, r: r2 }, + { x: anchor.x + nx * d3, y: anchor.y + ny * d3, r: r3 }, + ]; +} + +/** One outward scallop: a half-ellipse from `from` to `to` bulging along `normal`. */ +function scallopEdge(from, to, normal, amp) { + const k = (4 / 3) * amp; + return cubicEdge( + from, + { x: from.x + normal.x * k, y: from.y + normal.y * k }, + { x: to.x + normal.x * k, y: to.y + normal.y * k }, + to, + ); +} + +/** A circle as `M`/`C`/`Z` commands, so it can join the bubble's one path. */ +function circleCommands(cx, cy, r) { + const k = r * KAPPA; + return [ + { type: 'M', x: cx + r, y: cy }, + { type: 'C', x1: cx + r, y1: cy + k, x2: cx + k, y2: cy + r, x: cx, y: cy + r }, + { type: 'C', x1: cx - k, y1: cy + r, x2: cx - r, y2: cy + k, x: cx - r, y: cy }, + { type: 'C', x1: cx - r, y1: cy - k, x2: cx - k, y2: cy - r, x: cx, y: cy - r }, + { type: 'C', x1: cx + k, y1: cy - r, x2: cx + r, y2: cy - k, x: cx + r, y: cy }, + { type: 'Z' }, + ]; +} + +/** Points around a rounded rectangle, clockwise from the top-left arc. */ +function roundedRectRing(box, radius) { + const { x, y, w, h } = box; + const r = clamp(radius, 0, Math.min(w, h) / 2); + const points = []; + const arc = (cx, cy, from, to) => { + const steps = 6; + for (let i = 1; i <= steps; i++) { + const t = from + (to - from) * (i / steps); + points.push({ x: cx + Math.cos(t) * r, y: cy + Math.sin(t) * r }); + } + }; + + points.push({ x: x + r, y }); + points.push({ x: x + w - r, y }); + arc(x + w - r, y + r, -Math.PI / 2, 0); + points.push({ x: x + w, y: y + h - r }); + arc(x + w - r, y + h - r, 0, Math.PI / 2); + points.push({ x: x + r, y: y + h }); + arc(x + r, y + h - r, Math.PI / 2, Math.PI); + points.push({ x, y: y + r }); + arc(x + r, y + r, Math.PI, Math.PI * 1.5); + return points; +} + +/** Total length of a closed polyline's perimeter. */ +function ringLength(points) { + let total = 0; + for (let i = 0; i < points.length; i++) { + total += distance(points[i], points[(i + 1) % points.length]); + } + return total; +} + +/** `count` points spaced evenly by arc length around a closed polyline. */ +function sampleRing(points, count) { + const n = points.length; + const total = ringLength(points); + if (!(total > 0) || !(count > 0)) return []; + const out = []; + let seg = 0; + let start = 0; + let length = n > 1 ? distance(points[0], points[1 % n]) : 0; + for (let i = 0; i < count; i++) { + const target = (total * i) / count; + while (seg < n - 1 && start + length < target) { + start += length; + seg += 1; + length = distance(points[seg], points[(seg + 1) % n]); + } + const t = length > 0 ? clamp((target - start) / length, 0, 1) : 0; + out.push(lerp(points[seg], points[(seg + 1) % n], t)); + } + return out; +} + +/** * Replace part of the edge the tail points at with a spike. * * The spike is inserted *into* the outline, not appended to it: the two sides @@ -923,7 +1127,8 @@ function resolveFontSize(caption, scale) { function bubbleOf(caption) { const value = caption?.bubble; - if (value === 'round' || value === 'rect' || value === 'shout' || value === 'none') return value; + if (value === 'round' || value === 'rect' || value === 'shout' + || value === 'whisper' || value === 'think' || value === 'none') return value; return 'round'; } diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 11319c1..716fcab 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -1207,18 +1207,27 @@ function drawGlasses(ctx, eye, line, style, limits) { // the full height so even that point cannot reach the canvas border. const halfV = shades ? ry * Math.max(L.innerTop + L.topSkew, L.outerBottom) : ry; + // The face's midline: the two eyes are symmetric about it, so it is where the + // two halves of the bridge meet and what `tilt` turns the pair about. + const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2; + // `lensGap` slides the two lenses apart (positive) or together (negative) along + // the face, symmetrically about the midline. It is clamped so the inner edges may + // meet at the midline but the two lenses can never cross each other. + const inwardRoom = Math.max(0, Math.abs(midX - eye.cx) - rx); + const gap = clamp(spec.lensGap ?? 0, -inwardRoom, HUGE); // Keep the lens (and so everything that hangs off it) inside the artwork. The // rest of the file clamps against `limits` for the same reason: a mark that // reaches the canvas border gets the edge row copied across the whole plate. - const lensCx = clamp(eye.cx, limits.left + EDGE_MARGIN + rx, limits.right - EDGE_MARGIN - rx); + const lensCx = clamp( + eye.cx + out * gap, + limits.left + EDGE_MARGIN + rx, + limits.right - EDGE_MARGIN - rx, + ); const lensCy = clamp( eye.cy + L.drop + (spec.offsetY ?? 0), limits.top + EDGE_MARGIN + halfV, limits.bottom - EDGE_MARGIN - halfV, ); - // The face's midline: the two eyes are symmetric about it, so it is where the - // two halves of the bridge meet and what `tilt` turns the pair about. - const midX = (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2; // Local (u, v) -> canvas, with u outwards and v downwards. const at = (u, v) => ({ x: lensCx + out * u, y: lensCy + v }); @@ -1366,6 +1375,12 @@ export function drawEyes(ctx, p) { const lookMax = style.lookMax ?? 1; const heartScale = 2; const heartColor = style.heartColor ?? '#e0344f'; + // A lens that is effectively opaque hides the eye completely, so a blink would + // show only as a flicker of the shut artwork through the lens. While such a + // lens is on, the eyes are drawn open and no blink is visible at all. This is a + // rendering decision, not an animation one - the blink still runs underneath. + const lensOpaque = style.glasses?.enabled === true + && clamp(style.glasses.lensOpacity ?? 0, 0, 1) >= 0.99; // ほっぺ and 頭の模様 belong to the whole face, so they are drawn once here // rather than inside the per-eye loop below. 鼻ちょうちん comes along too. @@ -1388,7 +1403,7 @@ export function drawEyes(ctx, p) { const lineStyle = { line: lineColor, width: lidWidth }; // Which artwork this eye shows. `shape` is the explicit choice; older saved // states and the おまかせ rolls only set `open`/`closed`, so fall back. - const shapeKey = eyeShapeKey(spec); + const shapeKey = eyeShapeKey(lensOpaque ? { ...spec, open: 1 } : spec); const explicitShut = typeof spec.shape === 'string' && (spec.shape === 'three' || spec.shape === 'arch' || spec.shape.startsWith('line')); const isHeart = shapeKey === 'heart'; @@ -1408,7 +1423,9 @@ export function drawEyes(ctx, p) { // "shut"; lift it or the artwork would be clipped away. An explicit shut // `shape` keeps its own lid height, so the lid can be lowered onto it. const rawOpen = spec.open ?? 1; - const open = clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1); + const open = lensOpaque + ? 1 + : clamp(explicitShut ? rawOpen : (rawOpen <= 0.02 ? 1 : rawOpen), 0, 1); const lowerLid = clamp(spec.lowerLid ?? style.lowerLid ?? 0, 0, 1); // 大きさ: one knob for the iris, the heart and the shut artwork. const shapeScale = clamp(style.irisScale ?? 1, 0.4, 2); @@ -1480,10 +1497,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), 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. + // The shut artwork and a heart both reach past the eyeball, so they are only + // lid-clipped - but once the white is shown they are clipped to the eyeball + // too, so they stay inside it. Clipping the heart by its *lids* rather than by + // the eyeball is also what lets a raised lower lid cover it from below: the + // heart replaces the eyeball, but it must still sit behind both lids. ctx.save(); - if (isShutShape && !showWhite) { + if ((isShutShape || isHeart) && !showWhite) { clipLids(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); } else if (!isHeart || open < 0.999 || showWhite) { clipEye(ctx, eye, open, lowerLid, lidFlat, lidTilt * eye.towardNose); diff --git a/public/bluebey-studio/src/hats.js b/public/bluebey-studio/src/hats.js index 3fb65f3..7a54c46 100644 --- a/public/bluebey-studio/src/hats.js +++ b/public/bluebey-studio/src/hats.js @@ -32,6 +32,19 @@ const sphere = (r, w = 24, h = 16, phiStart = 0, phiLength = Math.PI * 2, thetaS ); const box = (w, h, d) => new THREE.BoxGeometry(w, h, d); +/** + * The 線画 fill a part takes, as how far it steps from the paper towards the ink + * (see `addMesh` in src/styles.js). Every hat part already gets the light default + * tone, but two parts of the *same* hat then come out the same shade - so a part + * that sits flush on another (a band on a crown) loses its seam. Tagging it with + * a darker tone puts that seam back, without touching リアル / フラット, where the + * part's own colour already draws it. + */ +function tone(mesh, strength) { + mesh.userData.tone = strength; + return mesh; +} + /** シルクハット: a tall black crown, a narrow band and a flat brim. */ function buildSilk() { const group = new THREE.Group(); @@ -39,7 +52,7 @@ function buildSilk() { const band = material('#3a2f4a'); add(group, cylinder(1.52, 1.52, 0.12, 32), black, 0, 0.06, 0); add(group, cylinder(1.00, 1.04, 1.62, 32), black, 0, 0.93, 0); - add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0); + tone(add(group, cylinder(1.05, 1.05, 0.24, 32), band, 0, 0.30, 0), 0.34); return group; } @@ -51,12 +64,12 @@ function buildCap() { // The crown: a hemisphere whose flat face sits on y = 0. const crown = add(group, sphere(1.08, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), blue, 0, 0, 0); crown.scale.set(1, 0.92, 1); - add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0); + tone(add(group, cylinder(1.10, 1.10, 0.18, 28), dark, 0, 0.09, 0), 0.30); // The bill, tipped down a little at the front. - const bill = add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92); + const bill = tone(add(group, cylinder(0.86, 0.86, 0.09, 24), dark, 0, 0.06, 0.92), 0.30); bill.scale.set(1, 1, 1.25); bill.rotation.x = -0.12; - add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0); + tone(add(group, sphere(0.13, 12, 10), dark, 0, 1.02, 0), 0.30); return group; } @@ -67,7 +80,7 @@ function buildChef() { const shade = material('#e6e3da'); // The band: a tall cylinder that sits on the head. add(group, cylinder(0.86, 0.92, 0.95, 28), white, 0, 0.475, 0); - add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0); + tone(add(group, cylinder(0.90, 0.90, 0.10, 28), shade, 0, 0.90, 0), 0.26); // The crown: a big squashed sphere ringed with lobes, so the top reads as the // classic pleated toque rather than a plain dome. const puff = add(group, sphere(1.24, 26, 20), white, 0, 1.30, 0); @@ -90,12 +103,12 @@ function buildFire() { const gold = material('#f0c24a', { metalness: 0.4, roughness: 0.4 }); const dome = add(group, sphere(1.06, 28, 18, 0, Math.PI * 2, 0, Math.PI / 2), red, 0, 0, 0); dome.scale.set(1, 0.95, 1); - const brim = add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18); + const brim = tone(add(group, cylinder(1.34, 1.34, 0.12, 28), dark, 0, 0.06, 0.18), 0.28); brim.scale.set(1, 1, 1.1); // The ridge along the top, front to back. - const crest = add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0); + const crest = tone(add(group, box(0.16, 0.42, 1.5), dark, 0, 1.02, 0), 0.28); crest.rotation.x = -0.05; - add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02); + tone(add(group, sphere(0.2, 14, 12), gold, 0, 0.62, 1.02), 0.22); return group; } diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index 23ed017..20c6c7d 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -521,9 +521,9 @@ async function init() { // 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, { tone: true }); + hatStyleRegister = (mesh) => styles.addMesh(mesh, { tone: true, lineOnly: true }); hatStyleUnregister = (mesh) => styles.removeMesh(mesh); - for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true }); + for (const mesh of hatMeshes) styles.addMesh(mesh, { tone: true, lineOnly: true }); const face = new Face({ eyeMesh: model.parts.eyeMesh, mouthMesh: model.parts.mouthMesh, @@ -980,9 +980,10 @@ async function init() { ); propRoot.add(group); // Register with the style system, so フラット and 線画 reach the props too - // and each part gets an outline hull like the body and the hats. + // and each part gets an outline hull like the body and the hats. `lineOnly` + // keeps that outline out of リアル/フラット, where the props read by shading. group.traverse((object) => { - if (object.isMesh) styles.addMesh(object); + if (object.isMesh) styles.addMesh(object, { lineOnly: true }); }); // 看板 only: paint the saved text onto the freshly built face (no-op for // every other prop, which has no writing surface). @@ -1839,6 +1840,8 @@ async function init() { updateKeyLight(); key.intensity = state.render.lightIntensity; ambient.intensity = state.render.ambient; + key.color.set(state.render.lightColor ?? '#ffffff'); + ambient.color.set(state.render.ambientColor ?? '#ffffff'); renderer.toneMappingExposure = state.render.exposure; applyBackground(); renderer.shadowMap.needsUpdate = true; diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index f7053cb..bb409ef 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -4,7 +4,7 @@ import { } from './ui.js'; import { FACE_PRESETS, POSE_PRESETS, THEMES, BUBBLE_STYLES, CAPTION_PRESETS, - defaultState, applyPatch, BEARD_KIND_DEFAULTS, + defaultState, applyPatch, BEARD_KIND_DEFAULTS, HEAD_MARK_KIND_DEFAULTS, } from './presets.js'; import { STYLE_DEFS } from './styles.js'; import { ENVIRONMENTS } from './look.js'; @@ -40,7 +40,7 @@ const SECTION_ICONS = { '見た目の調整': 'M4 8h16M4 16h16M9.5 8h.01M15 16h.01', '背景': 'M4 4.5h16a1.5 1.5 0 0 1 1.5 1.5v12a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 18V6A1.5 1.5 0 0 1 4 4.5ZM3 16l4-3.5 3.6 3 3.1-2.8 4.3 3.7M15.8 8.8h.01', '舞台': 'M4 20V9l8-5 8 5v11M4 20h16M9.5 20v-5h5v5', - 'セリフ(吹き出し)': 'M4 5h16v10H9l-4 4v-4H4V5Z', + 'セリフ(フキダシ)': 'M4 5h16v10H9l-4 4v-4H4V5Z', '画面・光': 'M12 7.5a4.5 4.5 0 1 0 0 9 4.5 4.5 0 0 0 0-9ZM12 2.5v2M12 19.5v2M2.5 12h2M19.5 12h2M5.2 5.2l1.4 1.4M17.4 17.4l1.4 1.4M18.8 5.2l-1.4 1.4M6.6 17.4 5.2 18.8', '画面の情報': 'M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16ZM12 11v5M12 8h.01', '画像': 'M4 5h16v14H4zM4 15l5-4 4 3 4-3.5 3 2.5M15.5 9h.01', @@ -172,7 +172,38 @@ export function buildPanel(app, root) { open: +rnd(0.4, 1).toFixed(2), lookX: +rnd(-0.35, 0.35).toFixed(2), lookY: +rnd(-0.25, 0.25).toFixed(2), + eyeX: Math.round(rnd(-16, 16)), }); + const randomGlasses = () => { + const kind = pick(['glasses', 'sunglasses']); + const dark = kind === 'sunglasses'; + return { + enabled: true, + kind, + frameColor: pick(['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6']), + lensColor: pick(['#2b2433', '#1a1620', '#3a2f4a', '#7a3b1e']), + lensOpacity: dark ? +rnd(0.85, 1).toFixed(2) : +rnd(0, 0.35).toFixed(2), + lensGap: Math.round(rnd(-24, 56)), + frameWidth: +rnd(0.6, 1.8).toFixed(2), + scale: +rnd(0.9, 1.15).toFixed(2), + offsetY: Math.round(rnd(-18, 18)), + tilt: Math.round(rnd(-12, 12)), + }; + }; + const randomBeard = (shape) => { + const base = BEARD_KIND_DEFAULTS[shape] ?? { size: 1, offsetY: 0, spacing: 0 }; + if (shape === 'off') return { shape }; + return { + shape, + ...base, + size: +((base.size ?? 1) * rnd(0.8, 1.25)).toFixed(2), + offsetY: Math.round((base.offsetY ?? 0) + rnd(-16, 16)), + }; + }; + const randomHeadMark = () => { + const shape = pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']); + return { shape, ...(HEAD_MARK_KIND_DEFAULTS[shape] ?? {}) }; + }; const randomFacePatch = () => { const beardShape = pick(['off', 'scotch', 'kaiser', 'cat']); return { @@ -180,8 +211,10 @@ export function buildPanel(app, root) { left: randomEye(), right: randomEye(), cheeks: { enabled: Math.random() < 0.5 }, - beard: { shape: beardShape, ...(BEARD_KIND_DEFAULTS[beardShape] ?? { size: 1, offsetY: 0, spacing: 0 }) }, - headMark: { shape: pick(['off', 'cap', 'fringe', 'fringe-up', 'curve']) }, + // 眼鏡・サングラスも要素に含める(かける/かけない、各種設定)。 + glasses: Math.random() < 0.5 ? { enabled: false } : randomGlasses(), + beard: randomBeard(beardShape), + headMark: randomHeadMark(), brow: { enabled: true, // Random brows are line drawings, never ゴル風's loaded picture. @@ -198,8 +231,11 @@ export function buildPanel(app, root) { // The mouth takes the height and the tilt too, so the face really changes. mouth: { smile: +rnd(-1, 1.2).toFixed(2), + // ときどき丸く開く口(O)にする。 + round: Math.random() < 0.3 ? +rnd(0.35, 1).toFixed(2) : 0, open: +rnd(0, 0.5).toFixed(2), width: +rnd(0.7, 1.3).toFixed(2), + thickness: +rnd(0.7, 1.6).toFixed(2), tilt: Math.round(rnd(-12, 12)), offsetY: Math.round(rnd(-16, 16)), }, @@ -532,8 +568,8 @@ export function buildPanel(app, root) { const EYE_ICONS = { both: '<circle cx="7" cy="12" r="3.8"/><circle cx="17" cy="12" r="3.8"/><circle cx="7" cy="12" r="1.3" fill="currentColor" stroke="none"/><circle cx="17" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - left: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', - right: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + left: '<path d="M3 12h4"/><path d="M5 9.5l2.5 2.5-2.5 2.5"/><circle cx="15" cy="12" r="3.8"/><circle cx="15" cy="12" r="1.3" fill="currentColor" stroke="none"/>', + right: '<path d="M21 12h-4"/><path d="M19 9.5l-2.5 2.5 2.5 2.5"/><circle cx="9" cy="12" r="3.8"/><circle cx="9" cy="12" r="1.3" fill="currentColor" stroke="none"/>', lid: '<path d="M3 14c2.5-6.5 15.5-6.5 18 0"/><path d="M6 16v3M12 16v3M18 16v3"/>', }; // Each eye group carries the same controls. Linked, one group ("両目") drives @@ -809,7 +845,8 @@ export function buildPanel(app, root) { faceSection.add(hint('「まぶた」は目のグループごとに設定できます。「左右を連動させる」を入れると「両目」の設定が1つだけ出て、' + 'まぶた・目の形・大きさ・白目・光彩・目線が両目でそろいます。' - + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。')); + + 'はずすと「左の目」「右の目」の設定がそれぞれに出て、目線のパッドも2つになります。' + + '「左の目」「右の目」は、ぶるべー自身から見た左右です(正面から見ると、画面では左右が入れ替わって見えます)。')); const eyeColors = { white: colorField({ label: '白目', value: '#ffffff', onChange: (v) => { state.face.eyes.white = v; app.actions.refresh('face'); } }), @@ -1013,6 +1050,10 @@ export function buildPanel(app, root) { format: (v) => (v <= 0.005 ? '透明' : v.toFixed(2)), onInput: (v) => { state.face.eyes.glasses.lensOpacity = v; app.actions.refresh('face'); }, }), + lensGap: slider({ + label: '左右レンズの間隔', min: -40, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + onInput: (v) => { state.face.eyes.glasses.lensGap = v; app.actions.refresh('face'); }, + }), frameWidth: slider({ label: 'フレームの太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.eyes.glasses.frameWidth = v; app.actions.refresh('face'); }, @@ -1032,6 +1073,7 @@ export function buildPanel(app, root) { glassesBox.add(glassesSliders.frameWidth.el); glassesBox.add(glassesSliders.offsetY.el); glassesBox.add(glassesSliders.tilt.el); + glassesBox.add(glassesSliders.lensGap.el); const glassesFrameColor = colorField({ label: 'フレームの色', value: '#2a1e33', swatches: ['#2a1e33', '#12101a', '#7a5c2e', '#c9c2d6'], @@ -1127,7 +1169,15 @@ export function buildPanel(app, root) { { value: 'curve', label: 'カーブ' }, ], value: 'off', - onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); sync(); }, + onChange: (value) => { + const mark = state.face.eyes.headMark; + mark.shape = value; + // Entering a shape applies its own starting size (ぎざぎざ is smaller and + // shallower than the shared defaults). Other shapes just keep the values. + Object.assign(mark, HEAD_MARK_KIND_DEFAULTS[value] ?? {}); + app.actions.refresh('face'); + sync(); + }, }); faceSection.add(headMarkShape.el); @@ -1295,14 +1345,14 @@ export function buildPanel(app, root) { const cornerAngleFor = (smile) => Math.round(31 - 39 * Math.max(-1, Math.min(1.4, smile))); const mouthSliders = { - smile: slider({ label: '笑いの深さ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), - open: slider({ label: '口の開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), + smile: slider({ label: '曲げ', min: -1, max: 1.4, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.smile = v; state.face.mouth.cornerAngle = cornerAngleFor(v); app.actions.refresh('face'); sync(); } }), + open: slider({ label: '開き', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.open = v; app.actions.refresh('face'); } }), round: slider({ label: 'Oの大きさ', min: 0, max: 1, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.round = v; app.actions.refresh('face'); } }), - width: slider({ label: '口の幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), - length: slider({ label: '口の長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }), - thickness: slider({ label: '口の太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), - tilt: slider({ label: '口の傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), - offsetY: slider({ label: '口の高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), + width: slider({ label: '幅', min: 0.2, max: 1.6, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.width = v; app.actions.refresh('face'); } }), + length: slider({ label: '長さ', min: 0.2, max: 2.5, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.length = v; app.actions.refresh('face'); } }), + thickness: slider({ label: '太さ', min: 0.2, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.thickness = v; app.actions.refresh('face'); } }), + tilt: slider({ label: '傾き', min: -30, max: 30, step: 1, value: 0, format: (v) => `${Math.round(v)}°`, onInput: (v) => { state.face.mouth.tilt = v; app.actions.refresh('face'); } }), + offsetY: slider({ label: '高さ', min: -140, max: 140, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.mouth.offsetY = v; app.actions.refresh('face'); } }), tongue: slider({ label: '舌の大きさ', min: 0, max: 1.6, step: 0.01, value: 0.96, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tongue = v; app.actions.refresh('face'); } }), tonguePos: slider({ label: '舌の位置', min: 0.05, max: 0.95, step: 0.01, value: 0.84, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.tonguePos = v; app.actions.refresh('face'); } }), corners: slider({ label: '口角の長さ', min: 0, max: 1.6, step: 0.01, value: 0.71, format: (v) => v.toFixed(2), onInput: (v) => { state.face.mouth.corners = v; app.actions.refresh('face'); } }), @@ -1316,7 +1366,7 @@ export function buildPanel(app, root) { round: ['round', 'width', 'thickness', 'tongue', 'tilt', 'offsetY'], }; // DOM order is fixed here; the mode only shows or hides rows. - const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tongue', 'tonguePos', 'corners', 'cornerAngle', 'tilt', 'offsetY']; + const mouthRowOrder = ['round', 'smile', 'length', 'width', 'thickness', 'open', 'tilt', 'offsetY', 'tongue', 'tonguePos', 'corners', 'cornerAngle']; const mouthMode = segmented({ label: '口の形', options: [ @@ -1337,7 +1387,7 @@ export function buildPanel(app, root) { faceSection.add(mouthMode); for (const key of mouthRowOrder) faceSection.add(mouthSliders[key].el); faceSection.add(hint('「口の形」を切り替えると、その形で使うスライダーだけが出ます。' - + 'ふつうの口は笑いの深さと口の開き、丸く開く口(O)はOの大きさで形が決まります。')); + + 'ふつうの口は「曲げ」と「開き」、丸く開く口(O)は「Oの大きさ」で形が決まります。')); faceSection.add(buttons({ items: [{ id: 'mouthreset', @@ -1361,7 +1411,7 @@ export function buildPanel(app, root) { /* ---------------------------------------------------------------- caption */ - const captionSection = section(tabScene, 'セリフ(吹き出し)'); + const captionSection = section(tabScene, 'セリフ(フキダシ)'); // Two bubbles share this one set of controls: the selector says which bubble // they are editing, and every handler below reads the active one when it runs, @@ -1585,7 +1635,7 @@ export function buildPanel(app, root) { }; for (const widget of Object.values(flapSliders)) flapSection.add(widget.el); flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。' - + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); + + '「シーン」タブの「セリフ(フキダシ)」の文とは別で、こちらは長さにだけ使います。')); // --- 見る先 (moved here, next to the face controls, from the pose tab) ---- const lookAtSection = faceTabSections.look; @@ -2510,9 +2560,23 @@ export function buildPanel(app, root) { label: '露光', min: 0.4, max: 2, step: 0.02, value: 1, format: (v) => v.toFixed(2), onInput: (v) => { state.render.exposure = v; app.actions.refresh('render'); }, }); + const lightColorField = colorField({ + label: '光の色', value: '#ffffff', + swatches: ['#ffffff', '#fff3d6', '#d6e8ff', '#ffd9c9', '#e6dcff'], + onChange: (v) => { state.render.lightColor = v; app.actions.refresh('render'); }, + }); + const ambientColorField = colorField({ + label: '環境光の色', value: '#ffffff', + swatches: ['#ffffff', '#d9d0f2', '#cfe6ff', '#ffe6cc', '#e8ffe0'], + onChange: (v) => { state.render.ambientColor = v; app.actions.refresh('render'); }, + }); lightSection.add(lightIntensitySlider.el); lightSection.add(ambientSlider.el); + lightSection.add(lightColorField.el); + lightSection.add(ambientColorField.el); lightSection.add(exposureSlider.el); + lightSection.add(hint('「光の色」でライトの色、「環境光の色」で影側の色を変えられます。' + + 'テクスチャ(環境)の映りこみは「見た目」タブの「環境の映りこみ」で調整できます。')); /* ----------------------------------------------------------------- camera */ @@ -2905,6 +2969,8 @@ export function buildPanel(app, root) { }); lightIntensitySlider.set(state.render.lightIntensity ?? 2.1); ambientSlider.set(state.render.ambient ?? 0.9); + lightColorField.set(state.render.lightColor ?? '#ffffff'); + ambientColorField.set(state.render.ambientColor ?? '#ffffff'); exposureSlider.set(state.render.exposure ?? 1); const line = ['lineart', 'outline'].includes(state.render.style); outlineToggle.el.querySelector('input').disabled = line; diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 6bcf724..64480a9 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -81,6 +81,15 @@ export const BEARD_KIND_DEFAULTS = { }; /** + * Per-shape starting values for 髪 (headMark), applied when a shape is picked. + * `teeth` only matters for ぎざぎざ, so its default is set here rather than on + * the shared `size`/`teeth` fields. + */ +export const HEAD_MARK_KIND_DEFAULTS = { + fringe: { size: 0.83, teeth: 0.06 }, +}; + +/** * 見えない壁 (the invisible wall) settings. The studio offers two, so a corner (or * two opposite cuts) can be made. A wall's position is kept *relative to the * character*, so moving ぶるべー carries its walls along (see src/clip.js). @@ -155,6 +164,9 @@ export function defaultState() { lightElevation: 40, lightIntensity: 2.1, ambient: 0.9, + // The colour of the main light and of the ambient (sky) light. + lightColor: '#ffffff', + ambientColor: '#ffffff', exposure: 1, // Body colours. A theme fills these in; the colour pickers then adjust // them one part at a time, so a theme is a starting point, not a mode. @@ -226,7 +238,7 @@ export function defaultState() { // 帽子 (a hat on the head). `kind` is one of HAT_LIBRARY in src/hats.js. // `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', custom: false, x: 0, z: 0, height: 0, tiltX: 0, tiltZ: 0 }, + hat: { kind: 'none', custom: false, x: 0, z: 0, height: -0.7, tiltX: 0, tiltZ: 0 }, eyes: { source: 'parametric', // parametric | opened | closed | ... (hand-drawn) linked: true, // move both eyes together @@ -329,6 +341,7 @@ export function defaultState() { frameWidth: 1, // multiplier on the frame stroke (EYE_LAYOUT.lidStroke) lensColor: '#2b2433', lensOpacity: 0.22, // 0..1; 眼鏡 is nearly clear, サングラス dark + lensGap: 0, // px, + moves the two lenses further apart scale: 1, // lens size multiplier offsetY: 0, // px, artwork units (positive = down) tilt: 0, // degrees, both lenses turn together @@ -867,6 +880,8 @@ export const BUBBLE_STYLES = [ { value: 'round', label: 'まる' }, { value: 'rect', label: 'しかく' }, { value: 'shout', label: 'さけぶ' }, + { value: 'whisper', label: 'ささやき' }, + { value: 'think', label: '思考' }, { value: 'none', label: 'なし' }, ]; diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js index 9fb3e49..a513546 100644 --- a/public/bluebey-studio/src/props.js +++ b/public/bluebey-studio/src/props.js @@ -99,6 +99,18 @@ function addMesh(parent, geometry, material, x = 0, y = 0, z = 0) { return mesh; } +/** + * The 線画 fill a part takes, as how far it steps from the paper towards the ink + * (see `addMesh` in src/styles.js). A prop is paper in 線画, so two parts of a prop + * that meet flush - the pencil's lead in its wood, a can's label on its body - come + * out as one white shape. Tagging one of them with a tone puts the seam back; it + * has no effect on リアル / フラット, where the part's own colour draws it. + */ +function tone(mesh, strength) { + mesh.userData.tone = strength; + return mesh; +} + /** A cuboid, sized as [width, height, depth] and centred on `at`. */ function addBox(parent, material, size, at = [0, 0, 0]) { const geometry = new THREE.BoxGeometry(size[0], size[1], size[2]); @@ -235,8 +247,8 @@ const PLANT_BRANCHES = [ */ const buildPlant = prop((group, material) => { addCylinder(group, material('accent'), 0.40, 0.28, 0.56, [0, 0.28, 0], 14); - addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14); - addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14); + tone(addCylinder(group, material('accent', 0.88), 0.44, 0.42, 0.12, [0, 0.56, 0], 14), 0.18); + tone(addCylinder(group, material('wood', 0.75), 0.37, 0.37, 0.06, [0, 0.61, 0], 14), 0.28); for (const branch of PLANT_BRANCHES) { addStrut(group, material('leaf', 0.6), [0, 0.56, 0], branch.end, 0.035); @@ -264,13 +276,13 @@ const buildBox = prop((group, material, options) => { back.position.set(0, 1.00, -0.62); back.rotation.x = flapsDown ? 0 : -1.05; group.add(back); - addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]); + tone(addBox(back, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, 0.31]), 0.20); const front = new THREE.Group(); front.position.set(0, 1.00, 0.62); front.rotation.x = flapsDown ? 0 : 1.05; group.add(front); - addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]); + tone(addBox(front, material('wood', 0.72), [1.20, 0.07, 0.62], [0, 0.035, -0.31]), 0.20); if (flapsDown) addBox(group, material('paper', 0.95), [0.18, 0.05, 1.26], [0, 1.095, 0]); }); @@ -287,10 +299,10 @@ const buildBox = prop((group, material, options) => { const buildCan = prop((group, material) => { addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.025, 0], 16); addCylinder(group, material('metal'), 0.145, 0.145, 0.40, [0, 0.20, 0], 16); - addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16); + tone(addCylinder(group, material('accent', 0.85), 0.148, 0.148, 0.16, [0, 0.21, 0], 16), 0.20); addCylinder(group, material('metal'), 0.153, 0.153, 0.05, [0, 0.40, 0], 16); // A darker disc just inside the rim so the mouth reads as empty, not capped. - addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16); + tone(addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16), 0.30); // The one lid, peeled back on a hinge at the back of the mouth so its // underside shows. The hinge sits *on* the can's back rim and the disc is @@ -356,13 +368,15 @@ const buildFuton = prop((group, material) => { /** 鉛筆: an oversized pencil standing on its eraser, point up. */ const buildPencil = prop((group, material) => { const r = 0.13; - addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6); - addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6); + // Eraser, ferrule and lead are each a band of the same width against the body, + // so without a tone of their own they vanish into it in 線画. + tone(addCylinder(group, material('accent', 0.85), r * 0.9, r * 0.98, 0.16, [0, 0.08, 0], 6), 0.20); + tone(addCylinder(group, material('metal', 0.9), r, r, 0.12, [0, 0.22, 0], 6), 0.24); addCylinder(group, material('accent'), r, r, 1.30, [0, 0.93, 0], 6); addCylinder(group, material('wood'), 0, r, 0.30, [0, 1.73, 0], 6); // The lead's wide base sinks into the cone (whose tip is at y = 1.88), so the // two look joined, while its point still pokes out past the wood. - addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6); + tone(addCylinder(group, material('metal', 0.3), 0, r * 0.3, 0.12, [0, 1.86, 0], 6), 0.45); }); /** コップ: a mug with a rim, a handle and a little drink inside. */ @@ -396,7 +410,7 @@ const buildEraser = prop((group, material) => { const depth = 0.5; const half = 0.15; addBox(group, material('paper'), [width, half, depth], [0, half / 2, 0]); - addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]); + tone(addBox(group, material('accent', 0.9), [width, half, depth], [0, half + half / 2, 0]), 0.22); }); export const PROP_LIBRARY = [ diff --git a/public/bluebey-studio/src/rig.js b/public/bluebey-studio/src/rig.js index 1afe669..e3c8a8e 100644 --- a/public/bluebey-studio/src/rig.js +++ b/public/bluebey-studio/src/rig.js @@ -16,6 +16,21 @@ const DEG = 180 / Math.PI; /** three sanitises node names, so `armsupport.l` arrives as `armsupportl`. */ const normalizeName = (name) => (name ?? '').replace(/[.\s]/g, '').toLowerCase(); +/** + * The same angle shifted by whole turns to sit next to `reference`. On an exact + * half-turn tie the lower (smaller-magnitude) value wins, which keeps the + * canonical -180..180 reading when two are equally close. + */ +const nearestAngle = (angle, reference) => { + const turns = (reference - angle) / 360; + const low = angle + 360 * Math.floor(turns); + const high = angle + 360 * Math.ceil(turns); + return Math.abs(low - reference) <= Math.abs(high - reference) ? low : high; +}; + +/** True when two quaternions describe the same rotation (double cover included). */ +const sameRotation = (a, b) => Math.abs(Math.abs(a.dot(b)) - 1) < 1e-6; + export class Rig { constructor({ bones, scene, camera, domElement, orbit, pickTargets, onChange = null }) { this.bones = bones; @@ -31,6 +46,12 @@ export class Rig { this.pickTargets = pickTargets; this.onChange = onChange; this.selected = null; + // The delta last handed to (or written by) the UI, in degrees. An XYZ Euler + // has several equivalent triples for one rotation, so getDelta returns the + // one closest to this. That keeps a single-axis drag smooth through the + // wrap at 90 deg (the middle axis) instead of jumping to the gimbal-flipped + // twin, which made the sliders and the next setDelta spin the character. + this.lastDelta = new Map(); this.controls = new TransformControls(camera, domElement); this.controls.setMode('rotate'); @@ -87,8 +108,35 @@ export class Rig { const entry = this.byName.get(name); if (!entry) return { x: 0, y: 0, z: 0 }; const delta = entry.rest.clone().invert().multiply(entry.bone.quaternion); - const euler = new THREE.Euler().setFromQuaternion(delta, 'XYZ'); - return { x: euler.x * DEG, y: euler.y * DEG, z: euler.z * DEG }; + const base = new THREE.Euler().setFromQuaternion(delta, 'XYZ'); + const reference = this.lastDelta.get(entry) ?? { x: 0, y: 0, z: 0 }; + + // For one rotation an XYZ Euler has a second solution - its "flipped" twin + // `(x+180, 180-y, z+180)`. Three always returns the one whose middle angle + // stays within +/-90, so past 90 the twin is what continues the drag; offer + // both and keep whichever is closest to the last value. Near gimbal lock the + // twin no longer reproduces the rotation, so it is dropped by the check. + const candidates = [{ x: base.x, y: base.y, z: base.z }]; + const flipped = new THREE.Euler(base.x + Math.PI, Math.PI - base.y, base.z + Math.PI, 'XYZ'); + if (sameRotation(delta, new THREE.Quaternion().setFromEuler(flipped))) { + candidates.push({ x: flipped.x, y: flipped.y, z: flipped.z }); + } + + let best = { x: 0, y: 0, z: 0 }; + let bestDistance = Infinity; + for (const candidate of candidates) { + const x = nearestAngle(candidate.x * DEG, reference.x); + const y = nearestAngle(candidate.y * DEG, reference.y); + const z = nearestAngle(candidate.z * DEG, reference.z); + const distance = (x - reference.x) ** 2 + (y - reference.y) ** 2 + (z - reference.z) ** 2; + if (distance < bestDistance) { + bestDistance = distance; + best = { x, y, z }; + } + } + + this.lastDelta.set(entry, best); + return { ...best }; } setDelta(name, { x = 0, y = 0, z = 0 }) { @@ -98,6 +146,10 @@ export class Rig { new THREE.Euler(x * RAD, y * RAD, z * RAD, 'XYZ'), ); entry.bone.quaternion.copy(entry.rest).multiply(delta); + // Remember exactly what the UI asked for so getDelta can hand it straight + // back (this keeps applyPose/getPose an exact round-trip). Keyed by the bone + // entry, not the name, so a raw name and its normalised alias agree. + this.lastDelta.set(entry, { x, y, z }); } /** `pose.bones` maps a bone name to `[x, y, z]` degrees; `pose.root` is a translation. */ diff --git a/public/bluebey-studio/src/styles.js b/public/bluebey-studio/src/styles.js index 8efdf71..2e4f62d 100644 --- a/public/bluebey-studio/src/styles.js +++ b/public/bluebey-studio/src/styles.js @@ -112,9 +112,13 @@ export class Styles { this.paperMaterial = new THREE.MeshBasicMaterial({ color: paper, toneMapped: false }); // A hat is not flat: in a line drawing a white hat on a white head merges // into one shape, so hats get a light tone instead of paper (see addMesh). - this.toneMaterial = new THREE.MeshBasicMaterial({ color: toneOf(paper), toneMapped: false }); - /** Runtime meshes that want the tone fill rather than paper in 線画. */ - this.toneMeshes = new Set(); + // The tone is per-mesh (`toneMeshes`, mesh -> material) and the amount it + // steps from the paper towards the ink is cached by strength in + // `toneMaterials`, so a part on a same-coloured neighbour (a hat band, a + // pencil's lead) can ask for a darker fill and its seam then reads. + this.toneMaterials = new Map(); + /** Runtime meshes that want a tone fill rather than paper in 線画. */ + this.toneMeshes = new Map(); this.invisibleMaterial = new THREE.MeshBasicMaterial({ colorWrite: false, depthWrite: true }); this.outlineWidth = outlineWidth; @@ -159,6 +163,25 @@ export class Styles { return entry.material; } + /** + * The flat fill a runtime mesh wears instead of paper in 線画, cached by how far + * it steps from the paper towards the ink. `userData.tone` on the mesh (set by + * the builder) carries the amount; the hat default comes from `options.tone`. + */ + toneMaterialFor(strength = DEFAULT_TONE) { + const key = String(strength); + let material = this.toneMaterials.get(key); + if (!material) { + material = new THREE.MeshBasicMaterial({ + color: toneOf(this.paper, strength), + toneMapped: false, + }); + material.userData.tone = strength; + this.toneMaterials.set(key, material); + } + return material; + } + createOutlines() { const hulls = []; for (const mesh of this.meshes) { @@ -193,13 +216,20 @@ export class Styles { * styles and gets an outline hull like the GLB parts. `removeMesh` undoes it. * * `options.tone` gives the mesh the light tone fill in 線画 instead of paper, - * for parts whose shape would otherwise merge with the body (the hats). + * for parts whose shape would otherwise merge with the body (the hats). A + * number instead of `true` picks how far to step towards the ink, and the + * builder can override it per mesh with `userData.tone` - which is how a part + * draws its seam against a same-coloured neighbour. + * + * `options.lineOnly` keeps the hull to the line-art styles, so a runtime mesh + * (a hat, a prop) gets its ink outline in 線画 but none in リアル / フラット. */ addMesh(mesh, options = {}) { if (!mesh || this.originals.has(mesh)) return; this.originals.set(mesh, mesh.material); this.meshes.push(mesh); - if (options.tone) this.toneMeshes.add(mesh); + const tone = mesh.userData.tone ?? options.tone; + if (tone) this.toneMeshes.set(mesh, this.toneMaterialFor(tone === true ? DEFAULT_TONE : tone)); const kind = this.kindOf(mesh); if (!OUTLINE_SKIP.has(kind)) { const overlay = OUTLINE_SILHOUETTE_ONLY.has(kind); @@ -214,7 +244,7 @@ export class Styles { if (overlay) hull.renderOrder = -1; hull.userData.kind = kind; hull.userData.source = mesh; - hull.userData.lineOnly = OUTLINE_LINE_ONLY.has(kind); + hull.userData.lineOnly = options.lineOnly === true || OUTLINE_LINE_ONLY.has(kind); hull.userData.shadedOnly = OUTLINE_SHADED_ONLY.has(kind); mesh.parent?.add(hull); this.outlineMeshes.push(hull); @@ -264,7 +294,7 @@ export class Styles { // the leaves - is drawn by the screen-space outline, so no part needs a // material trick of its own any more. material = value === 'lineart' - ? (this.toneMeshes.has(mesh) ? this.toneMaterial : this.paperMaterial) + ? (this.toneMeshes.get(mesh) ?? this.paperMaterial) : this.invisibleMaterial; } else if (value === 'flat') { material = this.toonFor(original); @@ -325,7 +355,9 @@ export class Styles { setPaper(color) { this.paper = color; this.paperMaterial.color.set(color); - this.toneMaterial.color.copy(toneOf(color)); + for (const material of this.toneMaterials.values()) { + material.color.copy(toneOf(color, material.userData.tone)); + } } setOutlineVisible(visible) { @@ -379,7 +411,8 @@ export class Styles { for (const entry of this.hullMaterials.values()) entry.material.dispose(); this.hullMaterials.clear(); this.paperMaterial.dispose(); - this.toneMaterial.dispose(); + for (const material of this.toneMaterials.values()) material.dispose(); + this.toneMaterials.clear(); this.invisibleMaterial.dispose(); this.gradientMap.dispose(); for (const material of this.toonMaterials.values()) material.dispose(); @@ -387,9 +420,12 @@ export class Styles { } } -/** A step towards the ink, for the light fill a hat gets in 線画. */ -function toneOf(paper) { - return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), 0.14); +/** How far the hat default steps from the paper towards the ink in 線画. */ +const DEFAULT_TONE = 0.14; + +/** A step towards the ink, for the flat fill a runtime mesh gets in 線画. */ +function toneOf(paper, strength = DEFAULT_TONE) { + return new THREE.Color(paper).lerp(new THREE.Color('#2a1e33'), strength); } /** A 3-step ramp gives crisper cartoon bands than the shader's default. */ |
