From c71c5f5331a5d6d6e4e4da19d04840fb4c2dd4e0 Mon Sep 17 00:00:00 2001 From: ketle-man <152043204+ketle-man@users.noreply.github.com> Date: Sat, 8 Aug 2026 12:26:54 +0900 Subject: [PATCH] =?UTF-8?q?3D=E3=83=86=E3=82=AD=E3=82=B9=E3=83=88=E3=81=AB?= =?UTF-8?q?=20SVG=E7=AB=8B=E4=BD=93=E5=8C=96=E3=83=BB=E8=A1=A8=E9=9D=A2/?= =?UTF-8?q?=E5=81=B4=E9=9D=A2=E3=82=AB=E3=83=A9=E3=83=BC=E5=88=86=E9=9B=A2?= =?UTF-8?q?=E3=83=BB=E8=A8=AD=E5=AE=9A=E3=83=A2=E3=83=BC=E3=83=80=E3=83=AB?= =?UTF-8?q?=E3=81=AE=E3=82=BF=E3=83=96=E5=8C=96=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SVGファイルのベクターパスを立体押し出しできる「SVG」モードを追加(テキストモードと切替) - 表面色・側面色を個別指定できるマテリアル(ExtrudeGeometryの表裏キャップ面/側面グループを利用)と、面取り厚み・サイズ・分割数の詳細スライダーを追加 - 面取りが形状に対して大きすぎてジオメトリが破綻した場合に自動的に無効化し警告を出す防御処理を追加 - ⚙設定モーダルを3カラム構成(左: ライト/マテリアル/カメラのタブ切替、中央: プレビュー、右: 常時表示のテキストパネル)に再設計し、テキスト入力・フォント・整列をツールバーからモーダルへ移動、行間スライダーを新規追加 - モーダルを開いている間、コマ位置追従用ResizeObserverがプレビューを画面外へ書き戻してしまう不具合を修正 - モーダル名を「3D Text Editor」に、設定ボタンを「⚙ 3D設定」に変更し、テキスト初期値を設定してコマ配置直後から内容を確認できるようにした - ヘルプ(ja/en/zh)・README 3言語・DEVLOGを更新 Co-Authored-By: Claude Sonnet 5 --- DEVLOG.md | 18 ++ README.md | 2 +- README_en.md | 2 +- README_zh.md | 2 +- static/js/i18n.js | 78 +++++++- static/js/image-tab.js | 80 +++----- static/js/image-tab/Text3DTool.js | 132 ++++++++++++- static/js/main/01-state.js | 17 +- static/js/main/22-help-tab.js | 6 +- static/js/main/25-text3d-bridge.js | 251 +++++++++++++++--------- static/js/text3d-core.js | 270 +++++++++++++++++++++++--- static/js/text3d-settings-modal.js | 299 +++++++++++++++++++++++------ static/js/text3d.js | 11 +- templates/index.html | 23 ++- 14 files changed, 931 insertions(+), 260 deletions(-) diff --git a/DEVLOG.md b/DEVLOG.md index ee1eb11..dc13f9a 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,24 @@ --- +## 2026-08-08(3Dテキストに SVG立体化・表面/側面カラー分離・設定モーダルのタブ化を追加、未リリース) + +`feature/text3d-svg-material`ブランチで作業。参考アプリ`3d-text-generator`(React/three.js製プロトタイプ)の「SVGパス立体化」「表面色・側面色の個別指定」を、既存の3Dテキスト機能(レイアウトタブ・Imageタブ共有)へ移植し、あわせてユーザーからのフィードバックを受けて⚙設定モーダルをタブ構成に再設計した。 + +**SVG立体化モード**: 「テキスト」「SVG」のモード切替ボタンを追加。SVGモードではSVGファイルを選択すると、SVGLoader(three r160、`comfyui-vrm-pose-editor`が配信するthree.module.jsと同バージョンでvendor化)でパースしたベクターパスを`THREE.ExtrudeGeometry`で押し出す(`text3d-core.js`の`_buildSvgGeometry()`、参考アプリ`svgToShapes.ts`のロジックをvanilla js化して移植)。未パス化のテキスト要素(``)やビットマップ画像(``)を含むSVGは警告を表示する。 + +**表面/側面カラー分離**: `THREE.ExtrudeGeometry`が標準で持つ「表裏キャップ面=materialIndex 0」「側面=materialIndex 1」の2グループ(three.js本体の実装仕様)を利用し、`new THREE.Mesh(geometry, [frontMaterial, sideMaterial])`と配列を渡すだけで表面色・側面色を別マテリアルにした。テキスト・SVG両モード共通。面取り(厚み・サイズ・分割数)も詳細スライダーで調整できるようにした。 + +**バグ修正1: 面取りの値によってSVGの3Dビューが真っ黒になる不具合**。当初は「SVGのviewBoxサイズと押し出し座標系のスケール不一致」を疑ったが、実機調査(`gl.readPixels()`でキャンバスの実ピクセル値を直接確認)の結果、WebGL自体は正常に描画できており、原因は別にあると判明。⚙設定モーダルは実描画中のcanvasを一時的に自分のプレビュー枠へ「借りて」表示する仕組みだが、レイアウトタブ側の「コマの画面上位置にcanvasを追従させるResizeObserver」がモーダル表示中も動き続けており、モーダルが借りている最中のcanvasラッパーを、コマの本来の位置(画面外)へ強制的に書き戻してしまっていた(`text3d-settings-modal.js`に`onClose`コールバックを追加し、`25-text3d-bridge.js`側でモーダルを開く前にResizeObserverを止め、閉じたら再セットアップするよう修正)。あわせて、three.jsのExtrudeGeometry自体が面取りの自己交差で頂点座標や法線ベクトルを破綻させるケース(three.js本体の既知の制約)に対する防御コードも追加し、破綻を検知した場合は面取りを自動的に無効化して再構築し、無音で真っ黒になる代わりに警告メッセージを出すようにした(`_isGeometryValid()`)。 + +**⚙設定モーダルのタブ化**(ユーザー要望): 3カラム構成に再設計。左=「ライト」「マテリアル」「カメラ」のタブ切替パネル、中央=実描画中のプレビュー、右=常時表示の「テキスト」パネル(テキスト内容・フォント・整列・**行間スライダー(新規)**)。従来ツールバーに常時表示していたテキスト入力・フォント選択・整列ボタンをモーダルへ移動し、ツールバーはモード切替・押し出し厚み・面取りON/OFF・⚙3D設定ボタンのみのシンプルな構成にした。これに伴い、フォント読込ロジックをDOM値読取(`fontSelect.value`)から`state.text3d.fontFamily`/`fontSource`(stateが正)駆動へリファクタリング(モーダルは開いている間しかDOM要素が存在しないため)。フォント一覧取得はレイアウトタブ・Imageタブで実装が異なる(Google一覧の取得元・システムフォントキャッシュが別々)ため、モーダル自体はロジックを持たず`fontControls`コールバック一式を呼び出し側から受け取って委譲する設計にした。テキスト・整列・行間はモーダル内でeditorへ直接適用されるため、モーダルを閉じた時点(`onClose`)で`editor.getParams()`から読み戻してstateに同期する。 + +**UI文言調整**(ユーザー要望): モーダルタイトルを多言語化しない固定名称「3D Text Editor」に変更(3Dポーズの「RC」ボタン表記と同じ扱い)。⚙ボタンを「⚙ 設定」→「⚙ 3D設定」に変更しツールチップに「テキスト編集」を追加。テキスト内容の初期値を空文字から「text」に変更し、「コマに配置」を押しただけで即座に3Dビューに何か表示され操作に迷わないようにした。 + +Kaptureで実機E2E確認済み: SVGファイル選択・立体化・表面/側面カラー分離・面取り詳細スライダー(意図的に極端な値を試し、以前は真っ黒になっていた組み合わせでも正しく表示され続けることを確認)・タブ切替・フォントソース切替(Google/システム)・整列・行間スライダーのライブ反映・モーダルを閉じた後の状態保持・UI文言変更を確認。新規コード起因のコンソールエラーなし。ヘルプ(ja/en/zh、22-help-tab.jsの`_HELP_DATA`と`_HELP_I18N.en`/`.zh`両方)・README 3言語を更新済み。バージョン番号は未更新(リリースは別途依頼時に対応)。 + +--- + ## 2026-08-07(デフォルトワークフロー系チェックボックスがRunに反映されないバグを修正、v1.30.1) ユーザー報告「T2I一括生成でデフォルトワークフローのチェックを外して別のワークフロー(boogu_t2i.json)を生成UIタブに読み込んで実行したが、ernie_t2i.jsonで実行される」を調査。 diff --git a/README.md b/README.md index fa39e1f..e0f4847 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ ComfyUI 上で動作するマンガページ作成 SPA(シングルページ - **形状描画(ドロー)** — 矩形・楕円・直線・曲線・多角形・ベクター曲線・鎖・ロープ・My 曲線を SVG レイヤーに直接描画。多角形はクリックで頂点追加+始点付近クリックで確定、ベクター曲線はクリックでノード追加してなめらかな曲線(スプライン)で結び、始点付近クリックで閉じたシェイプ、Enter キーで開いた線として確定。塗り(矩形・楕円・多角形・ベクター曲線等)は単色に加えグラデーション・テクスチャ(位置 X/Y 指定可、シェイプの移動・リサイズに追従)・塗りなしに対応。図形の PNG 変換もこれらの塗りをそのまま反映 - **ペイントツール** — 形状描画(ドロー)とは別系統のラスターブラシ機能。「ペイントを追加」ボタンで選択中のコマ/オーバーレイ/下書きレイヤーのサイズにぴったり合わせた画像を自動生成・選択し、そのまま描画 ON でブラシ描画を開始できる(左隣の「背景色」チェックボックスをONにすると指定色の不透明画像として作成でき、透過のままI2Iへ送ると背景が黒くなる問題を回避可能)。ブラシは新規ペイントオブジェクトだけでなく選択中の通常画像にも直接描き込める。ブラシ色・不透明度・サイズ(スライダー)に加え、x5(ブラシサイズ 5 倍)・消しゴムの ON/OFF をボタンの色で明示するトグルに対応。レイヤーパネルには 🖌 アイコン・「ペイント」の名前で表示され通常画像と区別できる。「選択画像を統合」ボタンでレイヤーパネルのチェックボックスで選んだ画像・ペイントレイヤー(2枚以上)を重ね順・回転を保ったまま1枚のPNGへ合成でき、I2Iに複数レイヤーをまとめて送りたい場合に活用できる。Image タブでの下描き・I2I 参照画像の作成などに活用可能 - **3D ポーズ** — VRM/GLB/GLTF モデルをコマ内に配置してポーズを付け画像に焼き込み。視線ターゲット追従・揺れボーン物理・そよ風エフェクト(発生源マーカーで向き指定)にも対応([comfyui-vrm-pose-editor](#依存関係任意) 連携) -- **3D テキスト** — 日本語を含むテキストを Three.js で立体的に押し出して画像に焼き込み。TrueType Collection(.ttc、游ゴシック・メイリオ等の Windows 標準 CJK フォント)にも対応。マテリアルは標準(金属・粗さ)/トゥーン(MToon 互換シェーディング)から選択可能。**⚙ 設定**モーダルでマテリアル・ライト(環境光/メインライト/フィルライトの色・強度・位置)・カメラ(ズーム操作モード・アンチエイリアス強化)をまとめて調整でき、実描画中のプレビューをモーダル内に直接埋め込んで確認しながら編集できる。ズーム操作モードとアンチエイリアス強化は 3D ポーズのライトエディタと設定を共有([comfyui-vrm-pose-editor](#依存関係任意) 連携) +- **3D テキスト** — 日本語を含むテキスト、または SVG ファイルのベクターパスを Three.js で立体的に押し出して画像に焼き込み(「テキスト」「SVG」モード切替)。TrueType Collection(.ttc、游ゴシック・メイリオ等の Windows 標準 CJK フォント)にも対応。マテリアルは標準(金属・粗さ)/トゥーン(MToon 互換シェーディング)から選択でき、**表面色・側面色を個別に指定**できる(ExtrudeGeometry の表裏キャップ面/側面それぞれに別マテリアルを適用)。**⚙ 3D設定(3D Text Editor)**モーダルは左=ライト/マテリアル/カメラのタブ切替パネル、中央=実描画中のプレビュー、右=常時表示のテキストパネル(内容・フォント・整列・行間)という3カラム構成。面取りの厚み・サイズ・分割数も細かく調整でき、形状に対して大きすぎる場合は自動的に無効化され警告が表示される。ズーム操作モードとアンチエイリアス強化は 3D ポーズのライトエディタと設定を共有([comfyui-vrm-pose-editor](#依存関係任意) 連携) - **グループ機能・レイヤーパネル** — オブジェクトのグループ化、重ね順、表示切替、ロック、**Delete / Backspace キーでの削除** - **下書きレイヤー** — オーバーレイのさらに前面・ページ全体を覆う下書き専用レイヤー(画像のみ)。選択中(編集モード)のときだけプレビュー上でクリック・ドラッグ操作でき、それ以外は下のオーバーレイ/コマ/オブジェクトへクリックが透過。出力(JPEG/PNG/WebP/PDF/EPUB)には一切含まれない。Image タブの「下書き」ボタンで作品と同じ縦横比・72dpi 換算のキャンバスを作成し、「レイアウトに送る」で自動的にこのレイヤーへページ全面サイズで挿入される - **I2I 連携** — 「I2I」ボタンから、選択画像または現在のページ全体を対象に切り替え可能なモーダルを開き、Positive/Negative プロンプト・Denoise を設定してその場で Workflow Studio へ送信・生成を実行できる(結果は選択画像対象なら選択中のコマ/オーバーレイ/下書きへ、ページ全体対象は常にオーバーレイへページ全面サイズで新規画像として挿入)。デフォルトワークフローの自動読み込みはモーダル内および Image タブの Select ツール内 I2I パネルで指定可能(共有設定、[ComfyUI-Workflow-Studio](#依存関係任意) 連携) diff --git a/README_en.md b/README_en.md index 10e33f9..5cee2af 100644 --- a/README_en.md +++ b/README_en.md @@ -25,7 +25,7 @@ A manga page creation SPA (single-page application) that runs on top of ComfyUI. - **Shape drawing (Draw)** — Draw rectangles, ellipses, lines, curves, polygons, vector curves, chains, ropes, and My Curve directly onto an SVG layer. Polygons: click to add vertices, click near the start point to close. Vector curves: click to add nodes connected by a smooth spline, click near the start point to close as a filled shape, or press Enter to commit as an open line. Fills (rectangles, ellipses, polygons, vector curves, etc.) support gradients, textures (with configurable X/Y offset that follows the shape when moved or resized), and no-fill in addition to solid colors. Converting a shape to PNG preserves these fills as well - **Paint tool** — A raster-brush painting feature, separate from vector shape drawing. Clicking "Add Paint" creates an image sized exactly to the selected panel/overlay/draft layer, selects it automatically, and turns Draw ON so you can start brushing right away (turning on the "Background" checkbox to its left fills it with an opaque color instead of full transparency, avoiding black backgrounds when sent to I2I). The brush works on any selected regular image too, not just newly added paint objects. Supports brush color, opacity, and size (slider), plus x5 (5x brush size) and eraser toggles whose ON/OFF state is shown by button color. Paint objects appear in the layer panel with a 🖌 icon and the name "Paint" to distinguish them from regular images. The "Merge Selected" button composites 2+ checked images/paint layers in the layer panel into a single PNG (preserving stacking order and rotation), useful for sending multiple layers combined to I2I. Useful for rough sketches to edit in the Image tab, or for creating I2I reference images - **3D pose** — Place a VRM/GLB/GLTF model inside a panel, pose it, and bake it into the image. Supports a draggable LookAt target, spring bone (hair/skirt) physics, and a breeze wind effect with a draggable wind-direction marker (via [comfyui-vrm-pose-editor](#optional-dependencies)) -- **3D text** — Extrude text (including Japanese) into 3D using Three.js and bake it into the image. Supports TrueType Collection (.ttc) fonts, common among Windows CJK system fonts (Yu Gothic, Meiryo, etc.). Choose between a Standard material (metalness/roughness) or Toon (MToon-compatible shading). The **⚙ Settings** modal gathers material, lights (color/intensity/position for the Ambient/Key/Fill lights), and camera (zoom control mode, enhanced antialiasing) into one place, with the live preview embedded directly inside the modal so you can see changes immediately. Zoom control mode and enhanced antialiasing are shared with the 3D Pose light editor (via [comfyui-vrm-pose-editor](#optional-dependencies)) +- **3D text** — Extrude text (including Japanese), or the vector paths of an SVG file, into 3D using Three.js and bake it into the image ("Text" / "SVG" mode switch). Supports TrueType Collection (.ttc) fonts, common among Windows CJK system fonts (Yu Gothic, Meiryo, etc.). Choose between a Standard material (metalness/roughness) or Toon (MToon-compatible shading), and **set the front-face and side-face colors independently** (a separate material is applied to the geometry's front/back cap faces versus its extruded side faces). The **⚙ 3D Settings (3D Text Editor)** modal has a 3-column layout: a tab switcher for Light / Material / Camera on the left, a live preview embedded directly inside the modal in the center, and an always-visible Text panel (content, font, alignment, line height) on the right. Bevel thickness, size, and segment count can also be fine-tuned, and are automatically disabled with a warning if too large for the shape. Zoom control mode and enhanced antialiasing are shared with the 3D Pose light editor (via [comfyui-vrm-pose-editor](#optional-dependencies)) - **Groups and layer panel** — Group objects, manage stacking order, toggle visibility, lock, and **delete with the Delete / Backspace key** - **Draft layer** — A draft-only layer that sits in front of the overlay and covers the whole page (images only). Clicks only reach it while it's selected (edit mode); otherwise clicks pass straight through to the overlay/panels/objects below. Never included in output (JPEG/PNG/WebP/PDF/EPUB). The Image tab's "Draft" button creates a canvas at the same aspect ratio as the active work at 72dpi, and "Send to Layout" automatically inserts it into this layer at full page size - **I2I integration** — The "I2I" button opens a modal where you switch the target between a selected image or the whole current page, set Positive/Negative prompts and Denoise, and run Workflow Studio generation on the spot (results are inserted as a new image — into the selected panel/overlay/draft layer for a selected image, or always into the overlay at full page size for the whole page). A default workflow to auto-load can be configured both in the modal and in the I2I panel of the Image tab's Select tool (shared setting, via [ComfyUI-Workflow-Studio](#optional-dependencies)) diff --git a/README_zh.md b/README_zh.md index e72d0e1..95a0a47 100644 --- a/README_zh.md +++ b/README_zh.md @@ -25,7 +25,7 @@ - **形状绘制(绘制)** — 直接在SVG图层上绘制矩形、椭圆、直线、曲线、多边形、矢量曲线、锁链、绳索、My Curve。多边形:点击添加顶点,点击起点附近闭合;矢量曲线:点击添加节点,以平滑曲线(样条)连接,点击起点附近闭合为填充形状,或按Enter键确定为开放曲线。填充(矩形・椭圆・多边形・矢量曲线等)除纯色外还支持渐变、纹理(可指定X/Y位置偏移,会随形状的移动/缩放一起跟随)和无填充。图形转换为PNG时这些填充也会一并保留 - **绘画工具** — 与矢量图形绘制完全独立的光栅画笔功能。点击"添加绘画"会按所选分格/覆盖层/草稿层的尺寸创建图像,自动选中并开启绘制ON,可立即用画笔绘制(开启左侧的"背景色"复选框可改为创建指定颜色填充的不透明图像,避免发送到I2I时背景变黑)。画笔不仅可用于新添加的绘画对象,也可直接在当前选中的任意普通图像上绘制。支持画笔颜色、不透明度、大小(滑块),以及x5(5倍画笔大小)和橡皮擦的ON/OFF切换按钮(通过颜色明确显示状态)。绘画对象在图层面板中以🖌图标和"绘画"名称显示,与普通图像区分开。"统合选中图像"按钮可将图层面板中勾选的2个以上图像/绘画对象合成为一张PNG(保留叠放顺序和旋转),便于将多个图层合并后发送到I2I。可用于Image标签中绘制・编辑的草图,或制作I2I参考图像 - **3D姿势** — 在分格内放置 VRM/GLB/GLTF 模型并摆姿势后烘焙到图像中,支持可拖动的视线目标追踪、摇动骨骼(头发/裙子)物理效果,以及带可拖动风向标记的微风效果(通过 [comfyui-vrm-pose-editor](#可选依赖) 联动) -- **3D文字** — 使用Three.js将包含日文在内的文字立体挤出后烘焙到图像中,支持TrueType Collection(.ttc,游黑体・Meiryo等Windows标准CJK字体常见格式)。材质可从标准(金属度・粗糙度)或卡通(兼容MToon的着色)中选择。**⚙ 设置**弹窗汇集了材质、灯光(环境光/主光源/补光的颜色・强度・位置)、相机(缩放操作模式・增强抗锯齿)设置,并将实际渲染中的预览直接嵌入弹窗内即时确认。缩放操作模式与增强抗锯齿与3D姿势的灯光编辑器共用设置(通过 [comfyui-vrm-pose-editor](#可选依赖) 联动) +- **3D文字** — 使用Three.js将包含日文在内的文字、或SVG文件的矢量路径立体挤出后烘焙到图像中("文字""SVG"模式切换),支持TrueType Collection(.ttc,游黑体・Meiryo等Windows标准CJK字体常见格式)。材质可从标准(金属度・粗糙度)或卡通(兼容MToon的着色)中选择,并可**分别指定表面色与侧面色**(对几何体的正反面盖面与挤出侧面分别应用不同材质)。**⚙ 3D设置(3D Text Editor)**弹窗采用三栏布局:左侧为灯光/材质/相机标签切换面板,中央为直接嵌入弹窗的实时预览,右侧为常驻显示的文字面板(内容・字体・对齐・行距)。倒角的厚度・大小・分段数也可细致调整,若相对于形状过大会自动禁用并显示警告。缩放操作模式与增强抗锯齿与3D姿势的灯光编辑器共用设置(通过 [comfyui-vrm-pose-editor](#可选依赖) 联动) - **分组功能与图层面板** — 对象分组、层叠顺序管理、显示切换、锁定,以及**通过 Delete / Backspace 键删除** - **草稿图层** — 位于覆盖层更前面、覆盖整个页面的草稿专用图层(仅支持图像)。只有在选中(编辑模式)时才能在预览中点击・拖动操作,其他时候点击会直接穿透到下方的覆盖层/分格/对象。不会包含在任何输出格式(JPEG/PNG/WebP/PDF/EPUB)中。Image标签的"草稿"按钮可创建与当前作品相同宽高比、72dpi换算尺寸的画布,通过"发送到排版"会自动以整页尺寸插入到该图层 - **I2I联动** — "I2I"按钮会打开一个弹窗,可切换对象为选中图像或当前整页,设置Positive/Negative提示词和Denoise后就地执行Workflow Studio生成(结果作为新图像插入:选中图像对象插入到选中的分格/覆盖层/草稿图层,整页对象则始终以整页尺寸插入到覆盖层)。自动加载的默认工作流可在弹窗内以及 Image 标签 Select 工具的 I2I 面板中配置(共用设置,通过 [ComfyUI-Workflow-Studio](#可选依赖) 联动) diff --git a/static/js/i18n.js b/static/js/i18n.js index 490b0cd..63df825 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -489,6 +489,10 @@ const LANGUAGES = { 'layout.text3dTextPlaceholder': 'テキストを入力', 'layout.text3dFontSourceGoogle': 'Google', 'layout.text3dFontSourceSystem': 'システム', + 'layout.text3dTextSection': 'テキスト', + 'layout.text3dFontSection': 'フォント', + 'layout.text3dAlignSection': '整列', + 'layout.text3dLineHeightLabel': '行間:', 'layout.text3dDepthLabel': '厚み:', 'layout.text3dBevelLabel': '面取り', 'layout.text3dColorLabel': '色:', @@ -498,10 +502,26 @@ const LANGUAGES = { 'layout.text3dMaterialToon': 'トゥーン', 'layout.text3dShadeColorLabel': '陰色:', 'layout.text3dToonyLabel': '階調:', - 'layout.text3dSettingsBtn': '⚙ 設定', - 'layout.text3dSettingsBtnTitle': 'ライト・マテリアル(色/質感)・ズーム操作方式を設定します', - 'layout.text3dSettingsModalTitle': '⚙ ライト・マテリアル設定', + 'layout.text3dSettingsBtn': '⚙ 3D設定', + 'layout.text3dSettingsBtnTitle': 'ライト・マテリアル(色/質感)・テキスト編集・ズーム操作方式を設定します', 'layout.text3dMaterialSection': 'マテリアル', + // -- 3Dテキスト: SVG立体化・表面/側面カラー・面取り詳細 -- + 'layout.text3dModeText': 'テキスト', + 'layout.text3dModeSvg': 'SVG', + 'layout.text3dSvgFileNoneLabel': 'ファイル未選択', + 'layout.text3dSvgSizeLabel': 'サイズ:', + 'layout.text3dFrontColorLabel': '表面色:', + 'layout.text3dSideColorLabel': '側面色:', + 'layout.text3dSeparateSidesLabel': '表面/側面を分ける', + 'layout.text3dBevelSection': '面取り詳細', + 'layout.text3dBevelThicknessLabel': '厚み:', + 'layout.text3dBevelSizeLabel': 'サイズ:', + 'layout.text3dBevelSegmentsLabel': '分割数:', + 'layout.text3dSvgTextNodeWarning': 'SVG内に未パス化のテキスト要素()が含まれています。イラストソフトで「テキストをパスに変換」してから保存してください。', + 'layout.text3dSvgImageNodeWarning': 'SVG内にビットマップ画像()が含まれています。画像ではなくベクターパスのみが3D化されます。', + 'layout.text3dSvgNoPathWarning': '有効なベクターパスが見つかりませんでした。イラストソフトで「テキストをパスに変換」やストロークのパス変換を行ってから保存してください。', + 'layout.text3dSvgParseFailed': 'SVGの解析に失敗しました。ファイルが壊れているか、対応していない形式の可能性があります。', + 'layout.text3dSvgBevelTooLargeWarning': '面取り(厚み/サイズ)がこの形状の細い部分に対して大きすぎたため、面取りを無効化して再表示しました。⚙設定で面取りの値を小さくすると有効化できます。', 'layout.text3dLightSection': 'ライト', 'layout.text3dCameraSection': 'カメラ', 'layout.text3dLightIntensityLabel': '強度:', @@ -1785,6 +1805,10 @@ const LANGUAGES = { 'layout.text3dTextPlaceholder': 'Enter text', 'layout.text3dFontSourceGoogle': 'Google', 'layout.text3dFontSourceSystem': 'System', + 'layout.text3dTextSection': 'Text', + 'layout.text3dFontSection': 'Font', + 'layout.text3dAlignSection': 'Alignment', + 'layout.text3dLineHeightLabel': 'Line Height:', 'layout.text3dDepthLabel': 'Depth:', 'layout.text3dBevelLabel': 'Bevel', 'layout.text3dColorLabel': 'Color:', @@ -1794,10 +1818,26 @@ const LANGUAGES = { 'layout.text3dMaterialToon': 'Toon', 'layout.text3dShadeColorLabel': 'Shade:', 'layout.text3dToonyLabel': 'Toony:', - 'layout.text3dSettingsBtn': '⚙ Settings', - 'layout.text3dSettingsBtnTitle': 'Configure lights, material (color/finish), and zoom control mode', - 'layout.text3dSettingsModalTitle': '⚙ Light & Material Settings', + 'layout.text3dSettingsBtn': '⚙ 3D Settings', + 'layout.text3dSettingsBtnTitle': 'Configure lights, material (color/finish), text editing, and zoom control mode', 'layout.text3dMaterialSection': 'Material', + // -- 3D Text: SVG extrusion, front/side color, bevel details -- + 'layout.text3dModeText': 'Text', + 'layout.text3dModeSvg': 'SVG', + 'layout.text3dSvgFileNoneLabel': 'No file selected', + 'layout.text3dSvgSizeLabel': 'Size:', + 'layout.text3dFrontColorLabel': 'Front:', + 'layout.text3dSideColorLabel': 'Side:', + 'layout.text3dSeparateSidesLabel': 'Separate front/side colors', + 'layout.text3dBevelSection': 'Bevel Details', + 'layout.text3dBevelThicknessLabel': 'Thickness:', + 'layout.text3dBevelSizeLabel': 'Size:', + 'layout.text3dBevelSegmentsLabel': 'Segments:', + 'layout.text3dSvgTextNodeWarning': 'The SVG contains unconverted text elements (). Convert text to paths in your vector editor before saving.', + 'layout.text3dSvgImageNodeWarning': 'The SVG contains raster images (). Only vector paths are extruded into 3D, not images.', + 'layout.text3dSvgNoPathWarning': 'No valid vector paths were found. Convert text/strokes to paths in your vector editor before saving.', + 'layout.text3dSvgParseFailed': 'Failed to parse the SVG. The file may be corrupted or in an unsupported format.', + 'layout.text3dSvgBevelTooLargeWarning': 'The bevel (thickness/size) was too large for this shape’s thin parts, so the bevel was disabled and the shape was re-rendered. Lower the bevel values in ⚙ Settings to re-enable it.', 'layout.text3dLightSection': 'Lights', 'layout.text3dCameraSection': 'Camera', 'layout.text3dLightIntensityLabel': 'Intensity:', @@ -3062,6 +3102,10 @@ const LANGUAGES = { 'layout.text3dTextPlaceholder': '输入文字', 'layout.text3dFontSourceGoogle': 'Google', 'layout.text3dFontSourceSystem': '系统', + 'layout.text3dTextSection': '文字', + 'layout.text3dFontSection': '字体', + 'layout.text3dAlignSection': '对齐', + 'layout.text3dLineHeightLabel': '行距:', 'layout.text3dDepthLabel': '厚度:', 'layout.text3dBevelLabel': '倒角', 'layout.text3dColorLabel': '颜色:', @@ -3071,10 +3115,26 @@ const LANGUAGES = { 'layout.text3dMaterialToon': '卡通', 'layout.text3dShadeColorLabel': '阴影色:', 'layout.text3dToonyLabel': '色阶:', - 'layout.text3dSettingsBtn': '⚙ 设置', - 'layout.text3dSettingsBtnTitle': '设置灯光・材质(颜色/质感)・缩放操作方式', - 'layout.text3dSettingsModalTitle': '⚙ 灯光与材质设置', + 'layout.text3dSettingsBtn': '⚙ 3D设置', + 'layout.text3dSettingsBtnTitle': '设置灯光・材质(颜色/质感)・文字编辑・缩放操作方式', 'layout.text3dMaterialSection': '材质', + // -- 3D文字:SVG立体化・表面/侧面颜色・倒角详细 -- + 'layout.text3dModeText': '文字', + 'layout.text3dModeSvg': 'SVG', + 'layout.text3dSvgFileNoneLabel': '未选择文件', + 'layout.text3dSvgSizeLabel': '大小:', + 'layout.text3dFrontColorLabel': '表面颜色:', + 'layout.text3dSideColorLabel': '侧面颜色:', + 'layout.text3dSeparateSidesLabel': '表面/侧面分开设置', + 'layout.text3dBevelSection': '倒角详细设置', + 'layout.text3dBevelThicknessLabel': '厚度:', + 'layout.text3dBevelSizeLabel': '大小:', + 'layout.text3dBevelSegmentsLabel': '分段数:', + 'layout.text3dSvgTextNodeWarning': 'SVG中包含未转换为路径的文本元素()。请在矢量图软件中将文字转换为路径后再保存。', + 'layout.text3dSvgImageNodeWarning': 'SVG中包含位图图像()。只有矢量路径会被立体化,图像不会。', + 'layout.text3dSvgNoPathWarning': '未找到有效的矢量路径。请在矢量图软件中将文字/描边转换为路径后再保存。', + 'layout.text3dSvgParseFailed': 'SVG解析失败。文件可能已损坏或格式不受支持。', + 'layout.text3dSvgBevelTooLargeWarning': '倒角(厚度/大小)相对于该形状的细窄部分过大,已自动禁用倒角并重新渲染。请在⚙设置中调小倒角数值后再启用。', 'layout.text3dLightSection': '灯光', 'layout.text3dCameraSection': '摄像机', 'layout.text3dLightIntensityLabel': '强度:', diff --git a/static/js/image-tab.js b/static/js/image-tab.js index ac3baa2..a29a110 100644 --- a/static/js/image-tab.js +++ b/static/js/image-tab.js @@ -551,24 +551,6 @@ class ImageTab { sel.innerHTML = list.map(f => ``).join(""); } - /** #ie-text3d-font の options を _text3dTool.fontSource(Google/System)で再構築する */ - async _populateText3DFontSelect() { - const t3 = this._text3dTool; - if (!t3) return; - const families = t3.fontSource === "system" - ? await this._getSystemFontFamiliesCached() - : _fontMgrGoogleList(); - const sel = document.getElementById("ie-text3d-font"); - if (!sel) return; // 取得中にツールが切り替わっていた場合 - const current = t3.fontFamily; - if (families.length === 0) { - sel.innerHTML = ``; - return; - } - const list = families.includes(current) ? families : [current, ...families]; - sel.innerHTML = list.map(f => ``).join(""); - } - async _checkBiRefNetAvailability() { try { const resp = await fetch("/mask_editor/birefnet/status"); @@ -1019,17 +1001,22 @@ class ImageTab { }); } else if (toolId === "text3d" && this._text3dTool) { const t3 = this._text3dTool; + const isSvg = t3.renderMode === "svg"; el.innerHTML = ` -
- -
- - - + +
+ ${!isSvg ? `` : ` +
+ + ${t3.svgFileName || t("layout.text3dSvgFileNoneLabel")} +
+
+ + +
+ `}
@@ -1038,11 +1025,6 @@ class ImageTab {
-
- - - -
@@ -1052,27 +1034,29 @@ class ImageTab {
`; - document.getElementById("ie-text3d-input")?.addEventListener("input", e => t3.setText(e.target.value)); - ["google", "system"].forEach(src => { - document.getElementById(`ie-text3d-fsrc-${src}`)?.addEventListener("click", async () => { - if (t3.fontSource === src) return; - t3.fontSource = src; - await this._populateText3DFontSelect(); + document.getElementById("ie-text3d-mode-text")?.addEventListener("click", () => { + if (t3.renderMode === "text") return; + t3.setRenderMode("text"); + this._renderToolOptions("text3d"); + }); + document.getElementById("ie-text3d-mode-svg")?.addEventListener("click", () => { + if (t3.renderMode === "svg") return; + t3.setRenderMode("svg"); + this._renderToolOptions("text3d"); + }); + if (isSvg) { + document.getElementById("ie-text3d-svg-file")?.addEventListener("change", async e => { + const file = e.target.files?.[0]; + if (!file) return; + let warning = ""; + try { warning = await t3.setSvgFile(file); } catch (err) { console.error("[text3d] SVG読込失敗:", err); } this._renderToolOptions("text3d"); + if (warning) this._toast(warning, "info"); }); - }); - document.getElementById("ie-text3d-font")?.addEventListener("change", async e => { - try { await t3.setFont(e.target.value, t3.fontSource); } catch { /* エラーはreloadFont内でconsole出力済み */ } - }); - this._populateText3DFontSelect(); + document.getElementById("ie-text3d-svg-size")?.addEventListener("input", e => t3.setSvgSize(parseFloat(e.target.value))); + } document.getElementById("ie-text3d-depth")?.addEventListener("input", e => t3.setDepth(parseFloat(e.target.value))); document.getElementById("ie-text3d-bevel")?.addEventListener("change", e => t3.setBevel(e.target.checked)); - ["left", "center", "right"].forEach(al => { - document.getElementById(`ie-text3d-align-${al}`)?.addEventListener("click", () => { - t3.setAlign(al); - this._renderToolOptions("text3d"); - }); - }); document.getElementById("ie-text3d-reset-camera-btn")?.addEventListener("click", () => t3.resetCamera()); document.getElementById("ie-text3d-settings-btn")?.addEventListener("click", () => t3.openSettingsModal()); document.getElementById("ie-text3d-commit-btn")?.addEventListener("click", () => { diff --git a/static/js/image-tab/Text3DTool.js b/static/js/image-tab/Text3DTool.js index d33c6eb..48e6e69 100644 --- a/static/js/image-tab/Text3DTool.js +++ b/static/js/image-tab/Text3DTool.js @@ -10,10 +10,38 @@ import { loadFontForText3d } from '../text3d-font-loader.js'; import { openText3DSettingsModal } from '../text3d-settings-modal.js'; +import { t } from '../i18n.js'; +import { _fontMgrGoogleList } from '../main/19-font-manager.js'; const DEFAULT_CSS_W = 480; const DEFAULT_CSS_H = 320; +let _systemFontFamiliesCache = null; // window.queryLocalFonts() の結果(family名の重複除去済み配列)をキャッシュ(25-text3d-bridge.jsと同一パターン) + +async function _loadSystemFontFamilies() { + if (typeof window.queryLocalFonts !== 'function') return []; + try { + const fonts = await window.queryLocalFonts(); + return Array.from(new Set(fonts.map(f => f.family))).sort(); + } catch (e) { + console.warn('[text3d] システムフォント一覧の取得に失敗しました:', e); + return []; + } +} + +// editor.getSvgWarnings() が返す警告キー → i18nキーの対応表(25-text3d-bridge.jsと同一マッピング) +const SVG_WARNING_I18N_KEYS = { + svgTextNode: 'layout.text3dSvgTextNodeWarning', + svgImageNode: 'layout.text3dSvgImageNodeWarning', + svgNoPath: 'layout.text3dSvgNoPathWarning', + svgParseFailed: 'layout.text3dSvgParseFailed', + svgBevelTooLarge: 'layout.text3dSvgBevelTooLargeWarning', +}; + +function _formatSvgWarnings(keys) { + return (keys || []).map(k => t(SVG_WARNING_I18N_KEYS[k] ?? k)).join(' / '); +} + export class Text3DTool { constructor(hostCanvas) { this.hostCanvas = hostCanvas; // ie-canvas-draw(座標変換の基準にのみ使う) @@ -30,15 +58,25 @@ export class Text3DTool { this.text = '3D Text'; this.fontFamily = 'BIZ UDPGothic'; this.fontSource = 'google'; + this.renderMode = 'text'; // 'text' | 'svg' + this.svgData = ''; + this.svgFileName = ''; + this.svgSize = 1.5; this.depth = 0.15; this.bevelEnabled = false; - this.color = '#ffffff'; + this.bevelThickness = 0.02; + this.bevelSize = 0.01; + this.bevelSegments = 2; + this.frontColor = '#ffffff'; + this.sideColor = '#ffffff'; + this.separateSides = false; this.metalness = 0.1; this.roughness = 0.6; this.materialType = 'standard'; this.shadeColor = '#999999'; this.toony = 0.9; this.align = 'center'; + this.lineHeight = 1.4; } setCanvas(canvas) { this.hostCanvas = canvas; } @@ -104,15 +142,27 @@ export class Text3DTool { this.text = initialProps.text ?? this.text; this.fontFamily = initialProps.fontFamily ?? this.fontFamily; this.fontSource = initialProps.fontSource ?? this.fontSource; + // 旧データ(renderMode/svgData導入前)は常にテキストモードとして復元する + this.renderMode = initialProps.renderMode === 'svg' ? 'svg' : 'text'; + this.svgData = initialProps.svgData ?? this.svgData; + this.svgFileName = initialProps.svgFileName ?? this.svgFileName; + this.svgSize = initialProps.svgSize ?? this.svgSize; this.depth = initialProps.depth ?? this.depth; this.bevelEnabled = !!initialProps.bevelEnabled; - this.color = initialProps.color ?? this.color; + this.bevelThickness = initialProps.bevelThickness ?? this.bevelThickness; + this.bevelSize = initialProps.bevelSize ?? this.bevelSize; + this.bevelSegments = initialProps.bevelSegments ?? this.bevelSegments; + // 旧データ(frontColor/sideColor導入前)は単色の`color`をfrontColor/sideColor両方へフォールバックする + this.frontColor = initialProps.frontColor ?? initialProps.color ?? this.frontColor; + this.sideColor = initialProps.sideColor ?? initialProps.frontColor ?? initialProps.color ?? this.sideColor; + this.separateSides = !!initialProps.separateSides; this.metalness = initialProps.metalness ?? this.metalness; this.roughness = initialProps.roughness ?? this.roughness; this.materialType = initialProps.materialType ?? this.materialType; this.shadeColor = initialProps.shadeColor ?? this.shadeColor; this.toony = initialProps.toony ?? this.toony; this.align = initialProps.align ?? this.align; + this.lineHeight = initialProps.lineHeight ?? this.lineHeight; } const initFn = window.initText3DEditor; @@ -143,14 +193,24 @@ export class Text3DTool { _applyAllPropsToEditor() { if (!this._editor) return; this._editor.setDepth(this.depth); - this._editor.setBevel(this.bevelEnabled); - this._editor.setColor(this.color); + this._editor.setBevel(this.bevelEnabled, { + bevelThickness: this.bevelThickness, + bevelSize: this.bevelSize, + bevelSegments: this.bevelSegments, + }); + this._editor.setFrontColor(this.frontColor); + this._editor.setSideColor(this.sideColor); + this._editor.setSeparateSides(this.separateSides); this._editor.setMaterialType(this.materialType); this._editor.setMetalness(this.metalness); this._editor.setRoughness(this.roughness); this._editor.setShadeColor(this.shadeColor); this._editor.setToony(this.toony); this._editor.setAlign(this.align); + this._editor.setLineHeight(this.lineHeight); + this._editor.setSvgSize(this.svgSize); + this._editor.setSvgData(this.svgData); + this._editor.setRenderMode(this.renderMode); } // フォント変更時(ソース/ファミリー変更)に呼ぶ @@ -169,17 +229,36 @@ export class Text3DTool { // _editor(Three.jsエディタインスタンス)を外部から直接触らせないためのカプセル化。 setText(v) { this.text = v; this._editor?.setText(v); } setDepth(v) { this.depth = v; this._editor?.setDepth(v); } - setBevel(v) { this.bevelEnabled = v; this._editor?.setBevel(v); } - setColor(v) { this.color = v; this._editor?.setColor(v); } + setBevel(v) { this.bevelEnabled = v; this._editor?.setBevel(v, { bevelThickness: this.bevelThickness, bevelSize: this.bevelSize, bevelSegments: this.bevelSegments }); } + setBevelThickness(v) { this.bevelThickness = v; this._editor?.setBevel(this.bevelEnabled, { bevelThickness: v }); } + setBevelSize(v) { this.bevelSize = v; this._editor?.setBevel(this.bevelEnabled, { bevelSize: v }); } + setBevelSegments(v) { this.bevelSegments = v; this._editor?.setBevel(this.bevelEnabled, { bevelSegments: v }); } + setFrontColor(v) { this.frontColor = v; this._editor?.setFrontColor(v); } + setSideColor(v) { this.sideColor = v; this._editor?.setSideColor(v); } + setSeparateSides(v) { this.separateSides = v; this._editor?.setSeparateSides(v); } setMaterialType(v) { this.materialType = v; this._editor?.setMaterialType(v); } setMetalness(v) { this.metalness = v; this._editor?.setMetalness(v); } setRoughness(v) { this.roughness = v; this._editor?.setRoughness(v); } setShadeColor(v) { this.shadeColor = v; this._editor?.setShadeColor(v); } setToony(v) { this.toony = v; this._editor?.setToony(v); } setAlign(v) { this.align = v; this._editor?.setAlign(v); } + setLineHeight(v) { this.lineHeight = v; this._editor?.setLineHeight(v); } // OrbitControls操作でカメラを見失った場合に、現在のテキストが見える位置へ復帰させる resetCamera() { this._editor?.resetCamera(); } + // ---- SVG立体化モード ---- + setRenderMode(v) { this.renderMode = v; this._editor?.setRenderMode(v); } + setSvgSize(v) { this.svgSize = v; this._editor?.setSvgSize(v); } + // SVGファイルを読み込んでエディタへ反映し、警告メッセージ(未パス化テキスト等)を返す + async setSvgFile(file) { + if (!file) return ''; + const svgString = await file.text(); + this.svgData = svgString; + this.svgFileName = file.name; + this._editor?.setSvgData(svgString); + return _formatSvgWarnings(this._editor?.getSvgWarnings()); + } + // ⚙設定モーダルを開く。マテリアル系セッターはthis.setXxx経由でthis.xxxにも反映させ、 // commit()やopen()時の_applyAllPropsToEditor()が古い値で上書きしないようにする // (ライト・ズームモードは per-instance のプロパティを持たないためeditorへ直接委譲する)。 @@ -198,13 +277,38 @@ export class Text3DTool { getSuperSample: () => editor.getSuperSample(), setSuperSample: (v) => editor.setSuperSample(v), getParams: () => editor.getParams(), - setColor: (v) => this.setColor(v), + setFrontColor: (v) => this.setFrontColor(v), + setSideColor: (v) => this.setSideColor(v), + setSeparateSides: (v) => this.setSeparateSides(v), setMaterialType: (v) => this.setMaterialType(v), setMetalness: (v) => this.setMetalness(v), setRoughness: (v) => this.setRoughness(v), setShadeColor: (v) => this.setShadeColor(v), setToony: (v) => this.setToony(v), - }, this._wrapper); + setBevelThickness: (v) => this.setBevelThickness(v), + setBevelSize: (v) => this.setBevelSize(v), + setBevelSegments: (v) => this.setBevelSegments(v), + setText: (v) => this.setText(v), + setAlign: (v) => this.setAlign(v), + setLineHeight: (v) => this.setLineHeight(v), + }, this._wrapper, { + fontControls: { + getFontSource: () => this.fontSource, + setFontSource: (source) => { this.fontSource = source; }, + getFontFamily: () => this.fontFamily, + setFontFamily: (family) => { + this.setFont(family, this.fontSource).catch(() => { /* エラーはreloadFont内でconsole出力済み */ }); + }, + getFontFamilyOptions: async (source) => { + if (source === 'system') { + if (_systemFontFamiliesCache) return _systemFontFamiliesCache; + _systemFontFamiliesCache = await _loadSystemFontFamilies(); + return _systemFontFamiliesCache; + } + return _fontMgrGoogleList(); + }, + }, + }); } async setFont(family, source) { @@ -220,15 +324,25 @@ export class Text3DTool { text: this.text, fontFamily: this.fontFamily, fontSource: this.fontSource, + renderMode: this.renderMode, + svgData: this.svgData, + svgFileName: this.svgFileName, + svgSize: this.svgSize, depth: this.depth, bevelEnabled: this.bevelEnabled, - color: this.color, + bevelThickness: this.bevelThickness, + bevelSize: this.bevelSize, + bevelSegments: this.bevelSegments, + frontColor: this.frontColor, + sideColor: this.sideColor, + separateSides: this.separateSides, metalness: this.metalness, roughness: this.roughness, materialType: this.materialType, shadeColor: this.shadeColor, toony: this.toony, align: this.align, + lineHeight: this.lineHeight, }; const dataUrl = this._editor.capture(); const contentW = this._canvas.width; diff --git a/static/js/main/01-state.js b/static/js/main/01-state.js index d85272b..9be92b3 100644 --- a/static/js/main/01-state.js +++ b/static/js/main/01-state.js @@ -104,12 +104,27 @@ const state = { fontFamily: 'BIZ UDPGothic', resizeObserver: null, // SVGリサイズ監視 reeditImageEl: null, // 再編集対象の既存要素(nullなら新規配置モード) + // SVG立体化モード関連(パネルのモード切替UIが直接読み書きする。textareaやフォント選択と同階層) + renderMode: 'text', // 'text' | 'svg' + svgData: '', // 選択中SVGファイルの生文字列 + svgFileName: '', // 選択中SVGファイル名(表示用) + svgSize: 1.5, + // テキスト内容・整列・行間(⚙設定モーダルへツールバーから移動したため、UI要素ではなく + // ここで保持する。モーダル内で直接editorへ適用されるため、モーダルを閉じた時点で + // editor.getParams()から読み戻して同期する。 + // text初期値はtext3d-core.jsのparams初期値(空文字)とは異なりあえて'text'にしている + // (「コマに配置」を押した直後から3Dビューに何か表示され、操作に迷わないようにするため)。 + text: 'text', + align: 'center', + lineHeight: 1.4, // マテリアル設定(⚙設定モーダルへツールバーから移動したため、UI要素ではなくここで保持する。 // 既定値はtext3d-core.jsのparams初期値と一致させる) materialParams: { - color: '#ffffff', materialType: 'standard', + frontColor: '#ffffff', sideColor: '#ffffff', separateSides: false, + materialType: 'standard', metalness: 0.1, roughness: 0.6, shadeColor: '#999999', toony: 0.9, + bevelThickness: 0.02, bevelSize: 0.01, bevelSegments: 2, }, } }; diff --git a/static/js/main/22-help-tab.js b/static/js/main/22-help-tab.js index 65fdd14..4cdf883 100644 --- a/static/js/main/22-help-tab.js +++ b/static/js/main/22-help-tab.js @@ -55,7 +55,7 @@ const _HELP_DATA = [ { heading: 'テキスト', body: '
  • 右サイドパネルの「テキスト」サブタブでフォント・サイズ・縦横書きを設定してコマをクリックします。
  • 縦チェック: テキストを選択中に切り替えると、そのテキストが中心位置を保ったまま縦⇔横に即座に切り替わります(テキスト選択時はチェック状態もそのテキストの実状態に同期します)。
  • 既存テキストをダブルクリックすると再編集できます。
  • バウンディングボックスのリサイズハンドルでフォントサイズを変更できます。
  • 色: プリセット(黒/白/赤/青)から選びます。任意の色はスタイルモーダルの「塗り」で設定します。
  • フォント選択: 「Google」「システム」「カテゴリ」で表示元を切り替えます。Googleはフォント管理タブと共通のGoogle Fonts一覧です。「カテゴリ」選択時は横のセレクトで絞り込めます(カテゴリへのフォント登録はフォント管理タブで行います)。
  • スタイルボタン: 塗り・線・袋文字・影・太字・斜体・下線・文字寄せを設定するモーダルを開きます(文字寄せはImageタブ専用の設定で、レイアウトタブのテキストには適用されません)。塗りはチェックを外すと塗りなし(線・袋文字・影のみ描画)にでき、モード切替で単色/グラデーション/テクスチャを選べます。グラデーションは線形/円形・角度・カラーランプ(ストップのドラッグ移動・追加/削除)で設定し、テクスチャは画像ファイルを選択してスケール%で敷き詰めサイズを調整します(テクスチャのタイルサイズはフォントサイズに比例します)。テクスチャはX/Y欄でタイルの位置(位相)を手動でずらすこともでき、ドロー図形の塗りテクスチャと同様、テキストを移動・リサイズしてもテクスチャの見た目の位置がずれることはありません。これらの塗りはレイアウトタブ・Imageタブの両方のテキストで同じ見た目になります。プレビューは実際の適用と同じSVG描画のため、見た目がそのままキャンバスに反映されます。テキストを選択中であればそのテキストへ即座に適用し、未選択であれば選択中のコマ中心に新規挿入します。モーダルを開いた時点で選択中テキストの現在のフォントサイズ・スタイルが初期値として反映されるため、既存の設定を確認・微調整しながら変更できます。保存済みスタイルはフォント管理タブの「スタイル」一覧と共通です。線幅・袋文字幅・影はフォントサイズに対する相対値で保存されるため、フォントサイズを変更しても比率が保たれ、Imageタブ・フォント管理タブのプレビューと見た目が一致します。
  • Insert(テキスト→PNGの右隣): スクリプトタブのプロットで選択中のセル(シーン/要素/セリフ・説明等)の内容を、選択中のコマ中心にテキストとして挿入します(現在のフォント・縦横書き・色設定を適用)。
' }, { heading: '画像サブタブ', body: '
  • 「画像」サブタブで背景除去・シャープ・ノイズ除去などのProcessing処理と、G\'MIC GUIでの編集・反転(↔↕)が行えます。
  • すべてのコントロールがグレーの共通エリアに統合されています。
  • 背景除去(軽量 @imgly)のモデル品質: モデル選択の右に表示される品質セレクトで、低品質・軽量(42MB)/標準品質(84MB、既定)/高品質(168MB)を切り替えられます。品質が高いほど輪郭の精度は上がりますが、初回実行時のモデルダウンロード(インターネット接続が必要)が大きくなります。
  • 🎨 ハーフトーン: モノクロ/デュオトーンの網点(ハーフトーン)モーダルを開きます。2つのモードがあります。「画像を変換」は選択中の画像を網点画像に置き換えます(従来通り、明るさ・コントラスト・2値化・最小濃度など元画像の階調補正が使えます)。「パターンを作成」は元画像を使わず、選択中のコマ/オーバーレイのサイズにジャストフィットする網点パターンだけを新規オブジェクトとして生成・挿入します(濃度・グラデーション(線形/放射状)・背景透過を指定可能。本物のスクリーントーンシートを貼るような使い方ができます)。画像未選択でコマだけ選択していれば自動的に「パターンを作成」モードで開きます。共通パラメータとして色モード・パターン形状(ドット/線/正方形/十字)・ドットサイズ・角度・カバレッジ・明暗反転があります。プレビュー上部の「選択画像/デフォルト/白」ボタンで背景を切り替えられ、「パターンを作成」モードでは選択中の画像(またはコマ内の既存画像)を縦横比を保ったまま背景に敷いて、実際に重ねたときの網点の大きさを確認できます(背景はプレビュー確認専用で、生成結果には含まれません)。
' }, { heading: '3Dポーズサブタブ', body: '
  • 「3Dポーズ」サブタブで VRM / GLB / GLTF 形式の3Dモデルをコマ内に配置し、ポーズを付けて画像として焼き込めます。ComfyUIカスタムノード「comfyui-vrm-pose-editor」がインストールされていることが前提の機能で、未インストールの場合はステータス欄にエラーメッセージが表示されます。ノードの現在のバージョンの機能(ポーズライブラリ・ライトエディタ・ミラー等)がそのままこのタブから利用できます。
  • 基本の流れ: コマを選択 → 「コマに配置」→ 3Dビューが赤枠でオーバーレイ表示 → ポーズ調整 → 「コマに確定」
  • カメラ操作: 左ドラッグ=回転 / 右ドラッグ=パン / ホイール=ズーム。右上のギズモ(X/Y/Z球)をクリックすると軸方向にスナップします。
  • ボーン操作: 青い球(コントロールポイント)を左右ドラッグ=Y軸回転 / 上下ドラッグ=X軸回転 / Alt+上下ドラッグ=Z軸回転。
  • モデル読込: 「モデル読込」ボタンで PC 上の .vrm / .glb / .gltf ファイルを読み込めます。model.glb(または model.vrm)をプロジェクト直下に置くと起動時に自動ロードされます。
  • ポーズ保存・読込: 「ポーズ保存」で JSON ファイルをダウンロード、「ポーズ読込」で .json / .vroidpose ファイルを読み込めます。
  • 💾 poses/: 現在のポーズを comfyui-vrm-pose-editor ノードの poses/ フォルダへ保存します。ComfyUIワークフロー上の3D Pose Editorノードと共有のフォルダなので、どちらで保存したポーズも双方から呼び出せます。
  • 📚 ライブラリ: ポーズライブラリを開きます。poses/ フォルダ内のポーズをサムネイル一覧から選んでクリックで即適用でき、右クリックでミラー適用・お気に入り・メモ編集・ファイル名変更・サムネイル再生成ができます。
  • ↔ ミラー: 現在のポーズを左右反転します。
  • CC: カラー補正(sRGB + ACES Filmic)の ON/OFF。VRoid Studio 製モデルが暗く見える場合に ON にしてください。
  • RP / RC / OT: RP=ポーズリセット、RC=カメラリセット、OT=平行投影(Orthographic)に切替。平行投影中は「PR」と表示され、クリックで透視投影(Perspective)に戻ります。
  • 💡 ライト: ライトエディタを開きます。複数ライト(Sun/Point/Spot/Box/Ambient)の追加・編集、Ground(地面)・BG Wall(背景壁)の表示・色・テクスチャ・シャドウキャッチャー設定、ライト設定一式をプリセットとして保存・呼び出しできます。ズーム操作モード(ホイール/Ctrl+右ドラッグ)の切替もここで行い、設定はブラウザに保存されノード・このタブの双方で共有されます。
  • 👁 視線: ONにすると3Dビュー内にシアン色のマーカーが表示され、モデルの目・頭がマーカーの方向を自動的に追従します。マーカーをドラッグして視線の向きを調整できます(モデルにLookAt情報が無い場合は効果がありません)。マーカーは「コマに確定」時の画像には写り込みません。
  • 🎐 揺れ: 髪やスカートなど、モデルに揺れボーンが設定されている場合の物理シミュレーションのON/OFFです。OFFにすると現在の揺れ具合のまま固定されます。ポーズリセット・ポーズ読込・ミラー実行時は揺れボーンの内部状態を新しいポーズに合わせて再アンカーするため、切替直後に不自然に跳ねることはありません。
  • 🌬 風: 髪やスカートなど、モデルの揺れボーンをそよ風でなびかせる効果のON/OFFです。強さ・向き・そよぎ(ガストの度合い)はライトエディタ内のスライダーで調整できます。「揺れ」がOFFの間は効果がありません。
  • 🧭 発生源: ONにすると3Dビュー内にオレンジ色のコーン(円錐)マーカーが表示され、風がマーカーの方向からモデルへ向かって吹くように向きを指定できます(視線マーカーと同じ操作方法)。ONの間、ライトエディタ内の「向き」スライダーは無効化されます。マーカーは「コマに確定」時の画像には写り込みません。
  • 点サイズ: ボーン操作点のサイズを 0.2〜3.0 で調整できます(指など密集部の操作に)。
  • シェイプキー: モデルの表情・モーフターゲットをスライダーで操作できます(モデルロード後に表示)。
  • 確定後の画像にはポーズデータが保存されています(将来の再編集用)。
' }, - { heading: '3Dテキストサブタブ', body: '
  • 「3Dテキスト」サブタブで、日本語を含むテキストをThree.jsで立体的に押し出し表示し、画像として焼き込めます。3Dポーズと同じくComfyUIカスタムノード「comfyui-vrm-pose-editor」がインストールされていることが前提の機能で、未インストールの場合はステータス欄にエラーメッセージが表示されます。
  • 基本の流れ: コマを選択 → 「コマに配置」→ 3Dビューが青枠でオーバーレイ表示 → テキスト・フォント・見た目を調整 → 「コマに確定」。
  • テキスト・フォント: テキストエリアに入力すると即座に立体化されます。フォントは「Google」「システム」から選択でき、押し出し厚み・面取りのON/OFF・整列(左/中央/右)を調整できます。
  • 対応フォント形式: TrueType/OpenType単体フォントに加え、游ゴシック・游明朝・メイリオ・MSゴシック等のWindows標準CJKフォントに多いTrueType Collection(.ttc)形式にも対応しています(複数書体入りのファイルは先頭の書体を使用します)。
  • カメラ操作: 3Dポーズと同じく左ドラッグ=回転/右ドラッグ=パン/ホイール=ズームです。テキストや文字サイズを変更しても、一度手動でカメラを操作した後は視点が自動リセットされません(RCボタンで元の自動フィット位置に戻せます)。
  • ⚙ 設定: マテリアル(色・標準/トゥーン切替、標準時は金属・粗さ、トゥーン時は陰色・階調)、ライト(環境光/メインライト/フィルライトの色・強度・位置)、カメラ(RCボタン・ズーム操作モード・アンチエイリアス強化)をまとめたモーダルです。実際に描画中のビューをモーダル内にそのまま埋め込んで表示するため、設定を変えながらその場で見た目を確認できます。ズーム操作モード(ホイール/Ctrl+右ドラッグ)とアンチエイリアス強化のON/OFFは、3Dポーズのライトエディタと設定を共有します(どちらで切り替えても両方に反映されます)。
  • マテリアル種別: 標準(金属・粗さで質感を調整する一般的なPBRマテリアル)とトゥーン(MToon互換のアニメ調シェーディング。VRMキャラクターと同じシェーダーを流用)の2種類から選べます。
  • 確定後の画像には各種パラメータが保存されており、画像をダブルクリック(または選択して再編集ボタン)すると同じ内容で再編集できます。
' }, + { heading: '3Dテキストサブタブ', body: '
  • 「3Dテキスト」サブタブで、日本語を含むテキスト、またはSVGファイルのパスをThree.jsで立体的に押し出し表示し、画像として焼き込めます。3Dポーズと同じくComfyUIカスタムノード「comfyui-vrm-pose-editor」がインストールされていることが前提の機能で、未インストールの場合はステータス欄にエラーメッセージが表示されます。
  • 基本の流れ: コマを選択 → 「コマに配置」→ 3Dビューが青枠でオーバーレイ表示(テキスト内容の初期値は"text"のため、配置しただけですぐに立体表示を確認できます)→ 必要に応じて⚙ 3D設定で内容・見た目を調整 → 「コマに確定」。
  • テキスト/SVGモード切替: 「テキスト」「SVG」の2ボタンでモードを切り替えます。SVGモードでは「SVGを開く」でSVGファイルを選択すると、そのベクターパスが立体化されます(サイズスライダーで大きさを調整可能)。未パス化のテキスト要素(<text>)やビットマップ画像(<image>)を含むSVGはステータス欄に警告が表示されます(イラストソフトでパス化してから保存してください)。
  • 押し出し・面取り: ツールバーの「厚み」スライダーと「面取り」チェックボックスは常時表示で、テキスト・SVGどちらのモードでも即座に反映されます。面取りの厚み・サイズ・分割数の詳細は⚙ 3D設定内の「マテリアル」タブで調整します(形状の細い部分に対して面取りが大きすぎる場合は自動的に無効化され、警告メッセージが表示されます)。
  • 対応フォント形式: TrueType/OpenType単体フォントに加え、游ゴシック・游明朝・メイリオ・MSゴシック等のWindows標準CJKフォントに多いTrueType Collection(.ttc)形式にも対応しています(複数書体入りのファイルは先頭の書体を使用します)。
  • カメラ操作: 3Dポーズと同じく左ドラッグ=回転/右ドラッグ=パン/ホイール=ズームです。テキストや文字サイズを変更しても、一度手動でカメラを操作した後は視点が自動リセットされません(RCボタンで元の自動フィット位置に戻せます)。
  • ⚙ 3D設定(3D Text Editor): テキスト・フォント・整列・行間・マテリアル・ライト・カメラをまとめて調整するモーダルです。左側は「ライト」「マテリアル」「カメラ」のタブ切替パネル、中央は実際に描画中のビューをそのまま埋め込んだプレビュー、右側は常時表示の「テキスト」パネルという3カラム構成です。設定を変えながらその場で見た目を確認できます。
  • テキストパネル(右側、常時表示): テキスト内容(複数行・スクロール可)、フォント(「Google」「システム」の切替+ファミリー選択)、整列(左/中央/右)、行間スライダーをまとめて設定できます(SVGモードでは使用しません)。
  • マテリアルタブ: 色・標準/トゥーン切替(標準時は金属・粗さ、トゥーン時は陰色・階調)に加え、表面色・側面色を個別に指定できます(「表面/側面を分ける」チェックボックスをONにすると側面色が独立します。ExtrudeGeometryの表裏キャップ面と側面それぞれに別マテリアルを適用する仕組みで、テキスト・SVGどちらのモードにも共通で使えます)。面取りの厚み・サイズ・分割数もここで調整します。
  • カメラタブ: RCボタン・ズーム操作モード(ホイール/Ctrl+右ドラッグ)・アンチエイリアス強化のON/OFFを設定します。ズーム操作モードとアンチエイリアス強化は3Dポーズのライトエディタと設定を共有します(どちらで切り替えても両方に反映されます)。
  • マテリアル種別: 標準(金属・粗さで質感を調整する一般的なPBRマテリアル)とトゥーン(MToon互換のアニメ調シェーディング。VRMキャラクターと同じシェーダーを流用)の2種類から選べます。
  • 確定後の画像には各種パラメータが保存されており、画像をダブルクリック(または選択して再編集ボタン)すると同じ内容で再編集できます。
' }, { heading: 'マンガサブタブ', body: '
  • 「マンガ」サブタブで、選択中のコマ/オーバーレイのサイズにジャストフィットする透過の新規オブジェクトとして、ヴィネット(周辺減光、色指定可。四辺の中央まで均等に暗くなる楕円グラデーション)・スクリーントーンノイズ(紙質のざらつき、粒サイズ調整可)・集中線を生成できます。実際のスクリーントーンシートや集中線シートを描画の上に貼るような感覚で使えます。
  • 対象は選択中の画像があればそのコマ、画像を選択していなければ選択中のコマ/オーバーレイです。
  • 集中線の種類: 「放射状」(中心側が針状に尖った古典的な集中線)・「ウニフラ」(中心のコア円+外向きのトゲ。色を白にするとフラッシュ表現に)・「ウニ(輪)」(基準円の内外に伸びる細かい線束のリング)・「線形」(流線。角度指定可)から選べます。
  • 集中線のパラメータ: 密度(%)・範囲・長さ(中心の空白サイズ。大きいほど線が中心近くまで届く)・外枠側の太さ・色に加え、放射状・ウニ系では中心X/Y、ウニ系では外側の長さ(トゲ・線束が外側へ届く距離)、線形では角度を指定できます。放射状で「外枠側の太さ」を大きくすると線同士が重なって手前側が塗りつぶされ、ウニフラッシュ状のベタ表現としても使えます。
  • プレビュー上部の「選択画像/デフォルト/白」ボタンで背景を切り替えて、実際の画像に重ねたときの見え方を確認できます(背景はプレビュー確認専用で、生成される透過オブジェクトには含まれません。チェッカーボード模様は透過部分の目安表示です)。
  • 「コマに追加」ボタンで新規オブジェクトとしてコマに挿入します。挿入後は他の画像と同じくレイヤーパネルから移動・リサイズ・削除ができます。不透明度はモーダル内ではなく、挿入後にレイヤーパネル側で調整する方式です。
  • 🎴 背景パターン: 同じく選択中のコマ/オーバーレイのサイズにジャストフィットする透過の新規オブジェクトとして、ストライプ(横/縦/斜め)・ドット・チェック・和柄(麻の葉・市松・七宝・鱗)・カスタムSVGの背景パターンを生成できます。色(市松は前景・背景の2色)・不透明度・サイズ(タイルの繰り返し間隔)・回転角度を指定でき、カスタムSVGを選んだ場合はサイズの代わりに幅・高さを個別指定できるため、横長のロゴなど正方形でない画像もそのままの縦横比でタイル状に敷き詰められます(さらにタイル間の間隔も指定可)。「背景を透過」のチェックを外すと不透明な背景として生成することもできます。プレビュー上部の「選択画像/デフォルト/白」ボタンで背景を切り替えて確認できます。
' }, { heading: 'レイヤーパネル', body: '
  • オブジェクト行をクリックして選択すると、コマ選択もそのオブジェクトが属するコマ(またはオーバーレイ)に自動で切り替わります。
  • コマ行右端の□/−ボタンで、そのコマの枠線表示を個別に切り替えられます(□=表示、−=非表示。設定はページに保存されます)。
  • コマ行の🔓ボタンで、そのコマ内の全オブジェクト(フキダシ・画像・テキスト・図形・グループ)を一括ロックできます(🔒表示に変化)。ロック中はキャンバス上・レイヤーパネルどちらからも選択・移動・編集・削除ができず、テキストのダブルクリック再編集も無効になります。個別オブジェクトの🔒状態はそのまま保持されるため、コマのロックを解除すれば元の個別ロック状態に戻ります。
  • 各オブジェクトの👁(表示切替)、🔓(ロック)、✕(削除)ボタンで操作できます。
  • Delete / Backspaceキー: レイヤーパネルの✕ボタンを押さなくても、キャンバス上またはレイヤーパネルで選択中のオブジェクト(画像・フキダシ・テキスト・図形・グループ)をキー1つで削除できます(ロック中のオブジェクトや、テキスト入力中は無効)。
  • ↑↓ボタンで重ね順を変更できます。
  • チェックボックスで2つ以上選択し「G+」ボタンでグループ化できます。
  • グループ行([G])の 📦 ボタンでグループをアセットとして登録できます。
  • 「複製」「移動」ボタンで別コマ・オーバーレイ・下書きレイヤーへのオブジェクト移動ができます(下書きレイヤーへは画像のみ移動・複製可能です)。
  • OC(上部メニュー「画像タブで編集」の右隣): 選択中のオブジェクトを中央へ移動します(コマ内のオブジェクトはそのコマの中心、オーバーレイ配下のオブジェクトはページ全体の中心)。コマ外にドラッグして操作できなくなった場合、レイヤーパネルからオブジェクトを選択してこのボタンで復帰させられます。
' }, { heading: 'グループ機能', body: '
  • レイヤーパネルのチェックボックスで2つ以上のオブジェクトを選択し「G+」でグループ化します(同一コマ内のみ)。
  • グループ行をクリックするとオレンジ色の点線枠ハンドルが表示され、ドラッグで一括移動、ハンドルで一括リサイズ・回転ができます。
  • 「G-」でグループを解除し、各オブジェクトを独立編集可能な状態に戻します。
  • グループ行の 📦 ボタンで、グループをアセット(.svg)として登録できます。登録先グループ選択またはフォルダ新規作成→ファイル名入力→「登録」で、アセットパネルに即時反映されます。
  • 登録したグループアセットは「コマに挿入」で通常のグループとして配置され、G-解除・テキスト再編集なども可能です。
' }, @@ -397,7 +397,7 @@ const _HELP_I18N = { { heading: 'Text', body: '
  • Set the font, size, and writing direction in the "Text" sub-tab on the right side panel, then click inside a panel.
  • Vertical checkbox: Toggling this while a text is selected instantly switches that text between vertical and horizontal while keeping its center position (when a text is selected, the checkbox state also syncs to that text\'s actual state).
  • Double-click existing text to re-edit it.
  • You can change the font size using the bounding box\'s resize handles.
  • Color: Choose from presets (black/white/red/blue). Set a custom color via "Fill" in the style modal.
  • Font selection: Switch the source with "Google", "System", or "Category". Google shows the same Google Fonts list as the Font tab. When "Category" is selected, you can narrow it down with the dropdown next to it (register fonts to a category from the Font tab).
  • Style button: Opens a modal for setting fill, stroke, outline, shadow, bold, italic, underline, and text alignment (text alignment is an Image-tab-only setting and is not applied to Layout tab text). Uncheck Fill for no fill (only stroke, outline, and shadow are drawn), and use the mode selector to choose Solid / Gradient / Texture. Gradients are configured with linear/radial shape, angle, and a color ramp (drag stops to move them, add/remove stops); textures use a selected image file tiled at an adjustable scale % (the tile size is proportional to the font size). These fills look identical on both Layout tab and Image tab text. The preview uses the same SVG rendering as the actual application, so what you see is exactly what will appear on the canvas. If a text is selected, it is applied immediately; if not, a new text is inserted at the center of the selected panel. When the modal opens, the currently selected text\'s font size and style are used as the initial values, so you can review and fine-tune the existing settings. Saved styles are shared with the "Style" list in the Font tab. Stroke width, outline width, and shadow are saved as values relative to the font size, so the proportions are preserved even if you change the font size, and the appearance matches the preview in the Image tab and Font tab.
  • Insert (next to Text→PNG): Inserts the content of the currently selected cell (Scene / Element / Dialogue-Description, etc.) from the Script tab\'s plot as text at the center of the selected panel (applying the current font, writing direction, and color settings).
' }, { heading: 'Image Sub-tab', body: '
  • The "Image" sub-tab lets you perform Processing operations such as background removal, sharpening, and denoising, as well as editing via the G\'MIC GUI and flipping (↔↕).
  • All controls are consolidated into a single shared gray area.
' }, { heading: '3D Pose Sub-tab', body: '
  • The "3D Pose" sub-tab lets you place a 3D model in VRM / GLB / GLTF format inside a panel, pose it, and bake it into the image. This feature requires the ComfyUI custom node "comfyui-vrm-pose-editor" to be installed; if it is not, an error message is shown in the status area. The node\'s current features (pose library, light editor, mirror, etc.) are available directly from this tab.
  • Basic flow: Select a panel → "Place in Panel" → the 3D view appears as a red-outlined overlay → adjust the pose → "Commit to Panel"
  • Camera controls: Left-drag = rotate / Right-drag = pan / Wheel = zoom. Click the gizmo (X/Y/Z spheres) in the upper right to snap to that axis.
  • Bone controls: Drag a blue sphere (control point) left/right = Y-axis rotation / up/down = X-axis rotation / Alt+up/down = Z-axis rotation.
  • Load model: The "Load Model" button lets you load a .vrm / .glb / .gltf file from your PC. Placing model.glb (or model.vrm) directly under the project folder makes it load automatically on startup.
  • Save/load pose: "Save Pose" downloads a JSON file, and "Load Pose" loads a .json / .vroidpose file.
  • 💾 poses/: Saves the current pose to the comfyui-vrm-pose-editor node\'s poses/ folder. Since this folder is shared with the 3D Pose Editor node in the ComfyUI workflow, poses saved from either side can be loaded from both.
  • 📚 Library: Opens the pose library. You can select a pose from the thumbnail list in the poses/ folder and apply it instantly with a click, or right-click for mirror-apply, favorite, edit memo, rename file, and regenerate thumbnail.
  • ↔ Mirror: Mirrors the current pose horizontally.
  • CC: Toggles color correction (sRGB + ACES Filmic) on/off. Turn this on if VRoid Studio models look too dark.
  • RP / RC / OT: RP = reset pose, RC = reset camera, OT = switch to orthographic projection. While in orthographic mode the button shows "PR"; clicking it returns to perspective projection.
  • 💡 Light: Opens the light editor. You can add/edit multiple lights (Sun/Point/Spot/Box/Ambient), configure the display, color, texture, and shadow catcher for the Ground and BG Wall, and save/load a full lighting setup as a preset. The zoom control mode (wheel / Ctrl+right-drag) is also switched here; settings are stored in the browser and shared between the node and this tab.
  • 👁 LookAt: When ON, a cyan marker appears in the 3D view and the model\'s eyes/head automatically follow the marker\'s direction. Drag the marker to adjust where the character looks (has no effect if the model has no LookAt data).
  • 🎐 Sway: Toggles the physics simulation for the model\'s sway bones (hair, skirts, etc.), if any are set up. When OFF, the current sway state is frozen.
  • 🌬 Wind: Toggles a breeze effect that gently sways the model\'s sway bones (hair, skirts, etc.). Strength, direction, and gustiness can be adjusted with the sliders in the light editor. Has no effect while Sway is OFF.
  • 🧭 Wind Source: When ON, an orange cone marker appears in the 3D view, letting you set the wind direction as blowing from the marker toward the model (same operation as the LookAt marker). While ON, the "direction" slider in the light editor is disabled. The marker does not appear in the committed image.
  • Point size: Adjusts the size of bone control points from 0.2 to 3.0 (useful for operating on densely packed areas like fingers).
  • Shape keys: Lets you control the model\'s facial expressions and morph targets with sliders (shown after a model is loaded).
  • Pose data is stored in the committed image (for future re-editing).
' }, - { heading: '3D Text Sub-tab', body: '
  • The "3D Text" sub-tab extrudes text (including Japanese) into 3D using Three.js and bakes it into an image. Like 3D Pose, this feature requires the ComfyUI custom node "comfyui-vrm-pose-editor" to be installed; if it is not, an error message is shown in the status area.
  • Basic flow: Select a panel → "Place in Panel" → the 3D view appears as a blue-outlined overlay → adjust the text, font, and appearance → "Commit to Panel".
  • Text and font: Typing in the text area extrudes it instantly. Choose the font source from "Google" or "System", and adjust the extrusion depth, bevel on/off, and alignment (left/center/right).
  • Supported font formats: In addition to standalone TrueType/OpenType fonts, TrueType Collection (.ttc) files — common among Windows CJK system fonts such as Yu Gothic, Yu Mincho, Meiryo, and MS Gothic — are also supported (if a file contains multiple faces, the first one is used).
  • Camera controls: Same as 3D Pose — left-drag = rotate / right-drag = pan / wheel = zoom. Once you manually adjust the camera, the view no longer auto-resets when you edit the text or font size (use the RC button to return to the original auto-fit position).
  • ⚙ Settings: Opens a modal that gathers Material (color, Standard/Toon switch, metalness/roughness for Standard or shade color/toony for Toon), Lights (color, intensity, and position for the Ambient/Key/Fill lights), and Camera (RC button, zoom control mode, enhanced antialiasing). The view being rendered live is embedded directly inside the modal, so you can see the effect of each change immediately. Zoom control mode (wheel / Ctrl+right-drag) and enhanced antialiasing are shared with the 3D Pose light editor (switching either one updates both).
  • Material types: Choose between Standard (a general PBR material adjusted via metalness/roughness) and Toon (MToon-compatible anime-style shading, reusing the same shader as VRM characters).
  • The committed image stores all parameters, so double-clicking it (or selecting it and clicking Re-edit) lets you re-edit it with the same settings.
' }, + { heading: '3D Text Sub-tab', body: '
  • The "3D Text" sub-tab extrudes text (including Japanese), or the vector paths of an SVG file, into 3D using Three.js and bakes it into an image. Like 3D Pose, this feature requires the ComfyUI custom node "comfyui-vrm-pose-editor" to be installed; if it is not, an error message is shown in the status area.
  • Basic flow: Select a panel → "Place in Panel" → the 3D view appears as a blue-outlined overlay (the default text content is "text", so you can see something extruded right after placing it) → adjust the content and appearance in ⚙ 3D Settings if needed → "Commit to Panel".
  • Text / SVG mode switch: Two buttons, "Text" and "SVG", switch between modes. In SVG mode, use "Open SVG" to choose a file and its vector paths are extruded (adjust the size with the slider). SVGs containing un-converted text elements (<text>) or raster images (<image>) show a warning in the status area (convert text to paths in your vector editor before saving).
  • Extrusion and bevel: The toolbar\'s "Depth" slider and "Bevel" checkbox are always visible and apply instantly in both Text and SVG mode. Fine-tune the bevel\'s thickness, size, and segment count in the "Material" tab inside ⚙ 3D Settings (if the bevel is too large for a thin part of the shape, it is automatically disabled and a warning message is shown).
  • Supported font formats: In addition to standalone TrueType/OpenType fonts, TrueType Collection (.ttc) files — common among Windows CJK system fonts such as Yu Gothic, Yu Mincho, Meiryo, and MS Gothic — are also supported (if a file contains multiple faces, the first one is used).
  • Camera controls: Same as 3D Pose — left-drag = rotate / right-drag = pan / wheel = zoom. Once you manually adjust the camera, the view no longer auto-resets when you edit the text or font size (use the RC button to return to the original auto-fit position).
  • ⚙ 3D Settings (3D Text Editor): A modal that gathers text, font, alignment, line height, material, lights, and camera settings in one place. It has a 3-column layout: a tab switcher for "Light" / "Material" / "Camera" on the left, a live preview in the center (the view being rendered is embedded directly inside the modal), and an always-visible "Text" panel on the right. You can see the effect of each change immediately.
  • Text panel (right, always visible): Set the text content (multi-line, scrollable), font (switch between "Google"/"System" plus a family selector), alignment (left/center/right), and a line-height slider all in one place (not used in SVG mode).
  • Material tab: In addition to color and the Standard/Toon switch (metalness/roughness for Standard, shade color/toony for Toon), you can specify the front-face and side-face colors independently (turn on "Separate front/side colors" to make the side color independent). This works by applying a separate material to the front/back cap faces versus the extruded side faces of the geometry, and is available in both Text and SVG mode. Bevel thickness, size, and segment count are also adjusted here.
  • Camera tab: Set the RC button, zoom control mode (wheel / Ctrl+right-drag), and enhanced antialiasing on/off. Zoom control mode and enhanced antialiasing are shared with the 3D Pose light editor (switching either one updates both).
  • Material types: Choose between Standard (a general PBR material adjusted via metalness/roughness) and Toon (MToon-compatible anime-style shading, reusing the same shader as VRM characters).
  • The committed image stores all parameters, so double-clicking it (or selecting it and clicking Re-edit) lets you re-edit it with the same settings.
' }, { heading: 'Manga Sub-tab', body: '
  • The "Manga" sub-tab lets you generate a new transparent object sized exactly to the selected panel/overlay: a vignette (peripheral darkening with a selectable color — an elliptical gradient that darkens evenly toward the center of all four edges), screentone noise (paper-like grain with an adjustable grain size), or speed lines. It feels like laying an actual screentone sheet or speed-line sheet on top of your artwork.
  • The target is the panel containing the selected image if one is selected, otherwise the selected panel/overlay.
  • Speed line types: Choose from "Radial" (classic speed lines that taper to a needle point toward the center), "Uni Flash" (a core circle with outward spikes; set the color to white for a flash effect), "Uni (Ring)" (a ring of fine line bundles extending inward and outward from a base circle), or "Linear" (streak lines with an adjustable angle).
  • Speed line parameters: Density (%), range, length (the size of the blank area at the center — the larger it is, the closer the lines reach the center), outer-edge thickness, and color, plus center X/Y for the Radial and Uni types, an outer length (how far the spikes/bundles extend outward) for the Uni types, and an angle for Linear. Increasing the outer-edge thickness for Radial makes the lines overlap and fill in near the front, which can also be used as a solid, uni-flash-like effect.
  • Use the "Selected Image / Default / White" buttons above the preview to switch the background and check how it will look layered over the actual artwork (the background is preview-only and is not included in the generated transparent object; the checkerboard pattern indicates transparent areas).
  • Click "Add to Panel" to insert the result as a new object into the panel. After insertion it can be moved, resized, and deleted from the layer panel just like any other image. Opacity is not set in the modal — adjust it afterward from the layer panel.
  • 🎴 Background Pattern: Also generates a new transparent object sized exactly to the selected panel/overlay: stripes (horizontal/vertical/diagonal), dots, checks, Japanese traditional patterns (asanoha/ichimatsu/shippou/uroko), or a custom-SVG background pattern. You can set the color (two colors — foreground/background — for ichimatsu), opacity, size (tile repeat spacing), and rotation angle; when a custom SVG is selected, you specify width and height individually instead of size, so non-square images such as a wide logo can be tiled while keeping their original aspect ratio (tile spacing can also be set). Unchecking "Transparent background" generates an opaque background instead. Use the "Selected Image / Default / White" buttons above the preview to check it.
' }, { heading: 'Layer Panel', body: '
  • Clicking an object row to select it also automatically switches the panel selection to the panel (or overlay) that object belongs to.
  • The □/− button at the right end of a panel row individually toggles that panel\'s border display (□ = shown, − = hidden; the setting is saved with the page).
  • The 🔓 button on a panel row lets you lock all objects within that panel (balloons, images, text, shapes, groups) at once (it changes to a 🔒 display). While locked, objects cannot be selected, moved, edited, or deleted from either the canvas or the layer panel, and double-click re-editing of text is also disabled. Individual objects\' own 🔒 state is preserved, so unlocking the panel restores their original individual lock states.
  • Each object can be operated with its 👁 (toggle visibility), 🔓 (lock), and ✕ (delete) buttons.
  • Delete / Backspace key: You can delete the currently selected object (image, balloon, text, shape, or group) — on the canvas or in the layer panel — with a single keypress, without having to click the ✕ button in the layer panel (disabled for locked objects or while typing in a text field).
  • Use the ↑↓ buttons to change the stacking order.
  • Select two or more objects with the checkboxes and click "G+" to group them.
  • The 📦 button on a group row ([G]) registers the group as an asset.
  • The "Duplicate" and "Move" buttons let you duplicate or move objects to a different panel, overlay, or draft layer (only images can be moved/duplicated to the draft layer).
  • OC (next to "Edit in Image Tab" in the top menu): Moves the selected object to the center (the center of its panel for objects inside a panel, or the center of the whole page for objects under the overlay). If an object has been dragged outside its panel and become unreachable, select it from the layer panel and use this button to bring it back.
' }, { heading: 'Group Feature', body: '
  • Select two or more objects with the checkboxes in the layer panel and click "G+" to group them (only within the same panel).
  • Clicking a group row shows an orange dashed-outline handle; drag to move the whole group, or use the handles to resize/rotate it as a whole.
  • "G-" ungroups, returning each object to an independently editable state.
  • The 📦 button on a group row lets you register the group as an asset (.svg). Choose a target group or create a new folder → enter a file name → "Register", and it is reflected in the asset panel immediately.
  • A registered group asset is placed as a normal group via "Insert into Panel", and can also be ungrouped (G-) or have its text re-edited.
' }, @@ -648,7 +648,7 @@ const _HELP_I18N = { { heading: '文字', body: '
  • 在右侧面板的"文字"子标签中设置字体、大小和排版方向,然后点击分格。
  • 竖排复选框:在选中文字时切换此项,该文字会立即在保持中心位置不变的情况下在竖排⇔横排之间切换(选中文字时,复选框状态也会与该文字的实际状态同步)。
  • 双击已有文字可重新编辑。
  • 可通过边界框的调整大小手柄更改字体大小。
  • 颜色:从预设(黑/白/红/蓝)中选择。任意颜色可在样式弹窗的"填充"中设置。
  • 字体选择:通过"Google"、"系统"、"分类"切换来源。Google显示与字体管理标签相同的Google Fonts列表。选择"分类"时可通过旁边的下拉菜单进行筛选(字体的分类登记在字体管理标签中进行)。
  • 样式按钮:打开设置填充、描边、勾边字、阴影、粗体、斜体、下划线、文字对齐的弹窗(文字对齐是Image标签专用设置,不会应用于排版标签中的文字)。取消勾选填充可实现无填充(仅绘制描边・勾边字・阴影),并可通过模式切换选择纯色/渐变/纹理。渐变可设置线性/径向、角度和颜色渐变条(拖动色标移动、添加/删除);纹理则选择图像文件并以缩放%调整平铺尺寸(纹理平铺尺寸与字体大小成比例)。这些填充在排版标签和Image标签的文字上外观一致。预览采用与实际应用相同的SVG渲染方式,因此效果会直接反映在画布上。若已选中文字则立即应用于该文字,若未选中则在选中分格中心新建插入。打开弹窗时,会以当前选中文字的字体大小和样式作为初始值,方便确认和微调现有设置。已保存的样式与字体管理标签的"样式"列表共用。描边宽度、勾边字宽度、阴影均以相对于字体大小的比例保存,因此更改字体大小后比例保持不变,效果与Image标签、字体管理标签的预览一致。
  • Insert(文字→PNG右侧):将脚本标签中情节内选中单元格(场景/要素/台词说明等)的内容,作为文字插入到选中分格的中心(应用当前的字体、排版方向和颜色设置)。
' }, { heading: '图像子标签', body: '
  • 在"图像"子标签中可进行背景移除、锐化、降噪等Processing处理,以及通过G\'MIC GUI进行编辑和翻转(↔↕)。
  • 所有控件都集中在灰色的公共区域中。
' }, { heading: '3D姿势子标签', body: '
  • 在"3D姿势"子标签中,可以将VRM / GLB / GLTF格式的3D模型放置到分格内,摆好姿势后将其烘焙为图像。此功能需要预先安装ComfyUI自定义节点"comfyui-vrm-pose-editor",若未安装则状态栏会显示错误信息。该节点当前版本的功能(姿势库・灯光编辑器・镜像等)可直接在此标签中使用。
  • 基本流程:选择分格 → "放置到分格" → 3D视图以红色边框叠加显示 → 调整姿势 → "确定到分格"
  • 相机操作:左键拖动=旋转 / 右键拖动=平移 / 滚轮=缩放。点击右上角的辅助轴(X/Y/Z球)可吸附到对应轴方向。
  • 骨骼操作:拖动蓝色球体(控制点),左右拖动=Y轴旋转 / 上下拖动=X轴旋转 / Alt+上下拖动=Z轴旋转。
  • 加载模型:通过"加载模型"按钮可读取电脑上的 .vrm / .glb / .gltf 文件。将model.glb(或model.vrm)放在项目根目录下,启动时会自动加载。
  • 保存/加载姿势:"保存姿势"可下载JSON文件,"加载姿势"可读取 .json / .vroidpose 文件。
  • 💾 poses/:将当前姿势保存到comfyui-vrm-pose-editor节点的poses/文件夹。由于该文件夹与ComfyUI工作流中3D Pose Editor节点共用,因此无论在哪一侧保存的姿势,双方都可以调用。
  • 📚 姿势库:打开姿势库。可从poses/文件夹内的姿势缩略图列表中点击立即应用,右键可进行镜像应用、收藏、编辑备注、重命名文件、重新生成缩略图。
  • ↔ 镜像:将当前姿势左右翻转。
  • CC:色彩校正(sRGB + ACES Filmic)的开关。当VRoid Studio制作的模型显得过暗时请开启。
  • RP / RC / OT:RP=重置姿势,RC=重置相机,OT=切换为平行投影(Orthographic)。平行投影时会显示"PR",点击可返回透视投影(Perspective)。
  • 💡 灯光:打开灯光编辑器。可添加/编辑多个灯光(Sun/Point/Spot/Box/Ambient),设置Ground(地面)・BG Wall(背景墙)的显示、颜色、纹理、阴影接收设置,并可将整套灯光设置保存/调用为预设。缩放操作模式(滚轮/Ctrl+右键拖动)的切换也在此处进行,设置保存在浏览器中,节点与本标签共用。
  • 👁 视线:开启后3D视图中会显示青色标记,模型的眼睛/头部会自动朝向该标记的方向。拖动标记可调整视线方向(若模型没有视线数据则无效)。
  • 🎐 摇动:切换模型摇动骨骼(头发、裙子等,如有设置)的物理模拟开关。关闭后将固定在当前的摇动状态。
  • 🌬 风:切换让模型的摇动骨骼(头发、裙子等)随微风轻轻摆动的效果。强度・方向・阵风感可在灯光编辑器内的滑块中调整。摇动关闭时无效。
  • 🧭 风向标记:开启后3D视图中会显示橙色圆锥标记,可将风向设置为从标记指向模型的方向(操作方式与视线标记相同)。开启期间,灯光编辑器内的"方向"滑块将被禁用。该标记不会出现在确定后的图像中。
  • 点大小:可将骨骼控制点的大小调整为0.2~3.0(便于操作手指等密集部位)。
  • 形态键:可通过滑块操作模型的表情和变形目标(模型加载后显示)。
  • 确定后的图像中保存有姿势数据(供日后重新编辑使用)。
' }, - { heading: '3D文字子标签', body: '
  • 在"3D文字"子标签中,可以使用Three.js将包含日文在内的文字立体挤出显示,并烘焙为图像。与3D姿势相同,此功能需要预先安装ComfyUI自定义节点"comfyui-vrm-pose-editor",若未安装则状态栏会显示错误信息。
  • 基本流程:选择分格 → "放置到分格" → 3D视图以蓝色边框叠加显示 → 调整文字・字体・外观 → "确定到分格"。
  • 文字与字体:在文本框中输入即会立即生成立体效果。字体可从"Google"、"系统"中选择,并可调整挤出厚度、倒角开关、对齐方式(左/中央/右)。
  • 支持的字体格式:除单体TrueType/OpenType字体外,还支持游黑体・游明朝・Meiryo・MS黑体等Windows标准CJK字体中常见的TrueType Collection(.ttc)格式(若文件内含多个字体,则使用第一个)。
  • 相机操作:与3D姿势相同,左键拖动=旋转 / 右键拖动=平移 / 滚轮=缩放。手动操作相机后,即使更改文字或字号,视角也不会再自动重置(可用RC按钮恢复到最初的自动适配位置)。
  • ⚙ 设置:汇集了材质(颜色、标准/卡通切换,标准模式下为金属度・粗糙度,卡通模式下为阴影色・色阶)、灯光(环境光/主光源/补光的颜色・强度・位置)、相机(RC按钮・缩放操作模式・增强抗锯齿)的弹窗。实际渲染中的画面会直接嵌入弹窗内显示,因此可以一边调整设置一边即时确认效果。缩放操作模式(滚轮/Ctrl+右键拖动)与增强抗锯齿的开关与3D姿势的灯光编辑器共用设置(无论在哪一侧切换都会同时反映到双方)。
  • 材质类型:可从标准(通过金属度・粗糙度调整质感的通用PBR材质)与卡通(兼容MToon的动漫风格着色,与VRM角色使用相同的着色器)两种中选择。
  • 确定后的图像中保存有各项参数,双击该图像(或选中后点击重新编辑按钮)即可以相同设置重新编辑。
' }, + { heading: '3D文字子标签', body: '
  • 在"3D文字"子标签中,可以使用Three.js将包含日文在内的文字、或SVG文件的矢量路径立体挤出显示,并烘焙为图像。与3D姿势相同,此功能需要预先安装ComfyUI自定义节点"comfyui-vrm-pose-editor",若未安装则状态栏会显示错误信息。
  • 基本流程:选择分格 → "放置到分格" → 3D视图以蓝色边框叠加显示(文字内容的初始值为"text",因此放置后即可立即看到立体效果)→ 如有需要在⚙ 3D设置中调整内容与外观 → "确定到分格"。
  • 文字/SVG模式切换:通过"文字""SVG"两个按钮切换模式。SVG模式下点击"打开SVG"选择文件后,其矢量路径会被立体化(可用大小滑块调整尺寸)。若SVG中包含未转换为路径的文字元素(<text>)或位图图像(<image>),状态栏会显示警告(请在矢量图软件中将文字转换为路径后再保存)。
  • 挤出与倒角:工具栏的"厚度"滑块与"倒角"复选框始终显示,在文字、SVG两种模式下都会立即生效。倒角的厚度・大小・分段数等详细设置在⚙ 3D设置内的"材质"标签中调整(若倒角相对于形状的细窄部分过大,会自动禁用并显示警告信息)。
  • 支持的字体格式:除单体TrueType/OpenType字体外,还支持游黑体・游明朝・Meiryo・MS黑体等Windows标准CJK字体中常见的TrueType Collection(.ttc)格式(若文件内含多个字体,则使用第一个)。
  • 相机操作:与3D姿势相同,左键拖动=旋转 / 右键拖动=平移 / 滚轮=缩放。手动操作相机后,即使更改文字或字号,视角也不会再自动重置(可用RC按钮恢复到最初的自动适配位置)。
  • ⚙ 3D设置(3D Text Editor):汇集文字・字体・对齐・行距・材质・灯光・相机设置的弹窗,采用三栏布局:左侧为"灯光""材质""相机"标签切换面板,中央为实际渲染画面直接嵌入的预览,右侧为常驻显示的"文字"面板。可以一边调整设置一边即时确认效果。
  • 文字面板(右侧,常驻显示):可在此统一设置文字内容(多行・可滚动)、字体("Google""系统"切换+字体族选择)、对齐(左/中央/右)、行距滑块(SVG模式下不使用)。
  • 材质标签:除颜色、标准/卡通切换(标准模式下为金属度・粗糙度,卡通模式下为阴影色・色阶)外,还可以分别指定表面色与侧面色(勾选"表面/侧面分开设置"后侧面色即可独立设置。其原理是对几何体的正反面(盖面)与挤出侧面分别应用不同材质,文字、SVG两种模式下均可使用)。倒角的厚度・大小・分段数也在此调整。
  • 相机标签:设置RC按钮、缩放操作模式(滚轮/Ctrl+右键拖动)、增强抗锯齿的开关。缩放操作模式与增强抗锯齿与3D姿势的灯光编辑器共用设置(无论在哪一侧切换都会同时反映到双方)。
  • 材质类型:可从标准(通过金属度・粗糙度调整质感的通用PBR材质)与卡通(兼容MToon的动漫风格着色,与VRM角色使用相同的着色器)两种中选择。
  • 确定后的图像中保存有各项参数,双击该图像(或选中后点击重新编辑按钮)即可以相同设置重新编辑。
' }, { heading: '漫画子标签', body: '
  • 在"漫画"子标签中,可以生成一个按所选分格/覆盖层的尺寸精确匹配的透明新对象:晕影(周边减光,可指定颜色,是一种向四边中央均匀变暗的椭圆渐变)、网点噪点(纸张颗粒质感,可调整颗粒大小)或集中线。使用感就像在画稿上贴一张真正的网点纸或集中线纸。
  • 对象为:若已选中图像,则为该图像所在的分格;若未选中图像,则为选中的分格/覆盖层。
  • 集中线的种类:可从"放射状"(经典的、朝中心呈针状尖细的集中线)、"海胆闪"(中心核心圆+向外的尖刺,颜色设为白色可呈现闪光效果)、"海胆(环)"(从基准圆向内外延伸的细线束构成的环)、"线性"(可指定角度的流线)中选择。
  • 集中线参数:密度(%)、范围、长度(中心空白区域的大小,越大线条越靠近中心)、外缘粗细、颜色,此外放射状・海胆系还可指定中心X/Y,海胆系可指定外侧长度(尖刺/线束向外延伸的距离),线性可指定角度。放射状模式下增大"外缘粗细"会使线条相互重叠、靠近前端被填实,也可用作类似海胆闪光的实心效果。
  • 使用预览上方的"选中图像/默认/白色"按钮切换背景,可确认叠加在实际画稿上时的效果(背景仅用于预览确认,不会包含在生成的透明对象中;棋盘格图案表示透明部分)。
  • 点击"添加到分格"按钮,将结果作为新对象插入到分格中。插入后可像其他图像一样从图层面板中移动、调整大小、删除。不透明度不在弹窗内设置,而是插入后在图层面板中调整。
  • 🎴 背景图案:同样会生成一个按所选分格/覆盖层尺寸精确匹配的透明新对象:条纹(横/竖/斜)、圆点、格纹、日式传统纹样(麻叶纹/市松纹/七宝纹/鳞纹)或自定义SVG背景图案。可指定颜色(市松纹为前景・背景两色)、不透明度、尺寸(平铺重复间隔)、旋转角度;选择自定义SVG时,可分别指定宽度和高度来代替尺寸,因此横长的Logo等非正方形图像也能保持原有纵横比进行平铺(还可指定平铺间距)。取消勾选"背景透明"可生成不透明的背景。使用预览上方的"选中图像/默认/白色"按钮切换背景进行确认。
' }, { heading: '图层面板', body: '
  • 点击对象行进行选择时,分格选择也会自动切换为该对象所属的分格(或覆盖层)。
  • 分格行右端的□/−按钮可单独切换该分格的边框显示(□=显示,−=隐藏,设置会保存在页面中)。
  • 分格行的🔓按钮可将该分格内的所有对象(对话气泡、图像、文字、图形、分组)一并锁定(变为🔒显示)。锁定期间无论在画布上还是图层面板中都无法选择、移动、编辑、删除,文字的双击重新编辑也会失效。各对象自身的🔒状态会被保留,因此解除分格锁定后会恢复各对象原本的锁定状态。
  • 可通过各对象的👁(切换显示)、🔓(锁定)、✕(删除)按钮进行操作。
  • Delete / Backspace键:无需点击图层面板的✕按钮,也可以在画布上或图层面板中,通过单个按键删除当前选中的对象(图像、对话气泡、文字、图形、分组)(锁定中的对象及文字输入过程中该功能无效)。
  • 可用↑↓按钮更改层叠顺序。
  • 通过复选框选中2个以上对象后,用"G+"按钮可将其成组。
  • 分组行([G])的📦按钮可将分组注册为素材。
  • "复制"、"移动"按钮可将对象复制或移动到其他分格、覆盖层或草稿图层(草稿图层仅支持图像的移动/复制)。
  • OC(顶部菜单"在Image标签中编辑"右侧):将选中的对象移动到中心(分格内的对象移到该分格中心,覆盖层下的对象移到整个页面中心)。若对象被拖出分格外导致无法操作,可从图层面板中选择该对象后用此按钮使其恢复。
' }, { heading: '分组功能', body: '
  • 通过图层面板的复选框选中2个以上的对象,点击"G+"将其成组(仅限同一分格内)。
  • 点击分组行会显示橙色虚线边框手柄,拖动可整体移动,通过手柄可整体调整大小和旋转。
  • "G-"可解除分组,使各对象恢复为可独立编辑的状态。
  • 通过分组行的📦按钮,可将分组注册为素材(.svg)。选择目标分组或新建文件夹→输入文件名→"注册",即可立即反映到素材面板。
  • 已注册的分组素材通过"插入到分格"会作为普通分组放置,也可以进行G-解组、重新编辑文字等操作。
' }, diff --git a/static/js/main/25-text3d-bridge.js b/static/js/main/25-text3d-bridge.js index 9156ff8..628834c 100644 --- a/static/js/main/25-text3d-bridge.js +++ b/static/js/main/25-text3d-bridge.js @@ -14,6 +14,19 @@ import { openText3DSettingsModal } from '../text3d-settings-modal.js'; let _systemFontFamiliesCache = null; // window.queryLocalFonts() の結果(family名の重複除去済み配列)をキャッシュ +// editor.getSvgWarnings() が返す警告キー → i18nキーの対応表(text3d-core.js側は翻訳を持たないため呼び出し側で変換する) +const SVG_WARNING_I18N_KEYS = { + svgTextNode: 'layout.text3dSvgTextNodeWarning', + svgImageNode: 'layout.text3dSvgImageNodeWarning', + svgNoPath: 'layout.text3dSvgNoPathWarning', + svgParseFailed: 'layout.text3dSvgParseFailed', + svgBevelTooLarge: 'layout.text3dSvgBevelTooLargeWarning', +}; + +function _text3dFormatSvgWarnings(keys) { + return (keys || []).map(k => t(SVG_WARNING_I18N_KEYS[k] ?? k)).join(' / '); +} + // state.text3d.materialParams をエディタへ適用すべきタイミングかどうか。 // マテリアル設定は⚙モーダルからeditorへ直接適用され、editor内部に生きた状態として保持されるため、 // 毎回のshowText3DCanvas呼び出しで無条件に上書きすると、モーダルでの調整がリセットされてしまう。 @@ -30,15 +43,14 @@ function initText3DTab() { const reeditBtn = document.getElementById('text3d-reedit-btn'); const statusEl = document.getElementById('text3d-status'); - const textInput = document.getElementById('text3d-text-input'); - const fsrcGoogleBtn = document.getElementById('text3d-fsrc-google'); - const fsrcSystemBtn = document.getElementById('text3d-fsrc-system'); - const fontSelect = document.getElementById('text3d-font-family'); const depthInput = document.getElementById('text3d-depth'); const bevelInput = document.getElementById('text3d-bevel'); - const alignLeftBtn = document.getElementById('text3d-align-left'); - const alignCenterBtn = document.getElementById('text3d-align-center'); - const alignRightBtn = document.getElementById('text3d-align-right'); + const modeTextBtn = document.getElementById('text3d-mode-text'); + const modeSvgBtn = document.getElementById('text3d-mode-svg'); + const svgControls = document.getElementById('text3d-svg-controls'); + const svgFileInput = document.getElementById('text3d-svg-file'); + const svgFileNameEl = document.getElementById('text3d-svg-filename'); + const svgSizeInput = document.getElementById('text3d-svg-size'); if (!placeBtn) return; // HTMLが存在しない場合はスキップ @@ -86,37 +98,36 @@ function initText3DTab() { }); if (settingsBtn) settingsBtn.addEventListener('click', () => { - if (state.text3d.editor) openText3DSettingsModal(state.text3d.editor, state.text3d.wrapper); + if (!state.text3d.editor) return; + // モーダルがcvsWrapperを一時的に借りている間、コマ位置追従用ResizeObserverが + // 元のコマ位置へ強制的に書き戻してしまいモーダル内プレビューが画面外へ飛ぶ不具合への対応 + // (実機検証で確認)。モーダルを開く前に止め、閉じたら再セットアップする。 + state.text3d.resizeObserver?.disconnect(); + openText3DSettingsModal(state.text3d.editor, state.text3d.wrapper, { + onClose: () => { + _text3dStartResizeObserver(); + // テキスト・整列・行間はモーダル内でeditorへ直接適用されるため、閉じた時点で + // editor側の最新値をstateへ読み戻す(次回showText3DCanvas→_text3dApplyInitialState + // が古いstate値で上書きしてしまわないようにするため)。 + const p = state.text3d.editor.getParams(); + state.text3d.text = p.text; + state.text3d.align = p.align; + state.text3d.lineHeight = p.lineHeight; + }, + fontControls: { + getFontSource: () => state.text3d.fontSource, + setFontSource: (source) => { state.text3d.fontSource = source; }, + getFontFamily: () => state.text3d.fontFamily, + setFontFamily: (family) => { state.text3d.fontFamily = family; _text3dReloadFont(); }, + getFontFamilyOptions: (source) => _text3dGetFontFamilyOptions(source), + }, + }); }); if (reeditBtn) reeditBtn.addEventListener('click', () => { if (state.selectedImageEl) startReeditText3D(state.selectedImageEl); }); - // テキスト入力(デバウンスして再構築負荷を抑える) - if (textInput) { - let debounceTimer = null; - textInput.addEventListener('input', () => { - clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - state.text3d.editor?.setText(textInput.value); - }, 200); - }); - } - - // フォントソース切替 - const _setFontSource = (source) => { - state.text3d.fontSource = source; - if (fsrcGoogleBtn) fsrcGoogleBtn.classList.toggle('active', source === 'google'); - if (fsrcSystemBtn) fsrcSystemBtn.classList.toggle('active', source === 'system'); - _text3dPopulateFontSelect(source, fontSelect); - _text3dReloadFont(fontSelect, statusEl); - }; - if (fsrcGoogleBtn) fsrcGoogleBtn.addEventListener('click', () => _setFontSource('google')); - if (fsrcSystemBtn) fsrcSystemBtn.addEventListener('click', () => _setFontSource('system')); - - if (fontSelect) fontSelect.addEventListener('change', () => _text3dReloadFont(fontSelect, statusEl)); - if (depthInput) depthInput.addEventListener('input', () => { state.text3d.editor?.setDepth(parseFloat(depthInput.value)); }); @@ -124,17 +135,63 @@ function initText3DTab() { state.text3d.editor?.setBevel(bevelInput.checked); }); - const _setAlign = (align) => { - state.text3d.editor?.setAlign(align); - [alignLeftBtn, alignCenterBtn, alignRightBtn].forEach(b => b?.classList.remove('active')); - ({ left: alignLeftBtn, center: alignCenterBtn, right: alignRightBtn }[align])?.classList.add('active'); + // モード切替(テキスト/SVG立体化) + const _setRenderMode = (mode) => { + state.text3d.renderMode = mode; + modeTextBtn?.classList.toggle('active', mode === 'text'); + modeSvgBtn?.classList.toggle('active', mode === 'svg'); + if (svgControls) svgControls.style.display = mode === 'svg' ? 'flex' : 'none'; + state.text3d.editor?.setRenderMode(mode); + if (statusEl) statusEl.textContent = _text3dFormatSvgWarnings(state.text3d.editor?.getSvgWarnings()); }; - if (alignLeftBtn) alignLeftBtn.addEventListener('click', () => _setAlign('left')); - if (alignCenterBtn) alignCenterBtn.addEventListener('click', () => _setAlign('center')); - if (alignRightBtn) alignRightBtn.addEventListener('click', () => _setAlign('right')); + if (modeTextBtn) modeTextBtn.addEventListener('click', () => _setRenderMode('text')); + if (modeSvgBtn) modeSvgBtn.addEventListener('click', () => _setRenderMode('svg')); + + // SVGファイル選択 + if (svgFileInput) svgFileInput.addEventListener('change', () => { + const file = svgFileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + state.text3d.svgData = String(reader.result || ''); + state.text3d.svgFileName = file.name; + if (svgFileNameEl) svgFileNameEl.textContent = file.name; + state.text3d.editor?.setSvgData(state.text3d.svgData); + if (statusEl) statusEl.textContent = _text3dFormatSvgWarnings(state.text3d.editor?.getSvgWarnings()); + }; + reader.onerror = () => { + if (statusEl) statusEl.textContent = t('layout.text3dSvgParseFailed'); + }; + reader.readAsText(file); + }); + + if (svgSizeInput) svgSizeInput.addEventListener('input', () => { + state.text3d.svgSize = parseFloat(svgSizeInput.value); + state.text3d.editor?.setSvgSize(state.text3d.svgSize); + }); } // コマ上に Three.js canvas をオーバーレイ表示する(showPose3DCanvasと同型) +// コマ位置追従用ResizeObserverを(再)セットアップする。showText3DCanvas()に加え、 +// 3Dテキスト設定モーダルを閉じた直後(onCloseコールバック)からも呼ばれる +// (モーダルがcvsWrapperを借りている間はこのobserverを止めておく必要があるため)。 +function _text3dStartResizeObserver() { + const previewContainer = document.getElementById('layout-preview'); + const svgEl = document.querySelector('#layout-preview #image-layer svg'); + if (!previewContainer || !svgEl) return; + + if (state.text3d.resizeObserver) state.text3d.resizeObserver.disconnect(); + state.text3d.resizeObserver = new ResizeObserver(() => { + if (state.text3d.activePanelId) { + const p = state.activePage?.panels.find(pp => pp.id === state.text3d.activePanelId); + const sv = document.querySelector('#layout-preview #image-layer svg'); + if (p && sv) _text3dSyncPosition(p, sv); + } + }); + state.text3d.resizeObserver.observe(svgEl); + state.text3d.resizeObserver.observe(previewContainer); +} + function showText3DCanvas(panelId) { if (!panelId || !state.activePage) return; @@ -207,16 +264,7 @@ function showText3DCanvas(panelId) { // 反映されない・カメラが手動操作で見失った位置のまま戻らない、という不具合になる(実機で確認)。 _text3dApplyInitialState(); - if (state.text3d.resizeObserver) state.text3d.resizeObserver.disconnect(); - state.text3d.resizeObserver = new ResizeObserver(() => { - if (state.text3d.activePanelId) { - const p = state.activePage?.panels.find(pp => pp.id === state.text3d.activePanelId); - const sv = document.querySelector('#layout-preview #image-layer svg'); - if (p && sv) _text3dSyncPosition(p, sv); - } - }); - state.text3d.resizeObserver.observe(svgEl); - state.text3d.resizeObserver.observe(previewContainer); + _text3dStartResizeObserver(); if (state.text3d.editor) { const cvs = state.text3d.canvas; @@ -239,31 +287,57 @@ function _text3dApplyInitialState() { const editor = state.text3d.editor; if (!editor) return; - const textInput = document.getElementById('text3d-text-input'); const depthInput = document.getElementById('text3d-depth'); const bevelInput = document.getElementById('text3d-bevel'); - const fontSelect = document.getElementById('text3d-font-family'); const statusEl = document.getElementById('text3d-status'); + const modeTextBtn = document.getElementById('text3d-mode-text'); + const modeSvgBtn = document.getElementById('text3d-mode-svg'); + const svgControls = document.getElementById('text3d-svg-controls'); + const svgFileNameEl = document.getElementById('text3d-svg-filename'); + const svgSizeInput = document.getElementById('text3d-svg-size'); editor.setDepth(parseFloat(depthInput?.value ?? 0.15)); - editor.setBevel(!!bevelInput?.checked); // マテリアルは⚙モーダルで直接editorへ適用され、editor内部に生きた状態として残る。 // 新規作成直後・再編集復元直後(_pendingMaterialApply)以外は上書きしない。 if (_pendingMaterialApply) { const mp = state.text3d.materialParams; - editor.setColor(mp.color); + editor.setBevel(!!bevelInput?.checked, { + bevelThickness: mp.bevelThickness, + bevelSize: mp.bevelSize, + bevelSegments: mp.bevelSegments, + }); + editor.setFrontColor(mp.frontColor); + editor.setSideColor(mp.sideColor); + editor.setSeparateSides(mp.separateSides); editor.setMaterialType(mp.materialType); editor.setMetalness(mp.metalness); editor.setRoughness(mp.roughness); editor.setShadeColor(mp.shadeColor); editor.setToony(mp.toony); _pendingMaterialApply = false; + } else { + editor.setBevel(!!bevelInput?.checked); } - _text3dPopulateFontSelect(state.text3d.fontSource, fontSelect); - _text3dReloadFont(fontSelect, statusEl).then(() => { - editor.setText(textInput?.value ?? ''); + // SVG立体化モード(パネルに残っている現在値を毎回同期する) + const mode = state.text3d.renderMode || 'text'; + modeTextBtn?.classList.toggle('active', mode === 'text'); + modeSvgBtn?.classList.toggle('active', mode === 'svg'); + if (svgControls) svgControls.style.display = mode === 'svg' ? 'flex' : 'none'; + if (svgFileNameEl) svgFileNameEl.textContent = state.text3d.svgFileName || t('layout.text3dSvgFileNoneLabel'); + if (svgSizeInput) svgSizeInput.value = state.text3d.svgSize ?? 1.5; + editor.setSvgSize(state.text3d.svgSize ?? 1.5); + editor.setSvgData(state.text3d.svgData || ''); + editor.setRenderMode(mode); + if (mode === 'svg') statusEl && (statusEl.textContent = _text3dFormatSvgWarnings(editor.getSvgWarnings())); + + // テキスト内容・整列・行間・フォントは⚙設定モーダルへ移動しUI要素を持たないため、 + // state.text3dの値をここで一括適用する(モーダルを閉じた際にstateへ読み戻される)。 + editor.setAlign(state.text3d.align || 'center'); + editor.setLineHeight(state.text3d.lineHeight ?? 1.4); + _text3dReloadFont().then(() => { + editor.setText(state.text3d.text || ''); }); } @@ -385,35 +459,17 @@ async function commitText3D() { if (statusEl) statusEl.textContent = t('layout.text3dCommitted'); } -// フォントセレクトを現在のソース(Google/System)に応じて再構築する -function _text3dPopulateFontSelect(source, fontSelect) { - if (!fontSelect) return; - const prevValue = fontSelect.value; - fontSelect.innerHTML = ''; - - let families = []; +// 指定ソース(Google/System)のフォントファミリー一覧を返す(⚙設定モーダルのfontControls用)。 +// システムフォットは初回のみ非同期取得しキャッシュする。 +function _text3dGetFontFamilyOptions(source) { if (source === 'system') { - families = _systemFontFamiliesCache || []; - } else { - families = Array.from(GOOGLE_FONT_FAMILIES); - } - - families.forEach(family => { - const opt = document.createElement('option'); - opt.value = family; - opt.textContent = family; - fontSelect.appendChild(opt); - }); - - if (families.includes(prevValue)) fontSelect.value = prevValue; - else if (families.includes(state.text3d.fontFamily)) fontSelect.value = state.text3d.fontFamily; - - if (source === 'system' && !_systemFontFamiliesCache) { - _text3dLoadSystemFontFamilies().then(list => { + if (_systemFontFamiliesCache) return _systemFontFamiliesCache; + return _text3dLoadSystemFontFamilies().then(list => { _systemFontFamiliesCache = list; - if (state.text3d.fontSource === 'system') _text3dPopulateFontSelect('system', fontSelect); + return list; }); } + return Array.from(GOOGLE_FONT_FAMILIES); } async function _text3dLoadSystemFontFamilies() { @@ -427,13 +483,14 @@ async function _text3dLoadSystemFontFamilies() { } } -// 選択中のフォントファミリーをロードしてエディタに適用する -async function _text3dReloadFont(fontSelect, statusEl) { - const family = fontSelect?.value; +// state.text3d.fontFamily/fontSource(現在選択中のフォント)をロードしてエディタに適用する。 +// フォント選択UIは⚙設定モーダルへ移動しUI要素を常時持たないため、DOMではなくstateを直接読む。 +async function _text3dReloadFont() { + const family = state.text3d.fontFamily; const source = state.text3d.fontSource; + const statusEl = document.getElementById('text3d-status'); if (!family || !state.text3d.editor) return; - state.text3d.fontFamily = family; if (statusEl) statusEl.textContent = t('layout.text3dFontLoading'); try { const font = await loadFontForText3d(family, source); @@ -463,33 +520,41 @@ function startReeditText3D(imageEl) { state.text3d.reeditImageEl = imageEl; state.text3d.fontSource = params.fontSource || 'google'; state.text3d.fontFamily = params.fontFamily || state.text3d.fontFamily; + // SVG立体化モード関連の復元(旧データにはrenderMode/svgDataが存在しないため'text'にフォールバック) + state.text3d.renderMode = params.renderMode === 'svg' ? 'svg' : 'text'; + state.text3d.svgData = params.svgData || ''; + state.text3d.svgFileName = params.svgFileName || ''; + state.text3d.svgSize = params.svgSize ?? 1.5; + // テキスト内容・整列・行間の復元(⚙設定モーダルへ移動しUI要素を持たないためstateへ直接格納する) + state.text3d.text = params.text || ''; + state.text3d.align = params.align || 'center'; + state.text3d.lineHeight = params.lineHeight ?? 1.4; // UIコントロールに復元値を反映(showText3DCanvas → _text3dApplyInitialStateがこの値を読む) - const textInput = document.getElementById('text3d-text-input'); const depthInput = document.getElementById('text3d-depth'); const bevelInput = document.getElementById('text3d-bevel'); - const fsrcGoogleBtn = document.getElementById('text3d-fsrc-google'); - const fsrcSystemBtn = document.getElementById('text3d-fsrc-system'); - if (textInput) textInput.value = params.text || ''; if (depthInput) depthInput.value = params.depth ?? 0.15; if (bevelInput) bevelInput.checked = !!params.bevelEnabled; // マテリアルはUI要素を持たないため state.text3d.materialParams に復元値を格納し、 // 次の showText3DCanvas → _text3dApplyInitialState() での1回限りの適用を予約する。 + // 旧データ(frontColor/sideColor導入前)は単色の`color`をfrontColor/sideColor両方へフォールバックする。 state.text3d.materialParams = { - color: params.color || '#ffffff', + frontColor: params.frontColor || params.color || '#ffffff', + sideColor: params.sideColor || params.frontColor || params.color || '#ffffff', + separateSides: !!params.separateSides, materialType: params.materialType === 'toon' ? 'toon' : 'standard', metalness: params.metalness ?? 0.1, roughness: params.roughness ?? 0.6, shadeColor: params.shadeColor || '#999999', toony: params.toony ?? 0.9, + bevelThickness: params.bevelThickness ?? 0.02, + bevelSize: params.bevelSize ?? 0.01, + bevelSegments: params.bevelSegments ?? 2, }; _pendingMaterialApply = true; - if (fsrcGoogleBtn) fsrcGoogleBtn.classList.toggle('active', state.text3d.fontSource === 'google'); - if (fsrcSystemBtn) fsrcSystemBtn.classList.toggle('active', state.text3d.fontSource === 'system'); - // subtabを3Dテキストに切り替えてから表示する document.querySelector('.subtab-btn[data-subtab="text3d"]')?.click(); showText3DCanvas(panelId); diff --git a/static/js/text3d-core.js b/static/js/text3d-core.js index 9eb64f8..391497a 100644 --- a/static/js/text3d-core.js +++ b/static/js/text3d-core.js @@ -7,8 +7,18 @@ // // 日本語グリフの立体化: opentype.js の Font.getPath() が返すパスコマンド(M/L/C/Q)を // THREE.ShapePath に変換 → toShapes() → THREE.ExtrudeGeometry で押し出す。 +// +// SVG立体化: SVGLoader.parse() が返すパスを SVGLoader.createShapes() で Shape[] に変換し、 +// 同じ ExtrudeGeometry で押し出す(テキストモードと押し出し/ベベル設定を共有)。 +// SVGLoaderの座標系はSVG標準の「Y軸下向き正」のため、テキストモードと同様にYを反転する。 +// +// 表面/側面カラー: THREE.ExtrudeGeometryは標準で「キャップ面(表裏)=materialIndex 0」 +// 「側面(押し出し面)=materialIndex 1」の2グループを自動生成するため、 +// new THREE.Mesh(geometry, [frontMaterial, sideMaterial]) と配列を渡すだけで +// 表面/側面を別マテリアルにできる(three.js本体のExtrudeGeometry実装に基づく挙動)。 +// 分離しない場合はfrontMaterialをそのままsideにも使い回す(同一インスタンス参照)。 -export function initText3DEditor(THREE, OrbitControls, MToonMaterial, canvas, options = {}) { +export function initText3DEditor(THREE, OrbitControls, MToonMaterial, SVGLoaderClass, canvas, options = {}) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true, preserveDrawingBuffer: true }); renderer.setSize(canvas.width || 1, canvas.height || 1, false); @@ -122,17 +132,23 @@ export function initText3DEditor(THREE, OrbitControls, MToonMaterial, canvas, op let mesh = null; let currentFont = null; // opentype.Font + let lastSvgWarnings = []; // 直近のSVGパース結果に対する警告キー配列(getSvgWarnings()で参照) const params = { text: '', fontFamily: '', fontSource: 'google', fontSizeWorld: 1.2, + renderMode: 'text', // 'text' | 'svg' + svgData: '', // SVG立体化モード時の生SVG文字列 + svgSize: 1.5, // SVGのバウンディングボックス最大辺を合わせるワールドサイズ(fontSizeWorldと同オーダー) depth: 0.15, bevelEnabled: false, bevelThickness: 0.02, bevelSize: 0.01, bevelSegments: 2, - color: '#ffffff', + frontColor: '#ffffff', + sideColor: '#ffffff', + separateSides: false, // trueならfrontColor/sideColorを別マテリアルにする metalness: 0.1, roughness: 0.6, materialType: 'standard', // 'standard' | 'toon' @@ -196,64 +212,223 @@ export function initText3DEditor(THREE, OrbitControls, MToonMaterial, canvas, op if (!mesh) return; scene.remove(mesh); mesh.geometry.dispose(); - mesh.material.dispose(); + // mesh.materialは常に[front, side]の配列(分離しない場合は同一インスタンスを共有)。 + // Setで重複除去してから破棄する。 + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + new Set(mats).forEach((m) => m.dispose()); mesh = null; } + // mesh.material(常に[front, side]の配列。非分離時はfront===sideの同一インスタンス)を + // 重複なく巡回する。setMetalness等のライブ更新系セッターが使う。 + function _forEachMaterial(fn) { + if (!mesh) return; + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + new Set(mats).forEach(fn); + } + // ExtrudeGeometryの側面・キャップ面の法線向きが文字形状によって不安定になるケースへの // 安全策として両面描画にする(片面カリングによる欠け表示を防ぐ)。Standard/Toon共通。 - function _createMaterial() { + function _createMaterial(colorHex) { if (params.materialType === 'toon' && MToonMaterial) { return new MToonMaterial({ - color: params.color, + color: colorHex, shadeColorFactor: params.shadeColor, shadingToonyFactor: params.toony, side: THREE.DoubleSide, }); } return new THREE.MeshStandardMaterial({ - color: params.color, + color: colorHex, metalness: params.metalness, roughness: params.roughness, side: THREE.DoubleSide, }); } + // 表面(materialIndex 0)・側面(materialIndex 1)用マテリアルを生成する。 + // separateSidesがfalseの場合はsideにfrontと同一インスタンスを使い回す + // (setFrontColor()の1回のcolor.set()だけで両方に反映されるようにするため)。 + function _buildMaterials() { + const front = _createMaterial(params.frontColor); + const side = params.separateSides ? _createMaterial(params.sideColor) : front; + return [front, side]; + } + + // SVG文字列をパースしてExtrudeGeometryを生成する(3d-text-generator/src/utils/svgToShapes.ts を + // vanilla js化して移植)。テキストモードと同じdepth/bevel設定を共有する。 + // opts.forceNoBevel: trueの場合ベベルを無効化して押し出す(面取りがSVGの細い部分で自己交差し + // NaN頂点を生んだ場合の再構築フォールバック。_rebuildGeometry()から呼ばれる)。 + // 戻り値: { geometry, warnings, hasTextNodes, hasImageNodes, pathCount, shapeCount } + // geometryがnullの場合はパース失敗/有効パスなし(warningsに理由キーが入る)。 + function _buildSvgGeometry(svgString, targetSize, opts = {}) { + if (!svgString || !svgString.trim() || !SVGLoaderClass) return null; + + const warnings = []; + const hasTextNodes = /<(text|tspan)[\s>]/i.test(svgString); + const hasImageNodes = /<(image|img)[\s>]/i.test(svgString); + if (hasTextNodes) warnings.push('svgTextNode'); + if (hasImageNodes) warnings.push('svgImageNode'); + + let svgData; + try { + svgData = new SVGLoaderClass().parse(svgString); + } catch (err) { + console.error('[text3d] SVGパースに失敗しました:', err); + return { geometry: null, warnings: ['svgParseFailed', ...warnings], hasTextNodes, hasImageNodes, pathCount: 0, shapeCount: 0 }; + } + + const paths = svgData.paths || []; + if (paths.length === 0) { + return { geometry: null, warnings: ['svgNoPath', ...warnings], hasTextNodes, hasImageNodes, pathCount: 0, shapeCount: 0 }; + } + + const allShapes = []; + paths.forEach((path) => { + // 標準: SVGLoaderの通常のシェイプ生成 + let shapes = []; + try { shapes = SVGLoaderClass.createShapes(path); } catch (err) { /* ignore */ } + + // フォールバック1: createShapes()が空を返した場合(stroke-onlyやfill:noneのパス)にtoShapes()を強制 + if ((!shapes || shapes.length === 0) && typeof path.toShapes === 'function') { + try { shapes = path.toShapes(); } catch (err) { /* ignore */ } + } + + // フォールバック2: それでも空ならsubPathの点列から直接Shapeを構築する(線画向け救済策) + if ((!shapes || shapes.length === 0) && path.subPaths && path.subPaths.length > 0) { + path.subPaths.forEach((subPath) => { + const points = subPath.getPoints(); + if (points && points.length >= 3) { + const shape = new THREE.Shape(); + shape.moveTo(points[0].x, points[0].y); + for (let i = 1; i < points.length; i++) shape.lineTo(points[i].x, points[i].y); + shapes.push(shape); + } + }); + } + + if (shapes && shapes.length > 0) allShapes.push(...shapes); + }); + + if (allShapes.length === 0) { + return { geometry: null, warnings: ['svgNoPath', ...warnings], hasTextNodes, hasImageNodes, pathCount: paths.length, shapeCount: 0 }; + } + + const geometry = new THREE.ExtrudeGeometry(allShapes, { + depth: params.depth, + bevelEnabled: opts.forceNoBevel ? false : params.bevelEnabled, + bevelThickness: params.bevelThickness, + bevelSize: params.bevelSize, + bevelSegments: params.bevelSegments, + curveSegments: 8, + steps: 1, + }); + + // SVGLoaderの座標系はSVG標準の「Y軸下向き正」のため、テキストモードと同じくY反転する + geometry.scale(1, -1, 1); + + // バウンディングボックスの最大辺をsvgSize(テキストのfontSizeWorldと同オーダー)に正規化する + geometry.computeBoundingBox(); + const rawSize = new THREE.Vector3(); + geometry.boundingBox.getSize(rawSize); + const maxDim = Math.max(rawSize.x, rawSize.y, 0.0001); + const scale = (targetSize || 1.5) / maxDim; + geometry.scale(scale, scale, 1); + + return { geometry, warnings, hasTextNodes, hasImageNodes, pathCount: paths.length, shapeCount: allShapes.length }; + } + + // ジオメトリが健全かどうかを検証する。ExtrudeGeometryは面取り(bevelSize/bevelThickness)が + // 形状の細い部分(鋭い先端や薄い線幅)に対して大きすぎると、ベベル輪郭が自己交差して破綻することが + // ある(three.js本体の既知の制約。実機検証でSVGモードにて確認)。破綻のパターンは2種類あり、 + // 頂点座標そのものがNaNになるケースと、面積0の三角形が生じて法線ベクトルが(0,0,0)になり、 + // シェーダー側の正規化(0/0)でNaN化して真っ黒に描画されるケース(コンソールエラーは出ない)がある。 + // 後者はbbox(頂点座標)だけを見ても検出できないため、normal属性も併せて走査する。 + function _isGeometryValid(geometry) { + const pos = geometry.attributes.position; + if (!pos) return false; + const parr = pos.array; + for (let i = 0; i < parr.length; i++) { + if (!Number.isFinite(parr[i])) return false; + } + const nrm = geometry.attributes.normal; + if (nrm) { + const narr = nrm.array; + for (let i = 0; i < narr.length; i += 3) { + const x = narr[i], y = narr[i + 1], z = narr[i + 2]; + if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z)) return false; + if (x === 0 && y === 0 && z === 0) return false; + } + } + return true; + } + + function _buildTextExtrudeGeometry(bevelEnabled) { + const shapes = _buildShapesForText(currentFont, params.text, params.fontSizeWorld, params.align, params.lineHeight); + if (shapes.length === 0) return null; + return new THREE.ExtrudeGeometry(shapes, { + depth: params.depth, + bevelEnabled, + bevelThickness: params.bevelThickness, + bevelSize: params.bevelSize, + bevelSegments: params.bevelSegments, + curveSegments: 8, + steps: 1, + }); + } + // 直近に生成したジオメトリのサイズ(カメラのフィット計算に使う。テキストが空ならゼロのまま) let lastBBoxSize = { width: 0, height: 0, depth: 0 }; function _rebuildGeometry() { _disposeMesh(); - if (!currentFont || !params.text.trim()) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } - const shapes = _buildShapesForText(currentFont, params.text, params.fontSizeWorld, params.align, params.lineHeight); - if (shapes.length === 0) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } + let geometry; + if (params.renderMode === 'svg') { + const result = _buildSvgGeometry(params.svgData, params.svgSize); + lastSvgWarnings = result ? result.warnings : []; + if (!result || !result.geometry) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } + geometry = result.geometry; + } else { + lastSvgWarnings = []; + if (!currentFont || !params.text.trim()) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } + geometry = _buildTextExtrudeGeometry(params.bevelEnabled); + if (!geometry) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } + } - const geometry = new THREE.ExtrudeGeometry(shapes, { - depth: params.depth, - bevelEnabled: params.bevelEnabled, - bevelThickness: params.bevelThickness, - bevelSize: params.bevelSize, - bevelSegments: params.bevelSegments, - curveSegments: 8, - steps: 1, - }); geometry.computeBoundingBox(); + + // 面取りが原因でジオメトリが破綻した場合、真っ黒なまま無音で失敗させず、 + // 面取りを無効化して再構築することで必ず何かが表示される状態に復旧する。 + if (params.bevelEnabled && !_isGeometryValid(geometry)) { + console.warn('[text3d] 面取り(ベベル)が形状に対して大きすぎて破綻したため、面取りを無効化して再構築しました'); + geometry.dispose(); + if (params.renderMode === 'svg') { + const fallback = _buildSvgGeometry(params.svgData, params.svgSize, { forceNoBevel: true }); + lastSvgWarnings = [...(fallback ? fallback.warnings : []), 'svgBevelTooLarge']; + if (!fallback || !fallback.geometry) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } + geometry = fallback.geometry; + } else { + geometry = _buildTextExtrudeGeometry(false); + if (!geometry) { lastBBoxSize = { width: 0, height: 0, depth: 0 }; return; } + } + geometry.computeBoundingBox(); + } + const bb = geometry.boundingBox; lastBBoxSize = { width: bb.max.x - bb.min.x, height: bb.max.y - bb.min.y, depth: bb.max.z - bb.min.z, }; - // bbox中心を原点に揃える(カメラ・回転操作の基準を文字数に依らず一定にするため) + // bbox中心を原点に揃える(カメラ・回転操作の基準を文字数/SVG形状に依らず一定にするため) geometry.translate( -(bb.max.x + bb.min.x) / 2, -(bb.max.y + bb.min.y) / 2, -(bb.max.z + bb.min.z) / 2, ); - const material = _createMaterial(); - mesh = new THREE.Mesh(geometry, material); + mesh = new THREE.Mesh(geometry, _buildMaterials()); scene.add(mesh); } @@ -312,14 +487,57 @@ export function initText3DEditor(THREE, OrbitControls, MToonMaterial, canvas, op if (opts.bevelSegments != null) params.bevelSegments = opts.bevelSegments; _rebuildGeometry(); }, + // 面取り厚み/サイズ/分割数を個別に変更する(⚙設定モーダルの詳細スライダー用。ON/OFF自体はsetBevelで扱う) + setBevelThickness(v) { params.bevelThickness = v; _rebuildGeometry(); }, + setBevelSize(v) { params.bevelSize = v; _rebuildGeometry(); }, + setBevelSegments(v) { params.bevelSegments = Math.round(v); _rebuildGeometry(); }, setAlign(align) { params.align = align; _rebuildGeometry(); }, - setColor(hex) { params.color = hex; if (mesh) mesh.material.color.set(hex); }, - setMetalness(v) { params.metalness = v; if (mesh && mesh.material.isMeshStandardMaterial) mesh.material.metalness = v; }, - setRoughness(v) { params.roughness = v; if (mesh && mesh.material.isMeshStandardMaterial) mesh.material.roughness = v; }, + setLineHeight(v) { params.lineHeight = v; _rebuildGeometry(); if (!userAdjustedCamera) _fitCameraToText(); }, + // ---- SVG立体化モード ---- + setRenderMode(mode) { + params.renderMode = mode === 'svg' ? 'svg' : 'text'; + _rebuildGeometry(); + if (!userAdjustedCamera) _fitCameraToText(); + }, + setSvgData(svgString) { + params.svgData = svgString || ''; + _rebuildGeometry(); + if (!userAdjustedCamera) _fitCameraToText(); + }, + setSvgSize(v) { + params.svgSize = v; + _rebuildGeometry(); + if (!userAdjustedCamera) _fitCameraToText(); + }, + // 直近のSVGパース結果に対する警告キー配列('svgTextNode'/'svgImageNode'/'svgNoPath'/'svgParseFailed')。 + // 呼び出し側がi18nメッセージへ変換して表示する。 + getSvgWarnings() { return lastSvgWarnings.slice(); }, + // ---- 表面/側面カラー ---- + // 非分離時はmesh.material[0]===mesh.material[1](同一インスタンス)のため、 + // frontのcolor.set()だけで両方に反映される。 + setFrontColor(hex) { + params.frontColor = hex; + if (mesh) { + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + mats[0].color.set(hex); + } + }, + setColor(hex) { this.setFrontColor(hex); }, // 後方互換エイリアス(旧API。表面色として扱う) + setSideColor(hex) { + params.sideColor = hex; + if (mesh && params.separateSides) { + const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]; + if (mats[1]) mats[1].color.set(hex); + } + }, + // マテリアルインスタンスの共有/分離自体が切り替わるため再構築する + setSeparateSides(v) { params.separateSides = !!v; _rebuildGeometry(); }, + setMetalness(v) { params.metalness = v; _forEachMaterial((m) => { if (m.isMeshStandardMaterial) m.metalness = v; }); }, + setRoughness(v) { params.roughness = v; _forEachMaterial((m) => { if (m.isMeshStandardMaterial) m.roughness = v; }); }, // マテリアルクラス自体が変わるため、ライブ更新ではなくジオメトリごと再構築する setMaterialType(type) { params.materialType = type; _rebuildGeometry(); }, - setShadeColor(hex) { params.shadeColor = hex; if (mesh && mesh.material.isMToonMaterial) mesh.material.shadeColorFactor.set(hex); }, - setToony(v) { params.toony = v; if (mesh && mesh.material.isMToonMaterial) mesh.material.shadingToonyFactor = v; }, + setShadeColor(hex) { params.shadeColor = hex; _forEachMaterial((m) => { if (m.isMToonMaterial) m.shadeColorFactor.set(hex); }); }, + setToony(v) { params.toony = v; _forEachMaterial((m) => { if (m.isMToonMaterial) m.shadingToonyFactor = v; }); }, resetCamera() { userAdjustedCamera = false; if (lastBBoxSize.width > 0 && lastBBoxSize.height > 0) { diff --git a/static/js/text3d-settings-modal.js b/static/js/text3d-settings-modal.js index fdc2174..c46fb1c 100644 --- a/static/js/text3d-settings-modal.js +++ b/static/js/text3d-settings-modal.js @@ -1,9 +1,12 @@ -// text3d-settings-modal.js — 3Dテキストの「ライト・マテリアル設定」モーダル +// text3d-settings-modal.js — 3Dテキストの「ライト・マテリアル・テキスト設定」モーダル // // comfyui-vrm-pose-editor の light_editor.js (openLightEditor) を範にした軽量版。 // text3dのライトはAmbient/Key/Fillの3灯固定(3Dポーズ側のような自由な追加/削除・ -// プリセットライブラリは持たない)。マテリアル設定(色/Standard-Toon切替/金属・粗さ/ -// 陰色・階調)もここに集約し、常時表示のツールバーからは撤去する。 +// プリセットライブラリは持たない)。 +// +// レイアウト: 左=ライト/マテリアル/カメラのタブ切替パネル、中央=プレビュー、 +// 右=テキスト設定(テキスト内容・フォント・整列・行間、常時表示・タブ化しない)。 +// 常時表示のツールバーからはテキスト入力・フォント・整列を撤去し、ここに集約する。 // // ズーム操作モード設定(ホイール/Ctrl+右ドラッグ)は localStorage キー // 'vrmPoseEditor_zoomMode' を3Dポーズ側のLight Editorと共有する。どちらで変更しても @@ -15,16 +18,38 @@ // 実際に描画中のcanvasが隠れてしまい、色・質感の見比べができないための対応(ユーザー指摘)。 // // editor は initText3DEditor() の戻り値(getLights/setLightColor/setLightIntensity/ -// setLightPosition/getZoomMode/setZoomMode/getParams/setColor/setMaterialType/ -// setMetalness/setRoughness/setShadeColor/setToony を持つ)を渡す。 +// setLightPosition/getZoomMode/setZoomMode/getParams/setFrontColor/setSideColor/ +// setSeparateSides/setMaterialType/setMetalness/setRoughness/setShadeColor/setToony/ +// setBevelThickness/setBevelSize/setBevelSegments/setText/setAlign/setLineHeight/ +// resetCamera/resizeRenderer/getSuperSample/setSuperSample を持つ)を渡す。 // cvsWrapper は実際に描画中のcanvasを内包するDOM要素(呼び出し元のwrapper div)を渡す。 +// +// options.onClose(省略可): モーダルが閉じた(cleanup()が呼ばれた)タイミングで呼ばれる +// コールバック。レイアウトタブ側は cvsWrapper の位置をコマの画面上位置に追従させる +// ResizeObserverを持っており、これがモーダル表示中も動き続けると、モーダルが一時的に +// 借りているcvsWrapperのleft/top/transformを「コマの本来の位置」へ強制的に書き戻してしまい、 +// モーダル内プレビューが(実際には正しく描画されているのに)画面外へ飛ばされて真っ黒に見える +// 不具合が実機検証で判明した。呼び出し側はopen前にそのResizeObserverをdisconnectし、 +// onCloseで再接続する(テキスト/整列/行間はモーダル内で直接editorへ適用されるため、 +// 呼び出し側が独自の状態変数を持つ場合はonClose内でeditor.getParams()から読み戻して同期する)。 +// +// options.fontControls(省略可。渡さない場合はフォント選択UIを表示しない): +// フォントの読み込み・一覧取得は非同期かつ呼び出し側(レイアウトタブ/Imageタブ)ごとに +// 実装が異なる(Google Fonts一覧の取得元・システムフォントキャッシュの持ち方が別々)ため、 +// モーダル自身はロジックを持たず、以下のコールバック一式を呼び出し側から受け取って委譲する。 +// getFontSource(): 'google' | 'system' +// setFontSource(source): void — 呼び出し側の状態を更新する(一覧の再取得はモーダル側が行う) +// getFontFamily(): string — 現在選択中のフォントファミリー名 +// setFontFamily(family): void — フォントを読み込んでeditorへ適用する(非同期で構わない) +// getFontFamilyOptions(source): string[] | Promise — 指定ソースのフォント一覧 import { t } from './i18n.js'; const LIGHT_LABEL_KEYS = { ambient: 'layout.text3dLightAmbient', key: 'layout.text3dLightKey', fill: 'layout.text3dLightFill' }; -export function openText3DSettingsModal(editor, cvsWrapper) { +export function openText3DSettingsModal(editor, cvsWrapper, options = {}) { if (!editor || document.getElementById('text3d-settings-modal')) return; + const { onClose, fontControls } = options; // モーダル背後のテキスト入力/フォント選択にフォーカスが残ったままだと、オーバーレイの // クリック遮断をすり抜けてキー操作(矢印キーでのフォント切替など)が背後へ届いてしまう。 @@ -96,11 +121,12 @@ export function openText3DSettingsModal(editor, cvsWrapper) { } resizeObserver?.disconnect(); overlay.remove(); + onClose?.(); } const dialog = el('div', { style: 'background:#1e1e2e;color:#ccc;border-radius:10px;' + - 'width:min(94vw,820px);height:min(90vh,600px);display:flex;flex-direction:column;' + + 'width:min(94vw,1000px);height:min(90vh,600px);display:flex;flex-direction:column;' + 'box-shadow:0 8px 40px rgba(0,0,0,0.85);font-family:sans-serif;overflow:hidden;', }); @@ -109,34 +135,86 @@ export function openText3DSettingsModal(editor, cvsWrapper) { 'background:#16213e;border-bottom:1px solid #333;flex-shrink:0;', }); header.append( - el('span', { style: 'font-size:14px;font-weight:bold;color:#e0e0ff;flex:1;' }, t('layout.text3dSettingsModalTitle')), + // ユーザー指定によりロゴ的な固定名称とし、多言語化しない(3Dポーズの"RC"表記と同じ扱い) + el('span', { style: 'font-size:14px;font-weight:bold;color:#e0e0ff;flex:1;' }, '3D Text Editor'), mkCloseBtn(cleanup), ); const bodyRow = el('div', { style: 'flex:1;display:flex;overflow:hidden;min-height:0;' }); - // ---- 左: プレビュー(実描画中のcanvasを埋め込む) ---- - const previewPanel = el('div', { - style: 'flex:1;display:flex;flex-direction:column;background:#111118;' + - 'border-right:1px solid #2a2a4a;min-width:0;', + // ================================================================ + // 左: ライト/マテリアル/カメラ タブ切替パネル + // ================================================================ + const leftPanel = el('div', { + style: 'width:260px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;' + + 'border-right:1px solid #2a2a4a;', }); - const previewHeader = el('div', { - style: 'font-size:10px;color:#556;padding:5px 10px;flex-shrink:0;' + - 'border-bottom:1px solid #1a1a2a;background:#13131e;', - }, t('layout.text3dPreviewHint')); - const previewWrap = el('div', { - style: 'flex:1;display:flex;align-items:center;justify-content:center;' + - 'overflow:hidden;min-height:0;padding:4px;', + const tabBar = el('div', { style: 'display:flex;flex-shrink:0;border-bottom:1px solid #2a2a4a;' }); + const tabScroll = el('div', { style: 'flex:1;overflow-y:auto;padding:12px 14px;min-height:0;' }); + leftPanel.append(tabBar, tabScroll); + + const lightContent = el('div', {}); + const materialContent = el('div', {}); + const cameraContent = el('div', {}); + tabScroll.append(lightContent, materialContent, cameraContent); + + const TABS = [ + { id: 'light', label: t('layout.text3dLightSection'), content: lightContent }, + { id: 'material', label: t('layout.text3dMaterialSection'), content: materialContent }, + { id: 'camera', label: t('layout.text3dCameraSection'), content: cameraContent }, + ]; + const tabButtons = {}; + let activeTabId = 'light'; + + function showTab(tabId) { + activeTabId = tabId; + TABS.forEach((tab) => { + const isActive = tab.id === tabId; + tab.content.style.display = isActive ? 'block' : 'none'; + const btn = tabButtons[tab.id]; + btn.style.background = isActive ? '#26314f' : 'transparent'; + btn.style.color = isActive ? '#e0e0ff' : '#8892a6'; + btn.style.borderBottomColor = isActive ? '#4a90d9' : 'transparent'; + }); + } + + TABS.forEach((tab) => { + const btn = el('button', { + style: 'flex:1;padding:9px 4px;background:transparent;color:#8892a6;border:none;' + + 'border-bottom:2px solid transparent;cursor:pointer;font-size:11px;font-weight:bold;', + }, tab.label); + btn.onclick = () => showTab(tab.id); + tabButtons[tab.id] = btn; + tabBar.appendChild(btn); }); - previewPanel.append(previewHeader, previewWrap); - // ---- 右: 設定(スクロール可能) ---- - const settingsPanel = el('div', { style: 'width:280px;flex-shrink:0;overflow-y:auto;padding:12px 14px;' }); + // ---- ライト(Ambient/Key/Fillの3灯固定) ---- + editor.getLights().forEach((cfg) => { + lightContent.appendChild(el('div', { + style: 'font-size:11px;font-weight:bold;color:#7a9aaa;margin:0 0 4px;', + }, t(LIGHT_LABEL_KEYS[cfg.id] ?? cfg.id))); - // ---- マテリアル ---- - settingsPanel.appendChild(sectionTitle(t('layout.text3dMaterialSection'))); + lightContent.appendChild(row(t('layout.text3dColorLabel'), mkColorInput(cfg.color, (v) => editor.setLightColor(cfg.id, v)))); + const [iSl, iVl] = mkSl(0, 3, 0.05, cfg.intensity, (v) => editor.setLightIntensity(cfg.id, v)); + lightContent.appendChild(rowWithValue(t('layout.text3dLightIntensityLabel'), iSl, iVl)); + + if (cfg.position) { + const pos = { ...cfg.position }; + const posLabelKeys = { x: 'layout.text3dLightPosXLabel', y: 'layout.text3dLightPosYLabel', z: 'layout.text3dLightPosZLabel' }; + ['x', 'y', 'z'].forEach((axis) => { + const [sl, vl] = mkSl(-10, 10, 0.1, pos[axis], (v) => { + pos[axis] = v; + editor.setLightPosition(cfg.id, pos.x, pos.y, pos.z); + }); + lightContent.appendChild(rowWithValue(t(posLabelKeys[axis]), sl, vl)); + }); + } + lightContent.appendChild(el('div', { style: 'height:8px;' })); + }); + + // ---- マテリアル + 面取り詳細 ---- const materialSection = el('div', {}); - settingsPanel.appendChild(materialSection); + materialContent.appendChild(materialSection); function renderMaterial() { materialSection.innerHTML = ''; @@ -152,7 +230,19 @@ export function openText3DSettingsModal(editor, cvsWrapper) { matRow.append(stdBtn, toonBtn); materialSection.appendChild(matRow); - materialSection.appendChild(row(t('layout.text3dColorLabel'), mkColorInput(p.color, (v) => editor.setColor(v)))); + // 表面/側面カラー(ExtrudeGeometryの自動マテリアルグループ[0]=表裏キャップ, [1]=側面に対応) + materialSection.appendChild(row(t('layout.text3dFrontColorLabel'), mkColorInput(p.frontColor, (v) => editor.setFrontColor(v)))); + + const sepRow = el('div', { style: 'display:flex;align-items:center;gap:6px;margin-bottom:6px;' }); + const sepCheckbox = el('input', { type: 'checkbox', style: 'cursor:pointer;' }); + sepCheckbox.checked = !!p.separateSides; + sepCheckbox.addEventListener('change', () => { editor.setSeparateSides(sepCheckbox.checked); renderMaterial(); }); + sepRow.append(sepCheckbox, el('label', { style: 'font-size:11px;color:#888;cursor:pointer;' }, t('layout.text3dSeparateSidesLabel'))); + materialSection.appendChild(sepRow); + + const sideColorInput = mkColorInput(p.sideColor, (v) => editor.setSideColor(v)); + if (!p.separateSides) { sideColorInput.disabled = true; sideColorInput.style.opacity = '0.4'; } + materialSection.appendChild(row(t('layout.text3dSideColorLabel'), sideColorInput)); if (p.materialType === 'toon') { materialSection.appendChild(row(t('layout.text3dShadeColorLabel'), mkColorInput(p.shadeColor, (v) => editor.setShadeColor(v)))); @@ -167,40 +257,30 @@ export function openText3DSettingsModal(editor, cvsWrapper) { } renderMaterial(); - // ---- ライト(Ambient/Key/Fillの3灯固定) ---- - settingsPanel.appendChild(sectionTitle(t('layout.text3dLightSection'))); - editor.getLights().forEach((cfg) => { - settingsPanel.appendChild(el('div', { - style: 'font-size:11px;font-weight:bold;color:#7a9aaa;margin:8px 0 4px;', - }, t(LIGHT_LABEL_KEYS[cfg.id] ?? cfg.id))); + materialContent.appendChild(sectionTitle(t('layout.text3dBevelSection'))); + const bevelSection = el('div', {}); + materialContent.appendChild(bevelSection); - settingsPanel.appendChild(row(t('layout.text3dColorLabel'), mkColorInput(cfg.color, (v) => editor.setLightColor(cfg.id, v)))); - const [iSl, iVl] = mkSl(0, 3, 0.05, cfg.intensity, (v) => editor.setLightIntensity(cfg.id, v)); - settingsPanel.appendChild(rowWithValue(t('layout.text3dLightIntensityLabel'), iSl, iVl)); - - if (cfg.position) { - const pos = { ...cfg.position }; - const posLabelKeys = { x: 'layout.text3dLightPosXLabel', y: 'layout.text3dLightPosYLabel', z: 'layout.text3dLightPosZLabel' }; - ['x', 'y', 'z'].forEach((axis) => { - const [sl, vl] = mkSl(-10, 10, 0.1, pos[axis], (v) => { - pos[axis] = v; - editor.setLightPosition(cfg.id, pos.x, pos.y, pos.z); - }); - settingsPanel.appendChild(rowWithValue(t(posLabelKeys[axis]), sl, vl)); - }); - } - }); + function renderBevel() { + bevelSection.innerHTML = ''; + const p = editor.getParams(); + const [thSl, thVl] = mkSl(0, 0.1, 0.005, p.bevelThickness, (v) => editor.setBevelThickness(v)); + bevelSection.appendChild(rowWithValue(t('layout.text3dBevelThicknessLabel'), thSl, thVl)); + const [szSl, szVl] = mkSl(0, 0.1, 0.005, p.bevelSize, (v) => editor.setBevelSize(v)); + bevelSection.appendChild(rowWithValue(t('layout.text3dBevelSizeLabel'), szSl, szVl)); + const [sgSl, sgVl] = mkSl(1, 8, 1, p.bevelSegments, (v) => editor.setBevelSegments(v)); + bevelSection.appendChild(rowWithValue(t('layout.text3dBevelSegmentsLabel'), sgSl, sgVl)); + } + renderBevel(); // ---- カメラ(カメラリセット・ズーム操作モード[3Dポーズ側の設定と共通]) ---- - settingsPanel.appendChild(sectionTitle(t('layout.text3dCameraSection'))); - if (typeof editor.resetCamera === 'function') { const rcBtn = mkToggleBtn('RC', false); // 3Dポーズの表記に合わせ、翻訳せず"RC"固定 rcBtn.title = t('layout.text3dResetCameraTitle'); rcBtn.style.width = '100%'; rcBtn.style.marginBottom = '8px'; rcBtn.onclick = () => editor.resetCamera(); - settingsPanel.appendChild(rcBtn); + cameraContent.appendChild(rcBtn); } const zoomLabel = t('layout.text3dZoomModeBtn'); @@ -213,7 +293,7 @@ export function openText3DSettingsModal(editor, cvsWrapper) { editor.setZoomMode(next); applyToggle(zoomBtn, zoomLabel, next === 'ctrlDrag'); }; - settingsPanel.appendChild(zoomBtn); + cameraContent.appendChild(zoomBtn); if (typeof editor.getSuperSample === 'function') { const aaLabel = t('layout.text3dAntialiasBtn'); @@ -225,10 +305,121 @@ export function openText3DSettingsModal(editor, cvsWrapper) { editor.setSuperSample(next); applyToggle(aaBtn, aaLabel, next); }; - settingsPanel.appendChild(aaBtn); + cameraContent.appendChild(aaBtn); } - bodyRow.append(previewPanel, settingsPanel); + showTab('light'); + + // ================================================================ + // 中央: プレビュー(実描画中のcanvasを埋め込む) + // ================================================================ + const previewPanel = el('div', { + style: 'flex:1;display:flex;flex-direction:column;background:#111118;' + + 'border-right:1px solid #2a2a4a;min-width:0;', + }); + const previewHeader = el('div', { + style: 'font-size:10px;color:#556;padding:5px 10px;flex-shrink:0;' + + 'border-bottom:1px solid #1a1a2a;background:#13131e;', + }, t('layout.text3dPreviewHint')); + const previewWrap = el('div', { + style: 'flex:1;display:flex;align-items:center;justify-content:center;' + + 'overflow:hidden;min-height:0;padding:4px;', + }); + previewPanel.append(previewHeader, previewWrap); + + // ================================================================ + // 右: テキスト設定(テキスト内容・フォント・整列・行間。タブ化せず常時表示) + // ================================================================ + const textPanel = el('div', { style: 'width:280px;flex-shrink:0;overflow-y:auto;padding:12px 14px;' }); + textPanel.appendChild(sectionTitle(t('layout.text3dTextSection'))); + + const initialParams = editor.getParams(); + + // ---- テキスト内容(5行表示+スクロール) ---- + const textarea = el('textarea', { + rows: '5', + style: 'width:100%;box-sizing:border-box;resize:vertical;font-size:12px;font-family:sans-serif;' + + 'background:#13131e;color:#ddd;border:1px solid #2a2a4a;border-radius:4px;' + + 'padding:6px;margin-bottom:10px;min-height:80px;', + }); + textarea.placeholder = t('layout.text3dTextPlaceholder'); + textarea.value = initialParams.text || ''; + textarea.addEventListener('wheel', (e) => e.stopPropagation(), { passive: true }); + let textDebounceTimer = null; + textarea.addEventListener('input', () => { + clearTimeout(textDebounceTimer); + textDebounceTimer = setTimeout(() => editor.setText(textarea.value), 200); + }); + textPanel.appendChild(textarea); + + // ---- フォント(呼び出し側からfontControlsが渡された場合のみ表示) ---- + if (fontControls) { + textPanel.appendChild(sectionTitle(t('layout.text3dFontSection'))); + + const srcRow = el('div', { style: 'display:flex;gap:6px;margin-bottom:8px;' }); + const googleLabel = t('layout.text3dFontSourceGoogle'); + const systemLabel = t('layout.text3dFontSourceSystem'); + const googleBtn = mkToggleBtn(googleLabel, fontControls.getFontSource() !== 'system'); + const systemBtn = mkToggleBtn(systemLabel, fontControls.getFontSource() === 'system'); + googleBtn.style.flex = '1'; + systemBtn.style.flex = '1'; + srcRow.append(googleBtn, systemBtn); + textPanel.appendChild(srcRow); + + const familySelect = el('select', { + style: 'width:100%;box-sizing:border-box;font-size:12px;margin-bottom:10px;' + + 'background:#13131e;color:#ddd;border:1px solid #2a2a4a;border-radius:4px;padding:5px;', + }); + textPanel.appendChild(familySelect); + + async function refreshFamilyOptions(source) { + const opts = (await fontControls.getFontFamilyOptions(source)) || []; + const current = fontControls.getFontFamily(); + const list = opts.length ? opts : [current].filter(Boolean); + familySelect.innerHTML = ''; + list.forEach((f) => familySelect.appendChild(el('option', { value: f }, f))); + if (list.includes(current)) { + familySelect.value = current; + } else if (list.length) { + familySelect.value = list[0]; + fontControls.setFontFamily(list[0]); + } + } + + const setSourceActive = (source) => { + applyToggle(googleBtn, googleLabel, source !== 'system'); + applyToggle(systemBtn, systemLabel, source === 'system'); + }; + googleBtn.onclick = () => { fontControls.setFontSource('google'); setSourceActive('google'); refreshFamilyOptions('google'); }; + systemBtn.onclick = () => { fontControls.setFontSource('system'); setSourceActive('system'); refreshFamilyOptions('system'); }; + familySelect.addEventListener('change', () => fontControls.setFontFamily(familySelect.value)); + + refreshFamilyOptions(fontControls.getFontSource()); + } + + // ---- 整列 ---- + textPanel.appendChild(sectionTitle(t('layout.text3dAlignSection'))); + const alignRow = el('div', { style: 'display:flex;gap:6px;margin-bottom:10px;' }); + const alignBtns = { + left: mkToggleBtn(t('layout.text3dAlignLeft'), initialParams.align === 'left'), + center: mkToggleBtn(t('layout.text3dAlignCenter'), !initialParams.align || initialParams.align === 'center'), + right: mkToggleBtn(t('layout.text3dAlignRight'), initialParams.align === 'right'), + }; + Object.entries(alignBtns).forEach(([id, btn]) => { + btn.style.flex = '1'; + btn.onclick = () => { + editor.setAlign(id); + Object.entries(alignBtns).forEach(([bid, b]) => { b.style.background = bid === id ? '#3a6a1a' : '#333344'; }); + }; + alignRow.appendChild(btn); + }); + textPanel.appendChild(alignRow); + + // ---- 行間 ---- + const [lhSl, lhVl] = mkSl(1.0, 3.0, 0.05, initialParams.lineHeight ?? 1.4, (v) => editor.setLineHeight(v)); + textPanel.appendChild(rowWithValue(t('layout.text3dLineHeightLabel'), lhSl, lhVl)); + + bodyRow.append(leftPanel, previewPanel, textPanel); dialog.append(header, bodyRow); overlay.appendChild(dialog); document.body.appendChild(overlay); diff --git a/static/js/text3d.js b/static/js/text3d.js index 2bcec0f..583689a 100644 --- a/static/js/text3d.js +++ b/static/js/text3d.js @@ -5,8 +5,9 @@ // three-vrm.module.js を動的importして再利用する。three-vrm.module.js は3DポーズのVRM表示だけでなく // MToonMaterial(VRMのトゥーンシェーダー)も標準クラスとしてexportしており、GLTFロード無しでも // 単体でnewできるため、3Dテキストのトゥーン調マテリアルにもそのまま流用する。 -// text3d-core.js はTHREE非依存に書かれているため、ここで取得したTHREE/OrbitControls/MToonMaterialを -// 注入するだけの役割を持つ。 +// text3d-core.js はTHREE非依存に書かれているため、ここで取得したTHREE/OrbitControls/MToonMaterial/ +// SVGLoaderを注入するだけの役割を持つ。SVGLoaderはSVG立体化機能(テキストと同様にvendor化した +// three r160版 examples/jsm/loaders/SVGLoader.js)のパース処理に使う。 // // ノード未インストール時は window.initText3DEditor を一切公開しない。呼び出し側 // (25-text3d-bridge.js / Text3DTool.js)は既存の pose3d 呼び出し元と同じリトライ機構 @@ -16,18 +17,20 @@ const NODE_BASE = '/extensions/comfyui-vrm-pose-editor/'; const THREE_URL = NODE_BASE + 'vendor/three.module.js'; const ORBIT_URL = NODE_BASE + 'vendor/OrbitControls.js'; const VRM_URL = NODE_BASE + 'vendor/three-vrm.module.js'; +const SVG_URL = NODE_BASE + 'vendor/SVGLoader.js'; const CORE_URL = '/ccc_static/js/text3d-core.js'; async function _installBridge() { - const [THREE, orbitMod, vrmMod, core] = await Promise.all([ + const [THREE, orbitMod, vrmMod, svgMod, core] = await Promise.all([ import(THREE_URL), import(ORBIT_URL), import(VRM_URL), + import(SVG_URL), import(CORE_URL), ]); window.initText3DEditor = function (canvas, options) { - return core.initText3DEditor(THREE, orbitMod.OrbitControls, vrmMod.MToonMaterial, canvas, options); + return core.initText3DEditor(THREE, orbitMod.OrbitControls, vrmMod.MToonMaterial, svgMod.SVGLoader, canvas, options); }; } diff --git a/templates/index.html b/templates/index.html index 057105b..f38d610 100644 --- a/templates/index.html +++ b/templates/index.html @@ -708,11 +708,18 @@