diff options
| author | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-03 13:39:29 +0900 |
|---|---|---|
| committer | Yasutake Yohei <61961825+yasutakeyohei@users.noreply.github.com> | 2026-10-03 13:39:29 +0900 |
| commit | 34032981f60848940640e5741c9ced41a550c8f1 (patch) | |
| tree | 907e76722f9a62cd24c3093047554417ae76b64c /public/bluebey-studio/src | |
| parent | 58a00c403ccf4fc114a9b46d89925ea54d574e2a (diff) | |
public/bluebey-studio: ポーズ追加・シーンタブ新設・タブのアイコン化
- ポーズ例に「きおつけ」を追加し、おじぎ/ふかくおじぎを両手下げに、
くびかしげは左足を接地させたままにするよう補正、「てをふる」の動き
を追加、全身の移動スライダーの範囲を拡大
- 「表情」タブを「顔」に改名し、「背景」「舞台」「セリフ」「画面・光」
「画面の情報」をまとめた「シーン」タブを新設
- 各タブをアイコンのみの表示にし、タブらしい見た目に変更(名前はツール
チップと読み上げラベルに)
- 起動時の注意書きと /bluebey/ ページの禁止事項を「イメージを損なう
ような利用(たとえば、攻撃的・差別的・性的・過激な利用、誹謗中傷
など)」に変更
- これまでの未コミット分(背景の追加とWebP化、眼鏡・サングラス、ほっぺ
・頭の模様、缶詰、看板、集中線、輪郭線のズーム追従、スマホ対応、
共有など)を反映
- タッチ用の操作案内の <p> の閉じ位置を修正
Diffstat (limited to 'public/bluebey-studio/src')
| -rw-r--r-- | public/bluebey-studio/src/animation.js | 12 | ||||
| -rw-r--r-- | public/bluebey-studio/src/background.js | 34 | ||||
| -rw-r--r-- | public/bluebey-studio/src/faceArt.js | 245 | ||||
| -rw-r--r-- | public/bluebey-studio/src/main.js | 11 | ||||
| -rw-r--r-- | public/bluebey-studio/src/panel.js | 141 | ||||
| -rw-r--r-- | public/bluebey-studio/src/presets.js | 57 | ||||
| -rw-r--r-- | public/bluebey-studio/src/props.js | 63 | ||||
| -rw-r--r-- | public/bluebey-studio/src/style.css | 129 | ||||
| -rw-r--r-- | public/bluebey-studio/src/ui.js | 17 |
9 files changed, 514 insertions, 195 deletions
diff --git a/public/bluebey-studio/src/animation.js b/public/bluebey-studio/src/animation.js index 4446878..47f1cfb 100644 --- a/public/bluebey-studio/src/animation.js +++ b/public/bluebey-studio/src/animation.js @@ -111,6 +111,18 @@ export class Animator { return { bones, root }; } + if (mode === 'wave') { + // One flipper is held up and sweeps back and forth, with the hand lagging a + // little behind it; the body and the other flipper rock along gently. The + // +84 X holds the arm up on its own, so the wave reads from a plain stance. + const wave = Math.sin(t * 3.2); + add('arm.l', 84 + wave * 14, 0, wave * 12); + add('hand.l', 0, 0, Math.sin(t * 3.2 + 0.6) * 22); + add('arm.r', 0, 0, Math.sin(t * 1.6 + 1) * 4); + add('master', Math.sin(t * 1.6) * 1.2, 0, Math.sin(t * 1.6 + 0.4) * 3); + return { bones, root }; + } + // Default: gentle breathing, as if standing there alive. add('master', Math.sin(t * 1.5) * 1.3, 0, Math.sin(t * 0.81) * 1.6); add('arm.l', 0, 0, Math.sin(t * 1.28) * 4.5); diff --git a/public/bluebey-studio/src/background.js b/public/bluebey-studio/src/background.js index 72bc396..adc7f94 100644 --- a/public/bluebey-studio/src/background.js +++ b/public/bluebey-studio/src/background.js @@ -16,8 +16,9 @@ /** The library already downloaded into `assets/backgrounds/` (see CREDITS.json). * Most are Poly Haven *HDRIs* - CC0 photographs of real places - so each one comes * with a floor, a horizon and perspective, which is what gives a picture depth - * that a flat wall texture cannot. `mutoujima` is the exception: an original - * illustration drawn for this studio by the author, not a third-party CC0 photo. */ + * that a flat wall texture cannot. `mutoujima`, `dokan` and `uchu` are the + * exceptions: original illustrations drawn for this studio by the author, not + * CC0 photos. */ export const BACKGROUND_PRESETS = [ { name: 'empty_warehouse_01', label: '倉庫' }, { name: 'ballroom', label: '広間' }, @@ -32,6 +33,8 @@ export const BACKGROUND_PRESETS = [ { name: 'spiaggia_di_mondello', label: '海岸' }, // ポリヘイブンの写真ではなく、作者(安竹洋平)が描き起こしたオリジナルの一枚絵。 { name: 'mutoujima', label: '無人島' }, + { name: 'dokan', label: '土管のある空き地' }, + { name: 'uchu', label: '宇宙船の中' }, // 「ぶるべーを探せ!」用。物がたくさん詰まっていて、同じ形が何度も出てくる場所。 { name: 'abandoned_factory_canteen_01', label: '廃工場の食堂' }, { name: 'basement_boxing_ring', label: '地下室のリング' }, @@ -471,12 +474,22 @@ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) { return dataUrl; } - /** Japanese, actionable text for the ways `getUserMedia` usually fails. */ + /* Shown whenever the page is not a secure context. This is a fact about the + * web platform, not about any one browser, so the wording must not point a + * finger at the browser the user happens to be holding. */ + const INSECURE_CAMERA_REASON = + 'この開き方(http のアドレス)では、どのブラウザでもカメラを使えません。https のサイトか localhost で開いてください。'; + + /** + * Japanese, actionable text for the ways `getUserMedia` usually fails. On a + * phone this toast is all the user has to go on, so each case names the exact + * thing to tap instead of just stating that something went wrong. + */ function cameraFailureReason(error) { switch (error?.name) { case 'NotAllowedError': case 'PermissionDeniedError': - return 'カメラの使用が許可されていません'; + return 'カメラが許可されていません。URLバーのアイコン→カメラ→「許可」に変え(一度「ブロック」するとブラウザは覚えています)、端末の設定でもアプリに許可してください(Android: 設定→アプリ→Brave→権限→カメラ/iOS: 設定→Brave→カメラ)。'; case 'NotFoundError': case 'DevicesNotFoundError': return '使えるカメラが見つかりませんでした'; @@ -487,7 +500,9 @@ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) { case 'ConstraintNotSatisfiedError': return '指定したカメラを使用できません'; case 'SecurityError': - return 'カメラを使うには https か localhost が必要です'; + // No `mediaDevices` at all is the usual symptom, but this covers the + // same cause when a sandboxed frame raises the error instead. + return INSECURE_CAMERA_REASON; default: return 'カメラを起動できませんでした'; } @@ -496,7 +511,14 @@ export function createBackdrop({ stage, resolveUrl, onNeedsRender } = {}) { async function startCamera(facing = 'environment') { const mediaDevices = globalThis.navigator?.mediaDevices; if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function') { - return { ok: false, reason: 'カメラを使うには https か localhost が必要です' }; + // An insecure page has no `mediaDevices` at all. Say so plainly rather + // than leaving the user to think their camera or browser is broken. + return { + ok: false, + reason: globalThis.isSecureContext === false + ? INSECURE_CAMERA_REASON + : 'カメラを使うには https か localhost が必要です', + }; } stopCamera(); diff --git a/public/bluebey-studio/src/faceArt.js b/public/bluebey-studio/src/faceArt.js index 65205fa..61bfa0d 100644 --- a/public/bluebey-studio/src/faceArt.js +++ b/public/bluebey-studio/src/faceArt.js @@ -147,17 +147,34 @@ export const CHEEK_LAYOUT = { drop: 64, // how far below the eye centre the patch sits (artwork px) outward: 66, // how far outwards, away from the midline, it sits radius: 50, // half-width of the patch - squash: 0.62, // half-height, as a fraction of the half-width - lines: 4, // hatch strokes across the patch - lineWidth: 5, - slant: 58, // hatching angle, degrees + squash: 0.55, // half-height, as a fraction of the half-width (a flat ellipse) + // The blush is *always* four strokes - the reference artwork has four - so + // there is no line-count control any more. The strokes are horizontal and + // hand-drawn: each wobbles a little and tapers to a point at both ends. + lines: 4, + lineWidth: 10, // the stroke thickness at its widest (artwork px) + spread: 0.62, // how far the outermost stroke sits from the centre (x half-height) + margin: 0.18, // clear gap left between a stroke's end and the patch outline + wobble: 0.32, // sideways wander, as a fraction of the stroke thickness }; +/** + * 頭の模様: a *hair-like covering* over the whole head, not a small forehead symbol. + * + * The face plate is a shell of the *whole* front half of the body, so the plate's + * own edge is the head's front silhouette. Filling the plate from its top edge + * down to an adjustable boundary therefore makes the covering's outline follow + * the silhouette for free - there is no separate ellipse to keep in step. + * + * It is a generic device: a colour cap, a fringe, a side part, or a left/right + * two-tone split. It is deliberately *not* a copy of any one character's hair. + * It starts off. + */ export const HEAD_MARK_LAYOUT = { - cx: (EYE_LAYOUT.eyes[0].cx + EYE_LAYOUT.eyes[1].cx) / 2, // the face midline - cy: 92, // up on the forehead, above the brows - size: 46, // nominal radius of a shape - lineWidth: 9, + // Where the boundary sits by default: on the forehead, just above the brows + // (EYE_LAYOUT.eyes[0].cy is the eye row and the brows reach ~114px above it). + cy: 120, + reach: 190, // artwork px the boundary moves per unit of `size` }; /** @@ -212,8 +229,9 @@ const SUNGLASSES_LAYOUT = { bridgeLift: 0.25, // the bar sits this far above the lens centre (x half-height) templeLength: 1.15, // a short stub, like the round pair's but a little shorter templeRise: 24, - frameFactor: 0.6, // a thin rim, slimmer than the round pair's stroke - highlightWidth: 0.12, // glass highlight thickness, as a fraction of the half-width + // A heavy rim. It used to be slimmer than the round pair's stroke, which read + // as a thin pair of shades; the reference look is a chunky frame. + frameFactor: 1.6, }; /** The mouth artwork is drawn on the `mouth-plate` shell, which covers the whole @@ -666,31 +684,50 @@ function drawCheek(ctx, cx, cy, spec) { ctx.ellipse(cx, cy, r, ry, 0, 0, TAU); ctx.fillStyle = spec.color ?? '#f6a6b8'; ctx.fill(); - // Clip the hatching to the patch, so the strokes keep a clean edge. + // Clip the hatching to the patch, so a wobbly stroke keeps a clean edge. ctx.clip(); - const lines = Math.max(0, Math.round(spec.lines ?? CHEEK_LAYOUT.lines)); - if (lines > 0) { - const angle = (CHEEK_LAYOUT.slant * Math.PI) / 180; - const dx = Math.cos(angle); - const dy = -Math.sin(angle); - const reach = r * 1.3; - ctx.strokeStyle = spec.hatchColor ?? '#e0708f'; - ctx.lineWidth = Math.max(1, CHEEK_LAYOUT.lineWidth * size); - ctx.lineCap = 'round'; - for (let i = 0; i < lines; i += 1) { - // Spread the strokes evenly across the patch. - const t = lines === 1 ? 0 : i / (lines - 1) - 0.5; - const px = cx + t * r * 1.2; - ctx.beginPath(); - ctx.moveTo(px - dx * reach, cy - dy * reach); - ctx.lineTo(px + dx * reach, cy + dy * reach); - ctx.stroke(); - } + // Four horizontal strokes, hand-drawn rather than ruled: each tapers to a + // point at both ends and wanders a little. Each one stops short of the patch + // outline - the margin is measured against the patch's own width at that + // height, so a stroke on the narrow top or bottom cannot reach the edge. + const thickness = Math.max(1.5, CHEEK_LAYOUT.lineWidth * size); + ctx.fillStyle = spec.hatchColor ?? '#e0708f'; + for (let i = 0; i < CHEEK_LAYOUT.lines; i += 1) { + const t = CHEEK_LAYOUT.lines === 1 ? 0 : (i / (CHEEK_LAYOUT.lines - 1)) * 2 - 1; // -1..1 + const dy = t * ry * CHEEK_LAYOUT.spread; + const chord = r * Math.sqrt(Math.max(0, 1 - (dy / ry) ** 2)); + const half = chord - r * CHEEK_LAYOUT.margin; + if (half < 4) continue; + traceHandHatch(ctx, cx, cy + dy, half, thickness, i * 1.9); } ctx.restore(); } +/** + * One hand-drawn hatch stroke: a lens-shaped mark that tapers to a point at both + * ends, with a gentle wobble so it reads as drawn rather than printed. + */ +function traceHandHatch(ctx, cx, cy, half, thickness, seed) { + const steps = 12; + const top = []; + const bottom = []; + for (let i = 0; i <= steps; i += 1) { + const u = i / steps; + const x = cx + (u - 0.5) * 2 * half; + const wobble = Math.sin(u * Math.PI * 1.6 + seed) * thickness * CHEEK_LAYOUT.wobble; + const w = (thickness / 2) * Math.sin(u * Math.PI); // 0 at both ends + top.push({ x, y: cy + wobble - w }); + bottom.push({ x, y: cy + wobble + w }); + } + ctx.beginPath(); + ctx.moveTo(top[0].x, top[0].y); + for (const point of top.slice(1)) ctx.lineTo(point.x, point.y); + for (let i = bottom.length - 1; i >= 0; i -= 1) ctx.lineTo(bottom[i].x, bottom[i].y); + ctx.closePath(); + ctx.fill(); +} + /** The pair of cheeks: symmetric about the face's midline (see `EYE_LAYOUT`). */ function drawCheeks(ctx, spec) { if (!spec?.enabled) return; @@ -704,60 +741,97 @@ function drawCheeks(ctx, spec) { } /** - * 頭の模様: one of a few simple marks drawn on the forehead. Kept to a handful of - * shapes on purpose - the point is a generic manga device, not a particular - * character's artwork - and it starts off. + * 頭の模様: a hair-like covering over the whole head. + * + * The face plate is a shell of the front half of the body, so its own edge is + * the head's silhouette. This fills the plate from its top edge down to an + * adjustable boundary, which is what makes the covering follow the head outline + * without knowing the head's shape ahead of time. The boundary style, colour and + * size are all adjustable, and one style (`split`) divides the head into two + * colours along a vertical curve - a generic "two-tone head" device. It is not a + * copy of any particular character's hair. */ -function drawHeadMark(ctx, spec) { - const shape = spec.shape ?? 'off'; +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 s = HEAD_MARK_LAYOUT.size * size; - const cx = HEAD_MARK_LAYOUT.cx + clamp(spec.offsetX ?? 0, -400, 400); - const cy = HEAD_MARK_LAYOUT.cy + clamp(spec.offsetY ?? 0, -400, 400); + const left = limits.left; + const right = limits.right; + const top = limits.top; + const bottom = limits.bottom; + const width = right - left; + const cx = (left + right) / 2 + clamp(spec.offsetX ?? 0, -600, 600); + const color = spec.color ?? '#8a4fe0'; + const color2 = spec.color2 ?? '#ffffff'; ctx.save(); - ctx.strokeStyle = spec.color ?? '#8a4fe0'; - ctx.fillStyle = spec.color ?? '#8a4fe0'; - ctx.lineWidth = Math.max(1, HEAD_MARK_LAYOUT.lineWidth * size); - ctx.lineCap = 'round'; - ctx.lineJoin = 'round'; + // Stay on the plate: nothing may spill past the artwork's real edges. + ctx.beginPath(); + ctx.rect(left, top, width, bottom - top); + ctx.clip(); - if (shape === 'hook') { - // A short curved hook, like a comma. - ctx.beginPath(); - ctx.moveTo(cx - s * 1.05, cy + s * 0.45); - ctx.quadraticCurveTo(cx - s * 0.15, cy - s * 1.05, cx + s * 0.85, cy + s * 0.15); - ctx.stroke(); - } else if (shape === 'spiral') { - const steps = 60; - ctx.beginPath(); - for (let i = 0; i <= steps; i += 1) { - const t = i / steps; - const angle = t * TAU * 2.2; - const radius = s * (0.14 + 0.86 * t); - const x = cx + Math.cos(angle) * radius; - const y = cy + Math.sin(angle) * radius; - if (i === 0) ctx.moveTo(x, y); - else ctx.lineTo(x, y); - } - ctx.stroke(); - } else if (shape === 'strokes') { - // Three short strokes standing side by side. - for (let i = -1; i <= 1; i += 1) { + 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(cx + i * s * 0.62, cy - s * 0.6); - ctx.lineTo(cx + i * s * 0.62, cy + s * 0.6); - ctx.stroke(); + 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(); + ctx.restore(); + return; + } + + // The boundary curve. `t` runs -1..1 across the plate, so each style is a + // small shape function; the covering is everything above it. + const base = HEAD_MARK_LAYOUT.cy + (size - 1) * HEAD_MARK_LAYOUT.reach + + clamp(spec.offsetY ?? 0, -600, 600); + const halfSpan = width / 2; + const boundary = (x) => { + const t = clamp((x - cx) / halfSpan, -1, 1); + if (shape === 'fringe') { + // 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)); } - } else if (shape === 'dots') { - const radius = Math.max(1.5, ctx.lineWidth * 0.8); - for (let i = -1; i <= 1; i += 1) { - ctx.beginPath(); - ctx.arc(cx + i * s * 0.62, cy, radius, 0, TAU); - ctx.fill(); + if (shape === 'wave') { + // A single smooth sweep across the forehead. + return base + 120 * Math.sin(t * Math.PI * 0.9 + 0.5); + } + if (shape === 'side') { + // A side part: high on one side, low on the other. + return base + 260 * t; } + // `cap`: a rounded helmet, lowest in the middle. + return base - 100 * (1 - t * t); + }; + + const steps = 48; + ctx.beginPath(); + ctx.moveTo(left, top); + ctx.lineTo(right, top); + for (let i = steps; i >= 0; i -= 1) { + const x = left + (width * i) / steps; + ctx.lineTo(x, boundary(x)); } + ctx.closePath(); + ctx.fillStyle = color; + ctx.fill(); ctx.restore(); } @@ -767,7 +841,8 @@ function drawHeadMark(ctx, spec) { */ function drawFaceExtras(ctx, style) { drawCheeks(ctx, style.cheeks); - drawHeadMark(ctx, style.headMark); + // The head covering fills out to the plate's real edges, so it needs `limits`. + drawHeadCover(ctx, style.headMark, style.limits ?? WINDOW_LIMITS); } /** @@ -861,18 +936,6 @@ function drawGlasses(ctx, eye, line, style, limits) { ctx.strokeStyle = colour; ctx.lineWidth = frame; ctx.stroke(); - // A light streak inside the glass, clipped to the lens so it can never spill - // over the frame. Shaded drawings only: 線画's lens is empty paper. - if (!line) { - ctx.save(); - traceRoundedPolygon(ctx, lens, ry * L.corner); - ctx.clip(); - strokePolyline(ctx, [at(-rx * 0.6, ry * 0.15), at(rx * 0.4, -ry * 0.85)], { - color: 'rgba(255, 255, 255, 0.5)', - width: Math.max(2, rx * L.highlightWidth), - }); - ctx.restore(); - } } else { ctx.beginPath(); ctx.ellipse(lensCx, lensCy, rx, ry, 0, 0, TAU); @@ -915,13 +978,13 @@ function drawGlasses(ctx, eye, line, style, limits) { // --- temple ---------------------------------------------------------- // A stub outwards from the outer edge of the lens. It stays short on purpose: // the plate only reaches so far, and a temple that ran off it would smear. The - // round pair's outer edge is a vertical line at the lens centre height; the - // shades' outer end sits low, so their stub starts down there instead of - // floating below the lens. + // round pair's outer edge is a vertical line at the lens centre height. The + // shades' outer end drops low, so their arm leaves from the *top* edge instead + // - from the pointed outer corner - which is where a cat-eye's arm attaches. const outerX = shades - ? lensCx - eye.towardNose * rx * L.outerEndX + ? lensCx - eye.towardNose * rx * L.tipX : lensCx - eye.towardNose * rx; - const outerY = shades ? lensCy + ry * L.outerBottom * 0.7 : lensCy; + const outerY = shades ? lensCy - ry * (L.innerTop + L.topSkew) : lensCy; const reach = clamp( outerX - eye.towardNose * r * L.templeLength, limits.left + EDGE_MARGIN, diff --git a/public/bluebey-studio/src/main.js b/public/bluebey-studio/src/main.js index a9d27f8..9c0b98d 100644 --- a/public/bluebey-studio/src/main.js +++ b/public/bluebey-studio/src/main.js @@ -2076,6 +2076,17 @@ async function init() { resizeViewport(); } + // The panel is a side column on a wide screen and a bottom sheet on a small + // one, where it starts closed so the 3D view fills the screen (see the + // small-screen rules in src/style.css). When the window is resized across that + // breakpoint, match the layout the other side expects instead of leaving the + // panel stuck in the previous state. + const narrowScreenLayout = window.matchMedia('(max-width: 768px), (max-height: 500px) and (pointer: coarse)'); + narrowScreenLayout.addEventListener('change', (event) => { + document.body.classList.toggle('panel-hidden', event.matches); + resizeViewport(); + }); + function installKeys() { window.addEventListener('keydown', (event) => { // Undo/redo are the only shortcuts that need a modifier, so they are dealt diff --git a/public/bluebey-studio/src/panel.js b/public/bluebey-studio/src/panel.js index 28ea8f9..babeb59 100644 --- a/public/bluebey-studio/src/panel.js +++ b/public/bluebey-studio/src/panel.js @@ -115,9 +115,10 @@ export function buildPanel(app, root) { root.replaceChildren(); - // Four tabs instead of one long scroll: the panel used to be a wall of - // controls, and everything is easier to find this way. Each tab now carries a - // small stroke icon so the row can be scanned at a glance. + // Tabs instead of one long scroll: the panel used to be a wall of controls, + // and everything is easier to find this way. Each tab is a small stroke icon + // (its name is the tooltip and the accessible label) sitting on the top edge + // of the content, so the row reads as tabs rather than buttons. const tabBar = tabs(root, [ { id: 'pose', @@ -127,7 +128,7 @@ export function buildPanel(app, root) { }, { id: 'face', - label: '表情', + label: '顔', // A smiley face: an outline, two eyes and a smile. icon: 'M12 3.6a8.4 8.4 0 1 1 0 16.8 8.4 8.4 0 1 1 0-16.8ZM8.9 9.8v1M15.1 9.8v1M8.3 13.9a4.7 4.7 0 0 0 7.4 0', }, @@ -138,6 +139,12 @@ export function buildPanel(app, root) { icon: 'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.9 0 1.6-.7 1.6-1.7 0-.4-.2-.8-.4-1.1-.3-.3-.4-.7-.4-1.1a1.6 1.6 0 0 1 1.7-1.7h2c3 0 5.5-2.5 5.5-5.6C22 6 17.5 2 12 2ZM13.5 6.5h.01M17.5 10.5h.01M8.5 7.5h.01M6.5 12.5h.01', }, { + id: 'scene', + label: 'シーン', + // A framed picture: background, stage and light all sit inside the frame. + icon: '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', + }, + { id: 'export', label: '書き出し', // A download arrow dropping into a tray. @@ -147,6 +154,7 @@ export function buildPanel(app, root) { const tabPose = tabBar.panels.pose; const tabFace = tabBar.panels.face; const tabView = tabBar.panels.view; + const tabScene = tabBar.panels.scene; const tabExport = tabBar.panels.export; /* ------------------------------------------------------------------ pose */ @@ -197,8 +205,12 @@ export function buildPanel(app, root) { // こまかい設定: the whole-body nudge and the rotation gizmo are tweaks, not the // main way in, so they stay folded away inside the bone section. - const rootSliders = ['よこ移動', 'たて移動', 'おくゆき'].map((label, index) => slider({ - label, min: -2, max: 2, step: 0.01, value: 0, + const rootSliders = [ + { label: 'よこ移動', min: -5, max: 5 }, + { label: 'たて移動', min: -2, max: 6 }, + { label: 'おくゆき', min: -5, max: 5 }, + ].map(({ label, min, max }, index) => slider({ + label, min, max, step: 0.01, value: 0, format: (v) => v.toFixed(2), onInput: (v) => { state.pose.root[index] = v; app.needsRender = true; }, })); @@ -705,24 +717,43 @@ export function buildPanel(app, root) { faceSection.add(hint('眼鏡やサングラスを、目や眉と同じ絵にかけます。' + '線画のときはレンズを塗らず、フレームの線だけになります。')); - const glassesToggle = check({ - label: '眼鏡をかける', - value: false, - onChange: (value) => { state.face.eyes.glasses.enabled = value; app.actions.refresh('face'); }, - }); - faceSection.add(controlRow(null, glassesToggle.el, { wide: true })); - - // 眼鏡とサングラスの違いは、おもにレンズの濃さ。切り替えたらその種類のレンズの - // 既定値(色と濃さ)を一緒に持ってくる。フレームや大きさには触らないので、 - // 作りこんだフレームは種類を変えても失われない。 + // 眼鏡とサングラスの違いは、レンズの形だけではない。サングラスはレンズが不透明 + // なので、目を開けたままだと瞳が透けて見えてしまう。種類を変えたらその種類の + // 既定値(レンズの色・濃さ・高さ)を一緒に持ってきて、サングラスのときは両目を + // 閉じる。フレームや大きさには触らないので、作りこんだフレームは種類を変えても + // 失われない。 const GLASSES_KINDS = { - glasses: { lensColor: '#2b2433', lensOpacity: 0.22 }, - sunglasses: { lensColor: '#1a1620', lensOpacity: 0.75 }, + glasses: { lensColor: '#2b2433', lensOpacity: 0.22, offsetY: 0 }, + sunglasses: { lensColor: '#1a1620', lensOpacity: 1, offsetY: -40 }, + }; + const wearSunglasses = () => { + state.face.eyes.glasses.lensOpacity = 1; + state.face.eyes.left.open = 0; + state.face.eyes.right.open = 0; }; const setGlassesKind = (kind) => { Object.assign(state.face.eyes.glasses, { kind, ...GLASSES_KINDS[kind] }); + // サングラス hides the eye behind an opaque lens, so both eyes shut with it; + // 眼鏡's lens is clear, so switching back opens them again. + const open = kind === 'sunglasses' ? 0 : 1; + state.face.eyes.left.open = open; + state.face.eyes.right.open = open; }; + const glassesToggle = check({ + label: '眼鏡をかける', + value: false, + onChange: (value) => { + state.face.eyes.glasses.enabled = value; + // A loaded state can already be on サングラス, so enabling the shades also + // shuts the eyes rather than showing them through the opaque lens. + if (value && state.face.eyes.glasses.kind === 'sunglasses') wearSunglasses(); + app.actions.refresh('face'); + sync(); + }, + }); + faceSection.add(controlRow(null, glassesToggle.el, { wide: true })); + const glassesKindSegment = segmented({ label: '種類', options: [ @@ -833,34 +864,29 @@ export function buildPanel(app, root) { label: 'よこの間隔', min: -60, max: 80, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.eyes.cheeks.spacing = v; app.actions.refresh('face'); }, }), - lines: slider({ - label: '線の数', min: 0, max: 7, step: 1, value: 4, - format: (v) => (v < 0.5 ? 'なし' : `${Math.round(v)}本`), - onInput: (v) => { state.face.eyes.cheeks.lines = Math.round(v); app.actions.refresh('face'); }, - }), }; faceSection.add(cheekSliders.size.el); - const cheekBox = details(faceSection.body, 'こまかい設定(位置・線・色)'); + const cheekBox = details(faceSection.body, 'こまかい設定(位置・色)'); cheekBox.add(cheekSliders.offsetY.el); cheekBox.add(cheekSliders.spacing.el); - cheekBox.add(cheekSliders.lines.el); const cheekColor = colorField({ label: 'ほっぺの色', value: '#f6a6b8', swatches: ['#f6a6b8', '#f19ab0', '#f7b9a0', '#e88aa8'], onChange: (v) => { state.face.eyes.cheeks.color = v; app.actions.refresh('face'); }, }); cheekBox.add(cheekColor.el); - cheekBox.add(hint('「線の数」を 0 にすると、線のないほっぺになります。' - + '「よこの間隔」は左右のほっぺのへだたり、「たかさ」は目からの下がり具合です。')); + cheekBox.add(hint('線はいつも4本です。「よこの間隔」は左右のほっぺのへだたり、' + + '「たかさ」は目からの下がり具合です。')); const headMarkShape = segmented({ label: '頭の模様', options: [ { value: 'off', label: 'なし' }, - { value: 'hook', label: 'かぎ' }, - { value: 'spiral', label: 'うずまき' }, - { value: 'strokes', label: '三本線' }, - { value: 'dots', label: '点' }, + { value: 'cap', label: 'かぶせ' }, + { value: 'fringe', label: 'まえがみ' }, + { value: 'wave', label: 'なみ' }, + { value: 'side', label: 'わけめ' }, + { value: 'split', label: 'ツートン' }, ], value: 'off', onChange: (value) => { state.face.eyes.headMark.shape = value; app.actions.refresh('face'); }, @@ -877,27 +903,29 @@ export function buildPanel(app, root) { onInput: (v) => { state.face.eyes.headMark.offsetX = v; app.actions.refresh('face'); }, }), offsetY: slider({ - label: 'たかさ', min: -60, max: 120, step: 1, value: 0, format: (v) => `${Math.round(v)}`, + label: 'たかさ', min: -300, max: 300, step: 1, value: 0, format: (v) => `${Math.round(v)}`, onInput: (v) => { state.face.eyes.headMark.offsetY = v; app.actions.refresh('face'); }, }), - lineWidth: slider({ - label: '太さ', min: 0.4, max: 3, step: 0.01, value: 1, format: (v) => v.toFixed(2), - onInput: (v) => { state.face.eyes.headMark.lineWidth = v; app.actions.refresh('face'); }, - }), }; faceSection.add(headMarkSliders.size.el); - const headMarkBox = details(faceSection.body, 'こまかい設定(位置・太さ・色)'); + const headMarkBox = details(faceSection.body, 'こまかい設定(位置・色)'); headMarkBox.add(headMarkSliders.offsetX.el); headMarkBox.add(headMarkSliders.offsetY.el); - headMarkBox.add(headMarkSliders.lineWidth.el); const headMarkColor = colorField({ label: '模様の色', value: '#8a4fe0', swatches: ['#8a4fe0', '#2a1e33', '#e0344f', '#7a5c2e'], onChange: (v) => { state.face.eyes.headMark.color = v; app.actions.refresh('face'); }, }); + const headMarkColor2 = colorField({ + label: '2色目', value: '#ffffff', + swatches: ['#ffffff', '#f3ead9', '#ffe08a', '#7fd8ff'], + onChange: (v) => { state.face.eyes.headMark.color2 = v; app.actions.refresh('face'); }, + }); headMarkBox.add(headMarkColor.el); - headMarkBox.add(hint('「頭の模様」を選ぶと出ます。決まったキャラクターの絵ではなく、' - + 'よくあるマンガの記号です。色・大きさ・位置は自由に変えられます。')); + headMarkBox.add(headMarkColor2.el); + headMarkBox.add(hint('「頭の模様」は、頭にかぶせる髪のような塗りです。' + + '「ツートン」は頭を左右2色に分けます(2色目を使います)。' + + '決まったキャラクターの髪ではなく、よくあるマンガの記号として用意しました。')); // --- mouth faceSection = section(tabFace, '口'); @@ -987,7 +1015,7 @@ export function buildPanel(app, root) { /* ---------------------------------------------------------------- caption */ - const captionSection = section(tabFace, 'セリフ(吹き出し)'); + 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, @@ -1148,7 +1176,7 @@ export function buildPanel(app, root) { }; for (const widget of Object.values(flapSliders)) flapSection.add(widget.el); flapSection.add(hint('音は出ません。セリフの長さぶん口が動いて、自動で止まります。' - + '上の「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); + + '「シーン」タブの「セリフ(吹き出し)」の文とは別で、こちらは長さにだけ使います。')); /* ------------------------------------------------------ movement (pose tab) */ @@ -1167,6 +1195,7 @@ export function buildPanel(app, root) { { value: 'off', label: 'とまる' }, { value: 'idle', label: 'ゆれる' }, { value: 'walk', label: '歩く' }, + { value: 'wave', label: 'てをふる' }, ], value: 'off', onChange: (value) => { state.anim.mode = value; app.animator.reset(); app.needsRender = true; }, @@ -1364,7 +1393,7 @@ export function buildPanel(app, root) { /* -------------------------------------------------------------- background */ - const backdropSection = section(tabView, '背景'); + const backdropSection = section(tabScene, '背景'); // 素材 is one button for two state values (a photo or an effect line). This // remembers which of the two was picked last, so leaving 素材 and coming back // returns to it instead of always jumping to the photos. @@ -1540,7 +1569,7 @@ export function buildPanel(app, root) { }, }); backdropSection.add(controlRow(null, gyroToggle.el, { wide: true })); - backdropSection.add(hint('カメラを使うには https か localhost で開いてください(カメラの使用の許可も必要です)。')); + backdropSection.add(hint('カメラ(AR)を使うには、https のサイトか localhost で開いてください。http のLANアドレスでは、どのブラウザでもカメラは使えません。使えるときは、URLバーのアイコンを「許可」にし、端末の設定でもアプリにカメラを許可してください。')); backdropSection.add(buttons({ items: [{ id: 'bgshot', label: 'この背景で写真を撮る', primary: true, onClick: () => app.actions.savePNG() }], })); @@ -1549,7 +1578,7 @@ export function buildPanel(app, root) { // 舞台: the things you put the character *among* - the props and the wall - in // one place, since they are set up together. - const stageSection = section(tabView, '舞台'); + const stageSection = section(tabScene, '舞台'); const propSection = details(stageSection.body, '小物'); propSection.add(buttons({ label: '追加', @@ -1601,10 +1630,10 @@ export function buildPanel(app, root) { // The numbers are only a fallback for the drag, so they stay folded away. const numbers = details(block, '数値で調整'); for (const widget of Object.values(sliders)) numbers.add(widget.el); - // 看板 only: the writing surface can be enlarged on its own, so the frame - // and posts stay put. The app rebuilds the props from the state and carries - // only the whole-prop scale, so the props syncer re-applies this to the - // fresh mesh (see `applyPropFaceScale`). + // 看板 only: the writing area (face *and* frame) can be enlarged on its + // own, so the posts and base stay put. The app rebuilds the props from the + // state and carries only the whole-prop scale, so the props syncer + // re-applies this to the fresh mesh (see `applyPropFaceScale`). let faceScale = null; if (item.kind === 'sign') { faceScale = slider({ @@ -1946,7 +1975,7 @@ export function buildPanel(app, root) { // 画面・光: the light rig and the camera, which together decide how the picture // is lit and framed. - const screenLightSection = section(tabView, '画面・光'); + const screenLightSection = section(tabScene, '画面・光'); const lightSection = details(screenLightSection.body, 'ひかり'); const lightPad = xyPad({ value: { x: -0.2, y: 0.45 }, @@ -2246,11 +2275,20 @@ export function buildPanel(app, root) { // A tiny read-out of what the renderer is actually doing: if the picture ever // looks wrong or never appears, this says why (buffer size, quality, fps). - const infoSection = section(tabView, '画面の情報'); + const infoSection = section(tabScene, '画面の情報'); const statsLine = h('p', { class: 'hint', text: '計測中…' }); infoSection.add(statsLine); infoSection.add(hint('動きが重い環境では、負荷を下げるために画質(描画バッファの倍率)を自動で下げます。')); + // シーン: the scene controls are built wherever they belong in the code (the + // speech bubble sits with the face controls), so put them on the tab in the + // order the user meets them: what is behind the character, what is beside it, + // the speech bubble, then the light and the read-out. + tabScene.append( + backdropSection.el, stageSection.el, captionSection.el, + screenLightSection.el, infoSection.el, + ); + boneSyncers.push(() => { const name = rig.selected; for (const [key, button] of boneButtons) button.classList.toggle('active', key === name); @@ -2316,6 +2354,7 @@ export function buildPanel(app, root) { headMarkShape.set(state.face.eyes.headMark.shape ?? 'off'); for (const [key, widget] of Object.entries(headMarkSliders)) widget.set(state.face.eyes.headMark[key]); headMarkColor.set(state.face.eyes.headMark.color); + headMarkColor2.set(state.face.eyes.headMark.color2 ?? '#ffffff'); }); syncers.push(() => { diff --git a/public/bluebey-studio/src/presets.js b/public/bluebey-studio/src/presets.js index 3146e0b..b14a6e4 100644 --- a/public/bluebey-studio/src/presets.js +++ b/public/bluebey-studio/src/presets.js @@ -186,25 +186,28 @@ export function defaultState() { // 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', - // ほっぺ (a manga blush) and 頭の模様 (a mark on the head) are drawn into - // the same plate as the eyes and brows. Both are generic manga devices, - // not a copy of any one character, and both start off. The mark's shape - // is one of `off`, `hook`, `spiral`, `strokes`, `dots`. + // ほっぺ (a manga blush) and 頭の模様 (a hair-like head covering) are drawn + // into the same plate as the eyes and brows. Both are generic devices, + // not a copy of any one character, and both start off. The blush is always + // four horizontal strokes, so there is no line-count field. cheeks: { enabled: false, size: 1, spacing: 0, // extra gap outwards from the eyes offsetY: 0, // extra drop below the eyes - lines: 4, // hatch strokes; 0 leaves a plain patch color: '#f6a6b8', }, + // The head covering's `shape` is one of `off`, `cap`, `fringe`, `wave`, + // `side` (the boundary style) or `split` (a two-tone head divided by a + // vertical curve). `color` is the covering / left half; `color2` is the + // second colour of the two-tone split. headMark: { shape: 'off', size: 1, offsetX: 0, offsetY: 0, - lineWidth: 1, color: '#8a4fe0', + color2: '#ffffff', }, // Eyebrows are drawn into the eye texture. Off by default, because the // original character has no eyebrows. @@ -488,9 +491,43 @@ export const FACE_PRESETS = [ */ export const POSE_PRESETS = [ { id: 'stand', label: '立ち', pose: { bones: {} } }, - { id: 'bow', label: 'おじぎ', pose: { bones: { master: [22, 0, 0] } } }, - { id: 'deep-bow', label: 'ふかくおじぎ', pose: { bones: { master: [42, 0, 0] } } }, - { id: 'tilt', label: 'くびかしげ', pose: { bones: { master: [0, 0, 16] } } }, + { + id: 'attention', + label: 'きおつけ', + pose: { + bones: { + // Both flippers dropped to the sides. A negative X lowers the arm, and + // the value is the same on both sides (only Z mirrors between them). + 'arm.l': [-62, 0, 0], + 'arm.r': [-62, 0, 0], + }, + }, + }, + { + id: 'bow', + label: 'おじぎ', + pose: { bones: { master: [22, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } }, + }, + { + id: 'deep-bow', + label: 'ふかくおじぎ', + pose: { bones: { master: [42, 0, 0], 'arm.l': [-62, 0, 0], 'arm.r': [-62, 0, 0] } }, + }, + { + id: 'tilt', + label: 'くびかしげ', + pose: { + bones: { + master: [0, 0, 16], + // The roll turns the whole body about its base, which swings the feet up + // and down, so the legs take the difference. The right-hand leg (on the + // left of the screen) is the one that is planted: it opens outwards a + // little and keeps the sole down. The left-hand leg bends back behind it. + 'legsupport.r': [8, 0, 0], + 'legsupport.l': [-6, 0, 14], + }, + }, + }, { id: 'wave', label: 'てをふる', @@ -546,7 +583,7 @@ export const POSE_PRESETS = [ }, { id: 'leap', - label: 'とびはねる', + label: '大ジャンプ', pose: { bones: { master: [-18, 0, 0], diff --git a/public/bluebey-studio/src/props.js b/public/bluebey-studio/src/props.js index 5ad958a..a5fcbcc 100644 --- a/public/bluebey-studio/src/props.js +++ b/public/bluebey-studio/src/props.js @@ -192,19 +192,28 @@ const buildMic = prop((group, material) => { }); /** - * 看板. A post, a frame and a blank face. The face is a separate, named child - * tagged `userData.canvasTexture`, so a later feature can paint a CanvasTexture - * onto it without touching the frame - and `applyPropFaceScale` can grow just - * that surface, about its own centre, leaving the frame and posts alone. It is a - * thin box rather than a plane: the outline pass offsets geometry along its - * normals, and a zero-thickness plane gets a visibly doubled rim. + * 看板. A post, a frame and a blank face. The face is a named child tagged + * `userData.canvasTexture`, so a later feature can paint a CanvasTexture onto it + * without hunting for the writable mesh. The face *and* its frame are grouped, + * so `applyPropFaceScale` can grow the whole board about the face's centre while + * the post and base stay put. Both are thin boxes rather than planes: the outline + * pass offsets geometry along its normals, and a zero-thickness plane gets a + * visibly doubled rim. */ const buildSign = prop((group, material) => { addBox(group, material('accent'), [0.52, 0.10, 0.52], [0, 0.05, 0]); addBox(group, material('wood'), [0.16, 1.62, 0.16], [0, 0.91, 0]); - addBox(group, material('accent'), [1.42, 0.98, 0.12], [0, 2.21, 0]); - const face = addBox(group, material('paper'), [1.18, 0.76, 0.08], [0, 2.21, 0.06]); + // The board is one group so the face and its frame scale together, about the + // board's own centre - which is also the face's centre. + const board = new THREE.Group(); + board.name = 'sign-board'; + board.userData.faceScaleGroup = true; + board.position.set(0, 2.21, 0); + group.add(board); + + addBox(board, material('accent'), [1.42, 0.98, 0.12], [0, 0, 0]); + const face = addBox(board, material('paper'), [1.18, 0.76, 0.08], [0, 0, 0.06]); face.name = 'sign-face'; face.userData.canvasTexture = true; }); @@ -284,14 +293,19 @@ const buildCan = prop((group, material) => { addCylinder(group, material('metal', 0.45), 0.132, 0.132, 0.012, [0, 0.422, 0], 16); // The one lid, peeled back on a hinge at the back of the mouth so its - // underside shows. It is twisted slightly sideways so it does not read as a - // knob sitting straight up on the can. + // underside shows. The hinge sits *on* the can's back rim and the disc is + // placed so its own rim passes through that same point (its centre is one + // radius in front of the hinge), so opening the hinge rotates the lid about + // its edge - the two rims stay joined instead of the lid floating in mid-air. + // It is twisted slightly sideways so it does not read as a knob sitting + // straight up on the can. + const lidRadius = 0.132; const hinge = new THREE.Group(); - hinge.position.set(0, 0.42, -0.135); + hinge.position.set(0, 0.42, -lidRadius); hinge.rotation.x = -1.05; hinge.rotation.z = 0.4; group.add(hinge); - addCylinder(hinge, material('metal', 0.9), 0.132, 0.132, 0.018, [0, 0.12, 0], 18); + addCylinder(hinge, material('metal', 0.9), lidRadius, lidRadius, 0.018, [0, 0, lidRadius], 18); group.rotation.z = 0.05; }); @@ -327,8 +341,9 @@ export const PROP_DEFAULTS = { podium: { x: -2.1, y: 0, z: 1.9, rotY: 0.30, scale: 1 }, desk: { x: -2.6, y: 0, z: 0.5, rotY: 0.40, scale: 1 }, mic: { x: 2.0, y: 0, z: 1.8, rotY: -0.40, scale: 1 }, - // `faceScale` only means something for a 看板: it grows the writing surface - // alone, so the frame and posts stay put. The panel owns that control. + // `faceScale` only means something for a 看板: it grows the writing area (the + // face and its frame) together, so the post and base stay put. The panel owns + // that control. sign: { x: 2.9, y: 0, z: 0.4, rotY: -0.25, scale: 1, faceScale: 1 }, plant: { x: -2.0, y: 0, z: -1.8, rotY: 0.25, scale: 1 }, box: { x: 2.0, y: 0, z: -1.7, rotY: 0.55, scale: 1 }, @@ -419,21 +434,27 @@ export function propFace(group) { } /** - * Scale the writing surface about its own centre, so only the 看板's face grows - * and its frame and posts stay where they are. `addBox` puts the mesh's origin at - * the box centre, which is what makes this a scale about the centre rather than - * about the prop's base. + * Scale the writing area - the 看板's face *and* the frame around it - about the + * board's own centre, so the post and base stay where they are. A prop that tags + * a group `faceScaleGroup` is scaled as a whole; anything else falls back to the + * writable mesh alone. `addBox` puts a mesh's origin at the box centre and the + * 看板's board group sits at the face centre, which is what makes this a scale + * about the centre rather than about the prop's base. * * WHY this is applied from the panel: the app rebuilds every prop from the state * (`applyProps` in src/main.js) and hands `buildProp` only the whole-prop scale, * so a per-item `faceScale` has to be re-applied to the fresh mesh afterwards. */ export function applyPropFaceScale(group, faceScale) { - const face = propFace(group); - if (!face) return; + let target = null; + group?.traverse?.((object) => { + if (!target && object.userData?.faceScaleGroup) target = object; + }); + target ??= propFace(group); + if (!target) return; const value = Number(faceScale); const scale = Number.isFinite(value) ? Math.min(4, Math.max(0.2, value)) : 1; - face.scale.setScalar(scale); + target.scale.setScalar(scale); } /** The world-space box the prop occupies; the app drops its contact shadow from it. */ diff --git a/public/bluebey-studio/src/style.css b/public/bluebey-studio/src/style.css index 2ca7148..e5287a2 100644 --- a/public/bluebey-studio/src/style.css +++ b/public/bluebey-studio/src/style.css @@ -15,6 +15,10 @@ html, body { margin: 0; height: 100%; + height: 100vh; + /* `dvh` tracks the space mobile browser chrome leaves; the lines above are + the fallbacks for browsers that do not know it yet. */ + height: 100dvh; overflow: hidden; } @@ -91,7 +95,8 @@ body:not(.panel-hidden) #stage { #topbar button:active { transform: translateY(0); } #topbar button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); } -#viewport-hint { +/* Shared by the mouse hint and the touch hint (see #viewport-hint-touch). */ +.viewport-hint { position: fixed; left: 18px; bottom: 14px; @@ -102,6 +107,10 @@ body:not(.panel-hidden) #stage { pointer-events: none; } +/* Phones get a touch-specific hint instead of this mouse one; swapped in the + small-screen media query at the end of the viewport/panel section below. */ +#viewport-hint-touch { display: none; } + /* -------------------------------------------------------------------- panel */ #panel { @@ -132,6 +141,92 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); } #topbar { max-width: calc(100vw - 60px); } } +/* + * Phones and other short touch screens. The panel becomes a bottom sheet over + * the 3D view instead of a side column: the canvas keeps the full screen behind + * it, so opening the sheet does not resize the render (and the ≡ button, which + * already calls resizeViewport, is all the control needed). + */ +@media (max-width: 768px), (max-height: 500px) and (pointer: coarse) { + /* Full-screen canvas: the sheet floats over it, so its height never feeds + back into the renderer. */ + #stage, + body:not(.panel-hidden) #stage { + inset: 0; + right: 0; + } + + #topbar { + top: calc(8px + env(safe-area-inset-top)); + left: calc(10px + env(safe-area-inset-left)); + right: calc(10px + env(safe-area-inset-right)); + max-width: none; + gap: 8px; + } + + /* Let the title give way first (it can ellipsize); the buttons keep their + labels and stay on one line. */ + #topbar h1 { + font-size: 13px; + white-space: nowrap; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + } + + .topbar-actions { + flex: 1; + flex-wrap: nowrap; + justify-content: flex-end; + gap: 6px; + } + + /* Comfortable tap targets (>= 40px) in a row that still fits 360px. */ + #topbar button { + font-size: 11px; + padding: 9px 10px; + min-height: 40px; + } + + #btn-panel { min-width: 44px; } + + #viewport-hint { display: none; } + #viewport-hint-touch { + display: block; + bottom: calc(14px + env(safe-area-inset-bottom)); + } + + #panel { + top: auto; + left: 0; + right: 0; + bottom: 0; + width: auto; + max-height: 65dvh; + padding: 20px 14px calc(20px + env(safe-area-inset-bottom)); + border-left: 0; + border-top: 1px solid var(--border); + border-radius: 18px 18px 0 0; + box-shadow: 0 -6px 24px rgba(40, 20, 80, 0.18); + } + + /* A grab handle so the sheet reads as an edge that opens and closes; it + sticks to the top of the scroll area as the sheet is scrolled. */ + #panel::before { + content: ""; + position: sticky; + top: 0; + display: block; + width: 44px; + height: 5px; + margin: 0 auto 12px; + border-radius: 999px; + background: #cfc6e4; + } + + body.panel-hidden #topbar { max-width: none; } +} + /* ----------------------------------------------------------------- sections */ .tabs { @@ -139,27 +234,41 @@ body.panel-hidden #topbar { max-width: calc(100vw - 48px); } top: 0; z-index: 3; display: flex; - gap: 4px; - padding: 2px 0 10px; - margin: -2px 0 4px; + align-items: flex-end; + gap: 3px; + padding: 2px 0 0; + margin: -2px 0 8px; + border-bottom: 2px solid var(--border); background: linear-gradient(var(--card) 72%, rgba(255, 255, 255, 0)); } +/* Each tab is a rounded-top tab sitting on the strip's baseline; only the icon + shows, and the active one fills with the accent and covers the baseline. */ .tab { flex: 1; + display: flex; + align-items: center; + justify-content: center; font: inherit; - font-size: 12px; - padding: 8px 2px; + padding: 9px 2px 7px; border: 1px solid var(--border); - border-radius: 9px; - background: #fff; + border-bottom: none; + border-radius: 9px 9px 0 0; + background: var(--accent-soft); color: var(--muted); cursor: pointer; white-space: nowrap; } -.tab:hover { border-color: var(--accent-2); } -.tab.active { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; } +.tab svg { display: block; width: 20px; height: 20px; } +.tab:hover { color: var(--accent-2); border-color: var(--accent-2); } +.tab.active { + margin-bottom: -2px; + padding-bottom: 9px; + background: var(--accent); + border-color: var(--accent); + color: #fff; +} .tab-panel[hidden] { display: none; } .details { diff --git a/public/bluebey-studio/src/ui.js b/public/bluebey-studio/src/ui.js index 71f79b4..e184f5e 100644 --- a/public/bluebey-studio/src/ui.js +++ b/public/bluebey-studio/src/ui.js @@ -84,8 +84,9 @@ function tabIcon(d) { } /** - * Tabbed container: returns the per-tab panels to fill in. Each definition may - * carry an `icon` (an SVG path `d`); the label stays visible next to it. + * Tabbed container: returns the per-tab panels to fill in. When a definition + * carries an `icon` (an SVG path `d`) the tab shows only that icon, and the + * label becomes its tooltip and accessible name; without one the label shows. */ export function tabs(parent, definitions) { const bar = h('div', { class: 'tabs' }); @@ -101,10 +102,14 @@ export function tabs(parent, definitions) { }; for (const definition of definitions) { - const button = h('button', { type: 'button', class: 'tab' }, - definition.icon ? tabIcon(definition.icon) : null, - definition.icon ? ' ' : null, - h('span', { class: 'tab-label', text: definition.label })); + const button = h('button', { type: 'button', class: 'tab' }); + if (definition.icon) { + button.append(tabIcon(definition.icon)); + button.title = definition.label; + button.setAttribute('aria-label', definition.label); + } else { + button.append(h('span', { class: 'tab-label', text: definition.label })); + } button.addEventListener('click', () => select(definition.id)); buttons.set(definition.id, button); bar.append(button); |
