3Dテキストに SVG立体化・表面/側面カラー分離・設定モーダルのタブ化を追加
- SVGファイルのベクターパスを立体押し出しできる「SVG」モードを追加(テキストモードと切替) - 表面色・側面色を個別指定できるマテリアル(ExtrudeGeometryの表裏キャップ面/側面グループを利用)と、面取り厚み・サイズ・分割数の詳細スライダーを追加 - 面取りが形状に対して大きすぎてジオメトリが破綻した場合に自動的に無効化し警告を出す防御処理を追加 - ⚙設定モーダルを3カラム構成(左: ライト/マテリアル/カメラのタブ切替、中央: プレビュー、右: 常時表示のテキストパネル)に再設計し、テキスト入力・フォント・整列をツールバーからモーダルへ移動、行間スライダーを新規追加 - モーダルを開いている間、コマ位置追従用ResizeObserverがプレビューを画面外へ書き戻してしまう不具合を修正 - モーダル名を「3D Text Editor」に、設定ボタンを「⚙ 3D設定」に変更し、テキスト初期値を設定してコマ配置直後から内容を確認できるようにした - ヘルプ(ja/en/zh)・README 3言語・DEVLOGを更新 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
edebdb69a6
commit
c71c5f5331
@@ -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化して移植)。未パス化のテキスト要素(`<text>`)やビットマップ画像(`<image>`)を含む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で実行される」を調査。
|
||||
|
||||
@@ -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](#依存関係任意) 連携)
|
||||
|
||||
+1
-1
@@ -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))
|
||||
|
||||
+1
-1
@@ -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](#可选依赖) 联动)
|
||||
|
||||
+69
-9
@@ -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内に未パス化のテキスト要素(<text>)が含まれています。イラストソフトで「テキストをパスに変換」してから保存してください。',
|
||||
'layout.text3dSvgImageNodeWarning': 'SVG内にビットマップ画像(<image>)が含まれています。画像ではなくベクターパスのみが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 (<text>). Convert text to paths in your vector editor before saving.',
|
||||
'layout.text3dSvgImageNodeWarning': 'The SVG contains raster images (<image>). 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中包含未转换为路径的文本元素(<text>)。请在矢量图软件中将文字转换为路径后再保存。',
|
||||
'layout.text3dSvgImageNodeWarning': 'SVG中包含位图图像(<image>)。只有矢量路径会被立体化,图像不会。',
|
||||
'layout.text3dSvgNoPathWarning': '未找到有效的矢量路径。请在矢量图软件中将文字/描边转换为路径后再保存。',
|
||||
'layout.text3dSvgParseFailed': 'SVG解析失败。文件可能已损坏或格式不受支持。',
|
||||
'layout.text3dSvgBevelTooLargeWarning': '倒角(厚度/大小)相对于该形状的细窄部分过大,已自动禁用倒角并重新渲染。请在⚙设置中调小倒角数值后再启用。',
|
||||
'layout.text3dLightSection': '灯光',
|
||||
'layout.text3dCameraSection': '摄像机',
|
||||
'layout.text3dLightIntensityLabel': '强度:',
|
||||
|
||||
+32
-48
@@ -551,24 +551,6 @@ class ImageTab {
|
||||
sel.innerHTML = list.map(f => `<option value="${f}" ${f === current ? "selected" : ""}>${f}</option>`).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 = `<option value="${current}">${t("image.fontListEmpty", current)}</option>`;
|
||||
return;
|
||||
}
|
||||
const list = families.includes(current) ? families : [current, ...families];
|
||||
sel.innerHTML = list.map(f => `<option value="${f}" ${f === current ? "selected" : ""}>${f}</option>`).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 = `
|
||||
<div class="ie-opt-group">
|
||||
<textarea id="ie-text3d-input" rows="1" style="width:120px;font-size:12px;resize:vertical;" placeholder="${t("layout.text3dTextPlaceholder")}">${t3.text}</textarea>
|
||||
</div>
|
||||
<div class="ie-opt-group" style="gap:2px;">
|
||||
<button class="it-btn it-btn-sm ${t3.fontSource === "google" ? "ie-opt-active" : ""}" id="ie-text3d-fsrc-google">${t("layout.text3dFontSourceGoogle")}</button>
|
||||
<button class="it-btn it-btn-sm ${t3.fontSource === "system" ? "ie-opt-active" : ""}" id="ie-text3d-fsrc-system">${t("layout.text3dFontSourceSystem")}</button>
|
||||
<select id="ie-text3d-font" class="ie-opt-select" style="min-width:100px;">
|
||||
<option value="${t3.fontFamily}">${t3.fontFamily}</option>
|
||||
</select>
|
||||
<button class="it-btn it-btn-sm ${!isSvg ? "ie-opt-active" : ""}" id="ie-text3d-mode-text">${t("layout.text3dModeText")}</button>
|
||||
<button class="it-btn it-btn-sm ${isSvg ? "ie-opt-active" : ""}" id="ie-text3d-mode-svg">${t("layout.text3dModeSvg")}</button>
|
||||
</div>
|
||||
${!isSvg ? `` : `
|
||||
<div class="ie-opt-group" style="gap:4px;">
|
||||
<input type="file" id="ie-text3d-svg-file" accept=".svg,image/svg+xml" style="font-size:11px;max-width:120px;">
|
||||
<span id="ie-text3d-svg-filename" style="font-size:11px;color:var(--text-secondary);">${t3.svgFileName || t("layout.text3dSvgFileNoneLabel")}</span>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">${t("layout.text3dSvgSizeLabel")}</label>
|
||||
<input type="range" id="ie-text3d-svg-size" min="0.3" max="4.0" step="0.1" value="${t3.svgSize}" style="width:60px;">
|
||||
</div>
|
||||
`}
|
||||
<div class="ie-opt-group">
|
||||
<label style="font-size:11px;">${t("layout.text3dDepthLabel")}</label>
|
||||
<input type="range" id="ie-text3d-depth" min="0.02" max="1.0" step="0.01" value="${t3.depth}" style="width:60px;">
|
||||
@@ -1038,11 +1025,6 @@ class ImageTab {
|
||||
<input type="checkbox" id="ie-text3d-bevel" ${t3.bevelEnabled ? "checked" : ""}>
|
||||
<label for="ie-text3d-bevel">${t("layout.text3dBevelLabel")}</label>
|
||||
</div>
|
||||
<div class="ie-opt-group" style="gap:2px;">
|
||||
<button class="it-btn it-btn-sm ${t3.align === "left" ? "ie-opt-active" : ""}" id="ie-text3d-align-left">${t("layout.text3dAlignLeft")}</button>
|
||||
<button class="it-btn it-btn-sm ${t3.align === "center" ? "ie-opt-active" : ""}" id="ie-text3d-align-center">${t("layout.text3dAlignCenter")}</button>
|
||||
<button class="it-btn it-btn-sm ${t3.align === "right" ? "ie-opt-active" : ""}" id="ie-text3d-align-right">${t("layout.text3dAlignRight")}</button>
|
||||
</div>
|
||||
<div class="ie-opt-group">
|
||||
<button class="it-btn it-btn-sm" id="ie-text3d-reset-camera-btn" title="${t("layout.text3dResetCameraTitle")}">RC</button>
|
||||
<button class="it-btn it-btn-sm" id="ie-text3d-settings-btn" title="${t("layout.text3dSettingsBtnTitle")}">${t("layout.text3dSettingsBtn")}</button>
|
||||
@@ -1052,27 +1034,29 @@ class ImageTab {
|
||||
<button class="it-btn it-btn-sm" id="ie-text3d-cancel-btn">${t("image.text3dCancel")}</button>
|
||||
</div>
|
||||
`;
|
||||
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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -104,12 +104,27 @@ const state = {
|
||||
fontFamily: 'BIZ UDPGothic',
|
||||
resizeObserver: null, // SVGリサイズ監視
|
||||
reeditImageEl: null, // 再編集対象の既存<image>要素(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,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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);
|
||||
|
||||
+244
-26
@@ -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) {
|
||||
|
||||
@@ -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<string[]> — 指定ソースのフォント一覧
|
||||
|
||||
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);
|
||||
|
||||
+7
-4
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+13
-10
@@ -708,11 +708,18 @@
|
||||
<!-- 3Dテキストサブタブ -->
|
||||
<div id="subtab-text3d" class="subtab-content" style="display:none; flex:1; padding:0; margin:0;">
|
||||
<div style="display:flex; flex-wrap:wrap; gap:6px; align-items:center;">
|
||||
<textarea id="text3d-text-input" rows="1" style="width:140px; resize:vertical; font-size:12px;" data-i18n-placeholder="layout.text3dTextPlaceholder" placeholder="テキストを入力"></textarea>
|
||||
<!-- フォントソース切替 -->
|
||||
<button id="text3d-fsrc-google" class="btn small active" style="white-space:nowrap;" data-i18n="layout.text3dFontSourceGoogle" title="Google Fontsから選択">Google</button>
|
||||
<button id="text3d-fsrc-system" class="btn small" style="white-space:nowrap;" data-i18n="layout.text3dFontSourceSystem" title="このPCにインストール済みのフォントから選択">システム</button>
|
||||
<select id="text3d-font-family" style="max-width:140px; font-size:12px;"></select>
|
||||
<!-- モード切替(テキスト/SVG立体化) -->
|
||||
<button id="text3d-mode-text" class="btn small active" style="white-space:nowrap;" data-i18n="layout.text3dModeText" title="文字を3D化します">テキスト</button>
|
||||
<button id="text3d-mode-svg" class="btn small" style="white-space:nowrap;" data-i18n="layout.text3dModeSvg" title="SVGファイルのパスを3D化します">SVG</button>
|
||||
<!-- 区切り -->
|
||||
<div style="border-left:1px solid var(--border-color); height:20px;"></div>
|
||||
<!-- SVGモード専用コントロール -->
|
||||
<div id="text3d-svg-controls" style="display:none; flex-wrap:wrap; gap:6px; align-items:center;">
|
||||
<input type="file" id="text3d-svg-file" accept=".svg,image/svg+xml" style="font-size:11px; max-width:160px;" />
|
||||
<span id="text3d-svg-filename" style="font-size:11px; color:var(--text-secondary); white-space:nowrap;" data-i18n="layout.text3dSvgFileNoneLabel">ファイル未選択</span>
|
||||
<label style="font-size:11px; white-space:nowrap;" data-i18n="layout.text3dSvgSizeLabel" title="SVGの大きさ">サイズ:</label>
|
||||
<input type="range" id="text3d-svg-size" min="0.3" max="4.0" step="0.1" value="1.5" style="width:60px;" />
|
||||
</div>
|
||||
<!-- 区切り -->
|
||||
<div style="border-left:1px solid var(--border-color); height:20px;"></div>
|
||||
<!-- 押し出し・ベベル -->
|
||||
@@ -720,11 +727,7 @@
|
||||
<input type="range" id="text3d-depth" min="0.02" max="1.0" step="0.01" value="0.15" style="width:60px;" />
|
||||
<label style="font-size:11px; white-space:nowrap; display:flex; align-items:center; gap:2px;"><input type="checkbox" id="text3d-bevel" /><span data-i18n="layout.text3dBevelLabel">面取り</span></label>
|
||||
<!-- ライト・マテリアル設定モーダル -->
|
||||
<button id="text3d-settings-btn" class="btn small secondary" style="white-space:nowrap;" data-i18n="layout.text3dSettingsBtn" data-i18n-title="layout.text3dSettingsBtnTitle" title="ライト・マテリアル(色/質感)・ズーム操作方式を設定します">⚙ 設定</button>
|
||||
<!-- 整列 -->
|
||||
<button id="text3d-align-left" class="btn small" style="white-space:nowrap;" data-i18n="layout.text3dAlignLeft" title="左揃え">左</button>
|
||||
<button id="text3d-align-center" class="btn small active" style="white-space:nowrap;" data-i18n="layout.text3dAlignCenter" title="中央揃え">中央</button>
|
||||
<button id="text3d-align-right" class="btn small" style="white-space:nowrap;" data-i18n="layout.text3dAlignRight" title="右揃え">右</button>
|
||||
<button id="text3d-settings-btn" class="btn small secondary" style="white-space:nowrap;" data-i18n="layout.text3dSettingsBtn" data-i18n-title="layout.text3dSettingsBtnTitle" title="ライト・マテリアル(色/質感)・テキスト編集・ズーム操作方式を設定します">⚙ 3D設定</button>
|
||||
<!-- 区切り -->
|
||||
<div style="border-left:1px solid var(--border-color); height:20px;"></div>
|
||||
<!-- 配置・確定・キャンセル・再編集 -->
|
||||
|
||||
Reference in New Issue
Block a user