Files
ketle-man-comfyui-comic-cre…/static/js/image-tab/Text3DTool.js
T
ketle-manandClaude Sonnet 5 89c9f4d390 v1.40.0: Imageタブのキャンバス外操作性向上・3Dテキスト確定時解像度改善、グラデーション操作のペンタブ対応
- Imageタブ(SelectTool): キャンバス外へ拡張された操作用オーバーレイ(ie-canvas-select-overlay)を追加し、枠外オブジェクトも選択・移動・回転・リサイズを可能に
- Imageタブ(Text3DTool): 確定時キャプチャ解像度をキャンバス座標系での配置サイズに応じた動的計算に変更し、ズーム状態によらずジャギーを軽減
- Imageタブ(FillTool/ShapeTool): グラデーションランプ・方向ダイヤルのイベントをPointer Events化し、ペンタブレットでのスライド操作に対応
- キャンバス内透過エリアへチェッカー背景描画を追加

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-09 12:46:02 +09:00

400 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Text3DTool.js — Imageタブ用 3Dテキストツール
//
// TextTool.js と異なり、textareaオーバーレイの代わりにThree.jsの3Dビューを
// ie-canvas-container 上にオーバーレイ表示する。コントロール(テキスト内容・
// フォント・押し出し量等)は image-tab.js 側の _renderToolOptions("text3d") が
// 用意し、このクラスのプロパティを直接読み書きする。
//
// 座標変換は TextTool._showOverlay と同じ考え方(hostCanvas.getBoundingClientRect()
// から実表示スケールを求める)で、ie-canvas-container の transform:scale(zoom) にも追従する。
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(座標変換の基準にのみ使う)
this._wrapper = null;
this._canvas = null;
this._editor = null;
this._onCommit = null;
this._onCancel = null;
this._commitX = 0;
this._commitY = 0;
this._displayW = DEFAULT_CSS_W;
this._displayH = DEFAULT_CSS_H;
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.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; }
onCommit(fn) { this._onCommit = fn; }
onCancel(fn) { this._onCancel = fn; }
// TextTool等と同じインターフェース(image-tab.jsの_setActiveTool/_activateCurrentToolから呼ばれる)
activate() {}
deactivate() { this.close(); }
get isOpen() { return !!(this._wrapper && this._wrapper.style.opacity === '1'); }
// クリック位置(canvasX, canvasY)を中心にオーバーレイを開く。
// initialPropsを渡すと再編集モード(既存の3Dテキストの値を復元)になる。
open(canvasX, canvasY, initialProps = null) {
const cv = this.hostCanvas;
if (!cv) return;
const container = cv.parentElement;
if (!container) return;
const rect = cv.getBoundingClientRect();
const scaleX = rect.width / cv.width;
const scaleY = rect.height / cv.height;
const cssX = canvasX * scaleX;
const cssY = canvasY * scaleY;
if (!this._wrapper) {
const wrapper = document.createElement('div');
wrapper.className = 'ie-text3d-overlay';
wrapper.style.cssText =
'position:absolute; z-index:100; overflow:hidden; opacity:0; pointer-events:none; ' +
'border:2px solid #40a0e0; box-sizing:border-box;';
const canvas = document.createElement('canvas');
canvas.style.cssText = 'position:absolute; top:0; left:0; width:100%; height:100%;';
wrapper.appendChild(canvas);
this._wrapper = wrapper;
this._canvas = canvas;
}
if (!container.contains(this._wrapper)) container.appendChild(this._wrapper);
// オーバーレイの画面上の表示サイズは常にDEFAULT_CSS_W×H(px)で固定する。
// 以前はwrapperのCSSサイズにscaleX/Y(ズーム倍率相当)を掛けていたため、大きいキャンバスを
// ズームアウトして編集していると操作用ウィンドウが縮んでしまい操作困難になっていた。
// 代わりに、キャンバス座標系での配置サイズ(displayW/displayH)をズーム倍率の逆数で求め、
// 画面上は常に同じ大きさに見えるようにする(ズームアウト中はキャンバス上でより広い範囲を
// 占める)。極端なズームアウトで際限なく広がらないよう、キャンバス実サイズの60%を上限にクランプする。
const invScaleX = scaleX > 0 ? 1 / scaleX : 1;
const invScaleY = scaleY > 0 ? 1 / scaleY : 1;
const maxW = cv.width * 0.6;
const maxH = cv.height * 0.6;
const clamp = Math.min(1, maxW / (DEFAULT_CSS_W * invScaleX), maxH / (DEFAULT_CSS_H * invScaleY));
this._displayW = Math.round(DEFAULT_CSS_W * invScaleX * clamp);
this._displayH = Math.round(DEFAULT_CSS_H * invScaleY * clamp);
const left = Math.round(cssX - DEFAULT_CSS_W / 2);
const top = Math.round(cssY - DEFAULT_CSS_H / 2);
this._wrapper.style.left = `${left}px`;
this._wrapper.style.top = `${top}px`;
this._wrapper.style.width = `${DEFAULT_CSS_W}px`;
this._wrapper.style.height = `${DEFAULT_CSS_H}px`;
// レイヤー確定時の配置座標(canvas座標系)は中心基準で左上に変換
this._commitX = Math.round(canvasX - this._displayW / 2);
this._commitY = Math.round(canvasY - this._displayH / 2);
const dpr = window.devicePixelRatio || 1;
const cw = Math.round(DEFAULT_CSS_W * dpr);
const ch = Math.round(DEFAULT_CSS_H * dpr);
if (this._canvas.width !== cw || this._canvas.height !== ch) {
this._canvas.width = cw;
this._canvas.height = ch;
}
if (initialProps) {
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.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;
if (typeof initFn !== 'function') {
setTimeout(() => this.open(canvasX, canvasY, initialProps), 300);
return;
}
if (!this._editor) {
this._editor = initFn(this._canvas, {});
}
this._editor.resizeRenderer(cw, ch);
this._applyAllPropsToEditor();
loadFontForText3d(this.fontFamily, this.fontSource).then(font => {
this._editor.setFont(font, this.fontFamily, this.fontSource);
this._editor.setText(this.text);
}).catch(err => {
console.error('[text3d] フォント読込失敗:', err);
});
this._editor.startLoop();
this._wrapper.style.opacity = '1';
this._wrapper.style.pointerEvents = 'auto';
}
// 現在のプロパティ値をエディタへ一括反映する(フォント/テキスト以外)
_applyAllPropsToEditor() {
if (!this._editor) return;
this._editor.setDepth(this.depth);
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);
}
// フォント変更時(ソース/ファミリー変更)に呼ぶ
async reloadFont() {
if (!this._editor) return;
try {
const font = await loadFontForText3d(this.fontFamily, this.fontSource);
this._editor.setFont(font, this.fontFamily, this.fontSource);
} catch (err) {
console.error('[text3d] フォント読込失敗:', err);
throw err;
}
}
// オプションバー(image-tab.js側のUI)からライブプレビューを更新するためのセッター群。
// _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, { 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へ直接委譲する)。
openSettingsModal() {
if (!this._editor) return;
const editor = this._editor;
openText3DSettingsModal({
getLights: () => editor.getLights(),
setLightColor: (id, hex) => editor.setLightColor(id, hex),
setLightIntensity: (id, v) => editor.setLightIntensity(id, v),
setLightPosition: (id, x, y, z) => editor.setLightPosition(id, x, y, z),
getZoomMode: () => editor.getZoomMode(),
setZoomMode: (m) => editor.setZoomMode(m),
resetCamera: () => editor.resetCamera(),
resizeRenderer: (w, h) => editor.resizeRenderer(w, h),
getSuperSample: () => editor.getSuperSample(),
setSuperSample: (v) => editor.setSuperSample(v),
getParams: () => editor.getParams(),
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),
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) {
this.fontFamily = family;
this.fontSource = source;
await this.reloadFont();
this._editor?.setText(this.text);
}
commit() {
if (!this._editor) return;
const props = {
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,
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,
};
// 確定(PNG焼き込み)はプレビュー解像度のままだと、Imageタブ側でSelectToolを使って
// 拡大配置した際にジャギーが目立つ。プレビュー用WebGLキャンバスは常にDEFAULT_CSS_W×H×dpr
// 固定の軽量解像度で動いているため、固定倍率をそのまま掛けるだけだとキャンバス座標系での
// 実際の配置サイズ(displayW/displayH、ズームに応じて可変)によっては密度が足りず粗さが残る
// ケースがあった。displayW/displayHを基準に「配置後1pxあたり最低TARGET_DENSITY px」を
// 確保する解像度を動的に計算し、キャプチャ直前だけ一時的にその解像度でレンダリングしてから
// 元のプレビュー解像度に戻す(メモリ・処理負荷を抑えるためMAX_CAPTUREで上限も設ける)
const TARGET_DENSITY = 2.5;
const MAX_CAPTURE = 4096;
const previewW = this._canvas.width;
const previewH = this._canvas.height;
const captureW = Math.min(MAX_CAPTURE, Math.max(previewW, Math.round(this._displayW * TARGET_DENSITY)));
const captureH = Math.min(MAX_CAPTURE, Math.max(previewH, Math.round(this._displayH * TARGET_DENSITY)));
this._editor.resizeRenderer(captureW, captureH);
const dataUrl = this._editor.capture();
const contentW = this._canvas.width;
const contentH = this._canvas.height;
this._editor.resizeRenderer(previewW, previewH);
const displayW = this._displayW;
const displayH = this._displayH;
const x = this._commitX;
const y = this._commitY;
this.close();
if (this._onCommit) this._onCommit({ dataUrl, contentW, contentH, displayW, displayH, x, y, props });
}
cancel() {
this.close();
if (this._onCancel) this._onCancel();
}
close() {
if (this._editor) this._editor.stopLoop();
if (this._wrapper) {
this._wrapper.style.opacity = '0';
this._wrapper.style.pointerEvents = 'none';
}
}
}