描いたところが立体になる「SlimePainter」



【更新履歴】

 ・2026/3/9 バージョン1.0公開。
 ・2026/3/9 バージョン1.1公開。
 ・2026/8/1 バージョン1.2公開。(描くと同時に立体化)
 ・2026/8/1 バージョン1.3公開。(筆跡ごとに右クリックで色変更)

画像

・ダウンロードされる方はこちら。↓


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Slime Painter 1.3</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.17/paper-full.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/exporters/GLTFExporter.js"></script>
    <style>
        :root { --bg-dark: #0d0f12; --bg-panel: #171a1f; --bg-hover: #1e2229; --text-main: #c8cdd5; --text-muted: #606878; --accent: #4fc3f7; --border: #0d0f12; }
        body { margin: 0; padding: 0; display: flex; flex-direction: column; height: 100vh; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 14px; overflow: hidden; touch-action: none;}
        
        /* 共通UI */
        #menubar { display: flex; background-color: var(--bg-dark); padding: 5px 10px; border-bottom: 1px solid #111; user-select: none; }
        .menu-item { position: relative; padding: 5px 10px; cursor: pointer; border-radius: 3px; }
        .menu-item:hover { background-color: var(--bg-hover); }
        .dropdown { display: none; position: absolute; top: 100%; left: 0; background-color: var(--bg-panel); border: 1px solid #111; box-shadow: 0 4px 6px rgba(0,0,0,0.3); z-index: 100; min-width: 180px; }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item { padding: 8px 15px; cursor: pointer; display: flex; justify-content: space-between; }
        .dropdown-item:hover { background-color: var(--accent); color: white; }

        /* ツールバー */
        #toolbar { display: flex; background-color: var(--bg-panel); padding: 5px 10px; border-bottom: 1px solid var(--border); gap: 5px; align-items: center; }
        .tool-btn { background: transparent; border: 1px solid transparent; color: var(--text-main); font-size: 1.2em; padding: 5px 10px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 5px; }
        .tool-btn:hover { background-color: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); }
        .tool-btn.active { background-color: var(--accent); color: white; border-color: var(--accent); }
        .tool-btn:disabled { opacity: 0.3; cursor: not-allowed; }
        .tool-btn:disabled:hover { background-color: transparent; border-color: transparent; }
        .toolbar-sep { width: 1px; height: 24px; background-color: #555; margin: 0 10px; }

        #main { display: flex; flex-grow: 1; overflow: hidden; }

        /* サイドバーとタブ */
        #sidebar { width: 320px; flex-shrink: 0; background-color: var(--bg-panel); display: flex; flex-direction: column; border-right: 1px solid var(--border); z-index: 20;}
        #tabs { display: flex; border-bottom: 1px solid var(--border); background-color: var(--bg-dark); }
        .tab-btn { flex: 1; padding: 10px 0; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; color: var(--text-muted); font-size: 0.9em; }
        .tab-btn.active { color: var(--text-main); border-bottom-color: var(--accent); background-color: var(--bg-panel); font-weight: bold;}
        .tab-content { display: none; flex-grow: 1; padding: 15px; overflow-y: auto; }
        .tab-content.active { display: flex; flex-direction: column; gap: 15px; }

        /* プロパティパネル */
        .prop-group { display: flex; flex-direction: column; gap: 5px; background: rgba(0,0,0,0.1); padding: 10px; border-radius: 5px; border: 1px solid rgba(255,255,255,0.05); }
        .prop-group label { font-size: 0.9em; display: flex; justify-content: space-between; }
        input[type="range"] { width: 100%; accent-color: var(--accent); }
        select { background-color: #222; color: #fff; border: 1px solid #555; padding: 4px; border-radius: 3px; outline: none;}
        
        .color-picker-wrap { display: flex; align-items: center; gap: 10px; width: 100%; }
        .color-drag-handle { width: 100%; height: 30px; border-radius: 4px; border: 1px solid #555; cursor: pointer; display: flex; justify-content: center; align-items: center; box-shadow: inset 0 0 4px rgba(0,0,0,0.3); transition: transform 0.1s;}
        .color-drag-handle:hover { transform: scale(1.02); border-color: #aaa; }

        #palette-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-top: 5px; }
        .palette-cell { height: 25px; border-radius: 3px; border: 1px solid #111; cursor: pointer; box-sizing: border-box; }
        .palette-cell:hover { border-color: #fff; transform: scale(1.05); }

        button.action-btn { background-color: var(--accent); color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; width: 100%; margin-top: 5px; font-weight: bold; }
        button.action-btn:hover { filter: brightness(1.2); }
        button.action-btn-green { background: linear-gradient(135deg, #1a7fa0, #0a4f6a); }
        button.action-btn-outline { background-color: transparent; border: 1px solid var(--text-muted); color: var(--text-main); }
        button.action-btn-outline:hover { background-color: rgba(255,255,255,0.1); }

        /* リスト項目 */
        .list-item { padding: 6px 8px; border-radius: 4px; cursor: pointer; margin-bottom: 4px; border: 1px solid transparent; background-color: rgba(0,0,0,0.2); display: flex; align-items: center; gap: 10px; transition: background-color 0.1s; }
        .list-item:hover { background-color: rgba(255,255,255,0.05); }
        .list-item.selected { background-color: rgba(114, 137, 218, 0.4); border-color: var(--accent); color: white;}
        .list-item.drag-over-top { border-top: 2px solid #4fc3f7 !important; }
        .list-item.drag-over-bottom { border-bottom: 2px solid #4fc3f7 !important; }
        .list-item input[type="checkbox"] { cursor: pointer; width: 16px; height: 16px; accent-color: var(--accent); }
        .list-item .item-name { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: none;}

        /* キャンバス領域 */
        #workspace { flex-grow: 1; background-color: #0a0c0f; position: relative; display: flex; overflow: auto; padding: 40px; box-sizing: border-box; align-items: flex-start; justify-content: flex-start; background-image: radial-gradient(circle at 30% 20%, #111820 0%, #0a0c0f 70%);}
        #canvas-wrap { position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.8); flex-shrink: 0; margin: auto; background-color: transparent;}
        #checkerboard { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: linear-gradient(45deg, #111318 25%, transparent 25%, transparent 75%, #111318 75%, #111318), linear-gradient(45deg, #111318 25%, transparent 25%, transparent 75%, #111318 75%, #111318); background-size: 20px 20px; background-position: 0 0, 10px 10px; z-index: 0; }
        canvas { display: block; background-color: transparent; z-index: 1; position: absolute; top: 0; left: 0; touch-action: none;}

        #status-bar { position: fixed; bottom: 10px; left: 340px; background: rgba(0,0,0,0.7); padding: 5px 10px; border-radius: 5px; z-index: 10; font-size: 0.9em; pointer-events: none;}
        
        /* ポップアップ・ダイアログ */
        .popup-menu { position: fixed; background-color: var(--bg-panel); border: 1px solid #111; box-shadow: 0 4px 6px rgba(0,0,0,0.5); z-index: 1000; border-radius: 4px; padding: 5px 0; display: none; min-width: 150px;}
        .cm-item { padding: 8px 15px; cursor: pointer; font-size: 0.9em; }
        .cm-item:hover { background-color: var(--accent); color: white; }
        .cm-sep { height: 1px; background-color: #444; margin: 4px 0; }

        #palette-dialog { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:2000; justify-content:center; align-items:center; }

        #toast { position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%) translateY(10px); background: rgba(20,22,26,0.95); color: #fff; padding: 8px 18px; border-radius: 20px; font-size: 0.85em; z-index: 3000; opacity: 0; pointer-events: none; transition: opacity 0.2s ease, transform 0.2s ease; border: 1px solid rgba(255,255,255,0.15); box-shadow: 0 4px 12px rgba(0,0,0,0.4); }
        #toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

        #quick-palette-popup { padding: 10px; min-width: 220px; }
        #quick-palette-popup .qp-hint { font-size: 0.75em; color: var(--text-muted); margin-bottom: 8px; }
        #quick-palette-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; }
        #quick-palette-grid .palette-cell { height: 32px; position: relative; }
        .texture-badge { position: absolute; right: 2px; bottom: 0px; font-size: 12px; text-shadow: 0 0 2px #000, 0 0 3px #000; pointer-events: none; }
    </style>
</head>
<body>

    <div id="menubar">
        <div class="menu-item">ファイル
            <div class="dropdown">
                <div class="dropdown-item" onclick="fileAction('new')">新規作成</div>
                <div class="dropdown-item" onclick="fileAction('open')">開く... (JSON)</div>
                <div class="dropdown-item" onclick="fileAction('save')">上書き保存 (JSON) <span style="color:#888;">Ctrl+S</span></div>
                <div class="dropdown-item" onclick="fileAction('png')">PNG出力</div>
                <div class="dropdown-item" onclick="fileAction('svg')">SVG出力</div>
                <div class="cm-sep"></div>
                <div class="dropdown-item" onclick="fileAction('glb')" style="color:var(--accent); font-weight:bold;">GLB出力 (3Dモデル)</div>
                <div class="cm-sep"></div>
                <div class="dropdown-item" onclick="fileAction('resize')">キャンバスサイズ変更</div>
            </div>
        </div>
        <div class="menu-item">表示
            <div class="dropdown">
                <div class="dropdown-item" onclick="changeZoom(1.2)">ズームイン <span style="color:#888;">PageUp</span></div>
                <div class="dropdown-item" onclick="changeZoom(1 / 1.2)">ズームアウト <span style="color:#888;">PageDown</span></div>
                <div class="dropdown-item" onclick="changeZoom(null)">100%表示</div>
            </div>
        </div>
        <div class="menu-item">設定
            <div class="dropdown">
                <label class="dropdown-item" style="cursor:pointer; align-items:center; gap:8px;" title="OFFにするとCPUで描画します(重い場合や環境非対応時はOFFにしてください)">
                    <span>GPU (WebGL) を優先してレンダー</span>
                    <input type="checkbox" id="uiUseWebGL" checked style="width:16px; height:16px; margin:0;">
                </label>
            </div>
        </div>
    </div>

    <div id="toolbar">
        <button class="tool-btn active" onclick="setMode('draw')" id="btn-draw" title="スポット領域をドローしていく">🖊️ 描く</button>
        <button class="tool-btn" onclick="setMode('erase')" id="btn-erase" title="スポット領域を消していく">🧽 消す</button>
        <button class="tool-btn" onclick="setMode('fill')" id="btn-fill" title="囲まれた領域を塗りつぶす">🎨 塗る</button>
        <div class="toolbar-sep"></div>
        <button class="tool-btn" onclick="setMode('select')" id="btn-select" title="スポットを選択・複数選択">👆 選択</button>
        <button class="tool-btn" onclick="setMode('move')" id="btn-move" title="ドラッグで移動">🖐️ 移動</button>
        <button class="tool-btn" onclick="setMode('rotate')" id="btn-rotate" title="ドラッグで回転">🔄 回転</button>
        <button class="tool-btn" onclick="setMode('scale')" id="btn-scale" title="ドラッグで拡縮">📐 拡縮</button>
        <div class="toolbar-sep"></div>
        <button class="tool-btn" onclick="setMode('adjust')" id="btn-adjust" title="ベジェ曲線で調整">✒️ 調整</button>
        <div class="toolbar-sep"></div>
        <button class="tool-btn" onclick="execAction('undo')" id="btn-undo" title="元に戻す (Ctrl+Z)">⏪️</button>
        <button class="tool-btn" onclick="execAction('redo')" id="btn-redo" title="やり直す (Ctrl+Y)">⏩️</button>
    </div>

    <div id="main">
        <div id="sidebar">
            <div id="tabs">
                <div class="tab-btn active" data-tab="tab-layers" onclick="switchTab('tab-layers')">レイヤー</div>
                <div class="tab-btn" data-tab="tab-spots" onclick="switchTab('tab-spots')">スポット</div>
                <div class="tab-btn" data-tab="tab-prop" onclick="switchTab('tab-prop')">プロパティ</div>
            </div>

            <div id="tab-layers" class="tab-content active" style="position: relative;">
                <p style="color:#888; font-size:12px; margin:0;">※D&Dで順序入替 / 右クリックでメニュー表示</p>
                <div id="layer-list-container" style="flex-grow: 1;"></div>
            </div>

            <div id="tab-spots" class="tab-content" style="position: relative;">
                <p style="color:#888; font-size:12px; margin:0;">※D&Dで順序入替 / 右クリックでメニュー表示</p>
                <div id="spot-list-container" style="flex-grow: 1;"></div>
            </div>

            <div id="tab-prop" class="tab-content">
                <div class="prop-group">
                    <strong>ドロー設定</strong>
                    <label>ブラシサイズ: <span id="valBrush">10</span>px</label>
                    <input type="range" id="uiBrushSize" min="1" max="50" value="10">
                </div>
                
                <hr style="border-color: #444; width: 100%; margin: 5px 0;">

                <div id="spot-properties" style="opacity: 0.5; pointer-events: none;">
                    <strong>選択中スポットの質感</strong>

                    <div class="prop-group" style="background: rgba(79, 195, 247, 0.1); border-color: rgba(79, 195, 247, 0.3);">
                        <label style="color: var(--accent); font-weight: bold;">表面テクスチャ・模様</label>
                        <div style="display:flex; gap:5px; margin-top: 5px;">
                            <input type="file" id="uiTextureImage" accept="image/*" style="display:none;" onchange="handleTextureUpload(event)">
                            <button class="action-btn-outline" onclick="document.getElementById('uiTextureImage').click()" style="flex:1; padding:6px; font-size:0.9em;">📂 画像を選択</button>
                            <button class="action-btn-outline" onclick="clearTexture()" style="width:30px; padding:6px; font-size:0.9em;" title="画像をクリア">✖</button>
                        </div>
                        <img id="texturePreview" style="display:none; width:100%; height:auto; max-height: 80px; object-fit: contain; margin-top:5px; border-radius:4px; border:1px solid #555; background: #000;">
                        
                        <label style="margin-top: 5px;">内蔵パターン:</label>
                        <select id="uiPattern" onchange="updateProps({target: this}); saveState();">
                            <option value="none">なし (単色/画像のみ)</option>
                            <option value="stripe">ストライプ</option>
                            <option value="checker">チェッカーボード</option>
                            <option value="dots">水玉模様</option>
                            <option value="noise">クラウドノイズ</option>
                        </select>
                    </div>

                    <div class="prop-group">
                        <label>反射質感タイプ:</label>
                        <select id="uiTexture" onchange="updateProps({target: this}); saveState();">
                            <option value="slime">🟢 スライム</option>
                            <option value="waterdrop">💧 水滴</option>
                            <option value="liquidmetal">🌊 流体金属</option>
                            <option value="crystal">💎 クリスタル・岩</option>
                            <option value="cloud">☁️ フワフワ雲</option>
                            <option value="pearl" selected>🫧 パール</option>
                            <option value="water">💧 水 (旧)</option>
                            <option value="sticker">🏷️ モコモコシール</option>
                            <option value="chrome">🪞 クロムメタル</option>
                            <option value="gold">✨ ゴールド</option>
                            <option value="mercury">🌊 マーキュリー (旧)</option>
                            <option value="brushed">🔩 ブラッシュドメタル</option>
                            <option value="none">なし (フラット)</option>
                        </select>
                    </div>


                    <div class="prop-group">
                        <label>ハイライト色 / パターン色</label>
                        <div class="color-picker-wrap">
                            <input type="color" id="uiCenterCol" value="#ffffff" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
                            <div id="dragHandleCenter" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiCenterCol')" ondrop="handleColorDrop(event, 'uiCenterCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiCenterCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #ffffff;"></div>
                        </div>
                    </div>
                    
                    <div class="prop-group">
                        <label>ベース色</label>
                        <div class="color-picker-wrap">
                            <input type="color" id="uiMidCol" value="#8ab4cc" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
                            <div id="dragHandleMid" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiMidCol')" ondrop="handleColorDrop(event, 'uiMidCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiMidCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #8ab4cc;"></div>
                        </div>
                        <button class="action-btn action-btn-outline" style="padding: 4px; font-size: 0.8em; margin-top: 2px;" onclick="generateMidColor()">中間色を自動生成</button>
                    </div>

                    <div class="prop-group">
                        <label>影・エッジ色</label>
                        <div class="color-picker-wrap">
                            <input type="color" id="uiEdgeCol" value="#1a2a3a" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
                            <div id="dragHandleEdge" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiEdgeCol')" ondrop="handleColorDrop(event, 'uiEdgeCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiEdgeCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #1a2a3a;"></div>
                        </div>
                    </div>
                    
                    <div style="margin-top: 5px;">
                        <div style="display:flex; justify-content:space-between; align-items:center;">
                            <label style="font-size: 0.8em; color: var(--text-muted);">スタイルパレット</label>
                            <div style="display:flex; gap: 5px;">
                                <button class="action-btn-outline" style="padding:2px 5px; font-size:0.7em; margin:0; width:auto;" onclick="addCurrentToPalette()" title="現在のプロパティをパレットに保存">+ 追加</button>
                                <button class="action-btn-outline" style="padding:2px 5px; font-size:0.7em; margin:0; width:auto;" onclick="openPaletteLibrary()">もっと見る...</button>
                            </div>
                        </div>
                        <div id="palette-grid"></div>
                    </div>

                    <div class="prop-group" style="margin-top: 10px;">
                        <label>光の方向 (角度): <span id="valLightAngle">135</span>°</label>
                        <input type="range" id="uiLightAngle" min="0" max="360" value="135" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>

                    <div class="prop-group">
                        <label>光の強さ: <span id="valLightIntensity">80</span>%</label>
                        <input type="range" id="uiLightIntensity" min="0" max="200" value="80" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>
                    
                    <div class="prop-group">
                        <label>丘の高さ (盛り上がり): <span id="valHillHeight">5</span></label>
                        <input type="range" id="uiHillHeight" min="0" max="100" value="5" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>

                    <div class="prop-group">
                        <label>エッジの鋭さ: <span id="valDepth">3</span></label>
                        <input type="range" id="uiDepth" min="0" max="50" value="3" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>

                    <div class="prop-group">
                        <label>反射の粗さ: <span id="valBlur">1</span></label>
                        <input type="range" id="uiBlur" min="0" max="80" value="1" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>

                    <div style="display: flex; gap: 5px; margin-top: 10px;">
                        <button class="action-btn action-btn-outline" style="flex: 1; padding: 8px 5px;" onclick="execAction('vectorize')" title="3D質感表示を一時OFFにして、下絵のベクター形状だけを表示します(重い時や形状だけ調整したい時に)">■ ベクター化</button>
                        <button class="action-btn action-btn-green" style="flex: 1.5; padding: 8px 5px; margin-top: 0;" onclick="execAction('render')" title="選択中のスポットを今すぐ3Dレンダリング(通常は描画すると自動で立体化されるので、手動更新用です)">💎 質感レンダー</button>
                    </div>
                </div>
            </div>
        </div>

        <div id="workspace" oncontextmenu="return false;">
            <div id="canvas-wrap">
                <div id="checkerboard"></div>
                <canvas id="myCanvas"></canvas>
            </div>
            <div id="status-bar">モード: <span id="mode-text" style="color:var(--accent); font-weight:bold;">描く</span> | ズーム: <span id="zoom-text">100%</span> | 空白ドラッグ/方向キー: スクロール</div>
        </div>
    </div>

    <div id="context-menu" class="popup-menu">
        <div class="cm-item" data-action="add" onclick="cmAction('add')">追加</div>
        <div class="cm-sep" data-action="sep1"></div>
        <div class="cm-item" data-action="cut" onclick="cmAction('cut')">カット (Ctrl+X)</div>
        <div class="cm-item" data-action="copy" onclick="cmAction('copy')">コピー (Ctrl+C)</div>
        <div class="cm-item" data-action="paste" onclick="cmAction('paste')">貼り付け (Ctrl+V)</div>
        <div class="cm-sep" data-action="sep2"></div>
        <div class="cm-item" data-action="rename" onclick="cmAction('rename')">名前の変更</div>
        <div class="cm-item" data-action="opacity" onclick="cmAction('opacity')">透明度の変更</div>
        <div class="cm-item" data-action="quickPalette" onclick="cmAction('quickPalette')">🎨 パレットから色・質感を変更</div>
        <div class="cm-sep" data-action="sep3"></div>
        <div class="cm-item" data-action="merge" onclick="cmAction('merge')">結合 (選択項目のみ)</div>
        <div class="cm-sep" data-action="sep_order"></div>
        <div class="cm-item" data-action="bringFront" onclick="cmAction('bringFront')">最前面へ移動</div>
        <div class="cm-item" data-action="bringForward" onclick="cmAction('bringForward')">前面へ移動</div>
        <div class="cm-item" data-action="sendBackward" onclick="cmAction('sendBackward')">背面へ移動</div>
        <div class="cm-item" data-action="sendBack" onclick="cmAction('sendBack')">最背面へ移動</div>
        <div class="cm-sep" data-action="sep4"></div>
        <div class="cm-item" data-action="delete" onclick="cmAction('delete')" style="color: #ff4444;">削除 (Del)</div>
    </div>

    <div id="adjust-menu" class="popup-menu">
        <div class="cm-item" id="am-add" onclick="adjustMenuAction('add')">隣接するポイントの中間に追加</div>
        <div class="cm-item" id="am-delete" onclick="adjustMenuAction('delete')" style="color: #ff4444;">選択中のポイントを削除</div>
    </div>

    <div id="palette-dialog">
        <div style="background:var(--bg-panel); padding:20px; border-radius:8px; width:80%; max-height:80%; display:flex; flex-direction:column; border:1px solid #555;">
            <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
                <h2 style="margin:0; font-size:1.2em;">パレットライブラリ</h2>
                <button onclick="document.getElementById('palette-dialog').style.display='none'" class="action-btn-outline" style="padding:5px 10px; width:auto; margin:0;">閉じる</button>
            </div>
            <div style="color:var(--text-muted); font-size:0.9em; margin-bottom:10px;">クリックすると選択中のスポットのプロパティに適用されます。</div>
            <div id="library-grid" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(80px, 1fr)); gap:10px; overflow-y:auto; flex-grow:1; padding:5px;"></div>
        </div>
    </div>

    <input type="file" id="fileLoader" style="display:none;" accept=".json">

    <div id="toast"></div>

    <div id="quick-palette-popup" class="popup-menu">
        <div class="qp-hint">クリックで選択中のスポットに適用</div>
        <div id="quick-palette-grid"></div>
    </div>


    <script>
        paper.settings.handleSize = 8;
        paper.setup('myCanvas');
        
        let baseWidth = 1000;
        let baseHeight = 800;
        let currentZoom = 1;

        let mode = 'draw'; 
        let brushRadius = 10;
        
        let appLayers = []; 
        let activeLayer = null;
        let selectedLayers = []; 
        let layerCounter = 0;

        let selectedSpots = []; 
        let spotCounter = 0;
        let draftQueue = [];
        let selectedStrokeItem = null; // 右クリックで個別選択した「筆跡」(パレット適用・ベジェ調整の対象)

        let clipboardData = null;
        let clipboardType = ''; 

        let selectionRect = null;
        let isPanning = false;
        let cmTargetType = ''; 
        let cmTargetItem = null;
        let lastContextMenuPos = { x: 0, y: 0 };

        let undoStack = [];
        let redoStack = [];
        
        let selectedSegments = [];

        // --- WebGL用共有レンダラー ---
        let sharedWebGLRenderer = null;
        function getWebGLRenderer() {
            if(!sharedWebGLRenderer && typeof THREE !== 'undefined') {
                sharedWebGLRenderer = new THREE.WebGLRenderer({ alpha: true, antialias: false });
            }
            return sharedWebGLRenderer;
        }

        window.changeZoom = function(factor) {
            let ws = document.getElementById('workspace');
            let scrollCenterX = ws.scrollLeft + ws.clientWidth / 2;
            let scrollCenterY = ws.scrollTop + ws.clientHeight / 2;
            
            let oldZoom = currentZoom;
            if (factor === null) { currentZoom = 1; } 
            else { currentZoom *= factor; }
            
            let wrap = document.getElementById('canvas-wrap');
            wrap.style.width = (baseWidth * currentZoom) + 'px';
            wrap.style.height = (baseHeight * currentZoom) + 'px';
            
            paper.view.viewSize = new paper.Size(baseWidth * currentZoom, baseHeight * currentZoom);
            paper.view.zoom = currentZoom;
            paper.view.center = new paper.Point(baseWidth / 2, baseHeight / 2); 
            
            if (factor !== null) {
                let ratio = currentZoom / oldZoom;
                ws.scrollLeft = scrollCenterX * ratio - ws.clientWidth / 2;
                ws.scrollTop = scrollCenterY * ratio - ws.clientHeight / 2;
            } else {
                ws.scrollLeft = (wrap.offsetWidth - ws.clientWidth) / 2 + 40;
                ws.scrollTop = (wrap.offsetHeight - ws.clientHeight) / 2 + 40;
            }

            document.getElementById('zoom-text').innerText = Math.round(currentZoom * 100) + '%';
            if(selectionRect) updateSelectionBounds(); 
            updateVisuals();
        }

        const workspaceElem = document.getElementById('workspace');
        let initialPinchDist = null;
        let initialZoom = null;
        let lastTouchPoint = null;

        workspaceElem.addEventListener('touchstart', (e) => {
            if (e.touches.length === 2) {
                e.preventDefault();
                initialPinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
                initialZoom = currentZoom;
            } else if (e.touches.length === 1 && ['select','move','rotate','scale'].includes(mode) && !paper.project.hitTest(new paper.Point(e.touches[0].clientX - workspaceElem.getBoundingClientRect().left + workspaceElem.scrollLeft, e.touches[0].clientY - workspaceElem.getBoundingClientRect().top + workspaceElem.scrollTop), {fill:true, stroke:true, tolerance:4})) {
                lastTouchPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY };
            }
        }, {passive: false});

        workspaceElem.addEventListener('touchmove', (e) => {
            if (e.touches.length === 2 && initialPinchDist) {
                e.preventDefault();
                let currentDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
                let ratio = currentDist / initialPinchDist;
                let newZoom = initialZoom * ratio;
                if (newZoom < 0.1) newZoom = 0.1;
                if (newZoom > 10) newZoom = 10;
                
                let factor = newZoom / currentZoom;
                changeZoom(factor);
            } else if (e.touches.length === 1 && lastTouchPoint) {
                e.preventDefault();
                workspaceElem.scrollLeft -= (e.touches[0].clientX - lastTouchPoint.x);
                workspaceElem.scrollTop -= (e.touches[0].clientY - lastTouchPoint.y);
                lastTouchPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY };
            }
        }, {passive: false});

        workspaceElem.addEventListener('touchend', (e) => {
            if (e.touches.length < 2) initialPinchDist = null;
            if (e.touches.length === 0) lastTouchPoint = null;
        });

        let toastTimer = null;
        function showToast(msg) {
            const el = document.getElementById('toast');
            if (!el) return;
            el.textContent = msg;
            el.classList.add('show');
            clearTimeout(toastTimer);
            toastTimer = setTimeout(() => { el.classList.remove('show'); }, 1200);
        }

        function saveState() {
            let state = { layers: appLayers.map(l => l.exportJSON()), layerCounter: layerCounter, spotCounter: spotCounter };
            undoStack.push(state);
            if(undoStack.length > 50) undoStack.shift();
            redoStack = []; 
            updateUndoRedoButtons();
        }

        function updateUndoRedoButtons() {
            const undoBtn = document.getElementById('btn-undo');
            const redoBtn = document.getElementById('btn-redo');
            if (undoBtn) undoBtn.disabled = undoStack.length <= 1;
            if (redoBtn) redoBtn.disabled = redoStack.length === 0;
        }

        function restoreState(stateJSON) {
            appLayers.forEach(l => l.remove()); appLayers = []; draftLayer.removeChildren();
            layerCounter = stateJSON.layerCounter; spotCounter = stateJSON.spotCounter;
            stateJSON.layers.forEach(lData => {
                let newL = new paper.Layer(); newL.importJSON(lData); appLayers.push(newL);
                newL.children.forEach(spot => { if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot); });
            });
            if(appLayers.length > 0) { activeLayer = appLayers[0]; selectedLayers = [activeLayer]; } 
            else { activeLayer = null; selectedLayers = []; }
            selectedSpots = []; activeSegment = null; selectedSegments = [];
            clearSelection(); 
        }

        // --- パレット機能 (プロパティ完全保存対応) ---
        let stylePalette = JSON.parse(localStorage.getItem('liquid_metal_palette_v2'));
        if (!stylePalette) {
            // v1からのマイグレーションまたは初期値
            let old = JSON.parse(localStorage.getItem('liquid_metal_palette_v1'));
            if (old && old.length > 0) {
                stylePalette = old.map(o => ({ c: o.c, m: o.m, e: o.e, t: 'pearl', b: 1, d: 3, la: 135, li: 80, hh: 5, p: 'none' }));
            } else {
                stylePalette = [
                    {c: '#ffffff', m: '#8ab4cc', e: '#0d2233', t: 'pearl', b: 1, d: 3, la: 135, li: 80, hh: 5, p: 'none'},
                    {c: '#ffffff', m: '#b0b8c0', e: '#1a1a1a', t: 'liquidmetal', b: 0, d: 5, la: 135, li: 100, hh: 8, p: 'none'},
                    {c: '#e0f0ff', m: '#6090c0', e: '#081828', t: 'slime', b: 5, d: 10, la: 135, li: 70, hh: 15, p: 'none'},
                    {c: '#f0fff4', m: '#60c080', e: '#0a2810', t: 'crystal', b: 0, d: 15, la: 135, li: 90, hh: 20, p: 'none'},
                    {c: '#f8f0ff', m: '#9060c0', e: '#200840', t: 'cloud', b: 20, d: 5, la: 135, li: 60, hh: 12, p: 'noise'},
                    {c: '#fff7d0', m: '#d4a820', e: '#5a3800', t: 'gold', b: 2, d: 4, la: 135, li: 90, hh: 6, p: 'none'}
                ];
            }
        }

        const extendedPalettes = [
            {c: '#ffffff', m: '#8ab4cc', e: '#0d2233'}, {c: '#ffffff', m: '#b0b8c0', e: '#1a1a1a'},
            {c: '#fff7d0', m: '#d4a820', e: '#5a3800'}, {c: '#e0f0ff', m: '#6090c0', e: '#081828'},
            {c: '#e8eaec', m: '#8090a0', e: '#1a2028'}, {c: '#fff0f8', m: '#d4a8c0', e: '#4a1030'},
            {c: '#f0fff4', m: '#60c080', e: '#0a2810'}, {c: '#f8f0ff', m: '#9060c0', e: '#200840'},
            {c: '#ffe8e8', m: '#c04040', e: '#280000'}, {c: '#fff4e0', m: '#c08020', e: '#381800'},
            {c: '#e8f8ff', m: '#2080c0', e: '#001828'}, {c: '#f0ffe8', m: '#60a040', e: '#102000'}
        ];

        function savePalette() {
            localStorage.setItem('liquid_metal_palette_v2', JSON.stringify(stylePalette));
        }

        // パレットのスウォッチに質感の種類も分かるようにするためのメタ情報
        const TEXTURE_META = {
            slime: { emoji: '🟢', label: 'スライム' },
            waterdrop: { emoji: '💧', label: '水滴' },
            liquidmetal: { emoji: '🌊', label: '流体金属' },
            crystal: { emoji: '💎', label: 'クリスタル・岩' },
            cloud: { emoji: '☁️', label: 'フワフワ雲' },
            pearl: { emoji: '🫧', label: 'パール' },
            water: { emoji: '💧', label: '水 (旧)' },
            sticker: { emoji: '🏷️', label: 'モコモコシール' },
            chrome: { emoji: '🪞', label: 'クロムメタル' },
            gold: { emoji: '✨', label: 'ゴールド' },
            mercury: { emoji: '🌊', label: 'マーキュリー (旧)' },
            brushed: { emoji: '🔩', label: 'ブラッシュドメタル' },
            none: { emoji: '➖', label: 'なし (フラット)' }
        };
        function addTextureBadge(cell, textureId) {
            const meta = TEXTURE_META[textureId] || TEXTURE_META['pearl'];
            cell.title = (cell.title ? cell.title + ' / ' : '') + meta.label;
            const badge = document.createElement('span');
            badge.className = 'texture-badge';
            badge.textContent = meta.emoji;
            cell.appendChild(badge);
        }

        window.openPaletteLibrary = function() {
            const grid = document.getElementById('library-grid');
            grid.innerHTML = '';
            extendedPalettes.forEach(colors => {
                let cell = document.createElement('div');
                cell.style.height = '40px'; cell.style.borderRadius = '4px'; cell.style.border = '1px solid #111'; cell.style.cursor = 'pointer'; cell.style.position = 'relative';
                cell.style.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
                addTextureBadge(cell, 'pearl');
                cell.onclick = () => {
                    let style = { c: colors.c, m: colors.m, e: colors.e, t: 'pearl', b: 1, d: 3, la: 135, li: 80, hh: 5, p: 'none' };
                    applyStyle(style); document.getElementById('palette-dialog').style.display='none';
                };
                grid.appendChild(cell);
            });
            document.getElementById('palette-dialog').style.display='flex';
        };

        window.addCurrentToPalette = function() {
            let style = {
                c: uiCenterCol.value, m: uiMidCol.value, e: uiEdgeCol.value,
                t: uiTexture.value, p: uiPattern.value,
                b: parseInt(uiBlur.value), d: parseInt(uiDepth.value),
                la: parseInt(uiLightAngle.value), li: parseInt(uiLightIntensity.value), hh: parseInt(uiHillHeight.value)
            };
            stylePalette.push(style);
            savePalette();
            renderPalette();
        };

        function renderPalette() {
            const grid = document.getElementById('palette-grid');
            grid.innerHTML = '';
            stylePalette.forEach((style, i) => {
                let cell = document.createElement('div');
                cell.className = 'palette-cell';
                cell.style.background = `linear-gradient(to right, ${style.c} 33%, ${style.m} 33% 66%, ${style.e} 66%)`;
                cell.style.position = 'relative';
                cell.draggable = true;
                cell.title = "クリックで適用 / D&Dで入れ替え / 右クリックで削除";
                addTextureBadge(cell, style.t);
                
                cell.onclick = () => { applyStyle(style); };
                
                cell.ondragstart = (e) => { e.dataTransfer.setData('paletteIdx', i); e.dataTransfer.setData('type', 'palette'); };
                cell.ondragover = (e) => { e.preventDefault(); };
                cell.ondrop = (e) => {
                    e.preventDefault();
                    if (e.dataTransfer.getData('type') === 'palette') {
                        let dragIdx = parseInt(e.dataTransfer.getData('paletteIdx'));
                        if(dragIdx === i) return;
                        let draggedItem = stylePalette.splice(dragIdx, 1)[0];
                        stylePalette.splice(i, 0, draggedItem);
                        savePalette(); renderPalette();
                    }
                };
                cell.oncontextmenu = (e) => {
                    e.preventDefault();
                    if(confirm('このパレットを削除しますか?')) { stylePalette.splice(i, 1); savePalette(); renderPalette(); }
                };
                grid.appendChild(cell);
            });
        }
        renderPalette();

        function applyStyle(style) {
            uiCenterCol.value = style.c || '#ffffff';
            uiMidCol.value = style.m || '#8ab4cc';
            uiEdgeCol.value = style.e || '#1a2a3a';
            if(style.t) uiTexture.value = style.t;
            if(style.p) uiPattern.value = style.p;
            if(style.b !== undefined) { uiBlur.value = style.b; document.getElementById('valBlur').innerText = style.b; }
            if(style.d !== undefined) { uiDepth.value = style.d; document.getElementById('valDepth').innerText = style.d; }
            if(style.la !== undefined) { uiLightAngle.value = style.la; document.getElementById('valLightAngle').innerText = style.la; }
            if(style.li !== undefined) { uiLightIntensity.value = style.li; document.getElementById('valLightIntensity').innerText = style.li; }
            if(style.hh !== undefined) { uiHillHeight.value = style.hh; document.getElementById('valHillHeight').innerText = style.hh; }
            
            document.getElementById('dragHandleCenter').style.backgroundColor = uiCenterCol.value;
            document.getElementById('dragHandleMid').style.backgroundColor = uiMidCol.value;
            document.getElementById('dragHandleEdge').style.backgroundColor = uiEdgeCol.value;

            updateProps({target: uiTexture}); 
            saveState();
        }

        // キャンバス上で右クリック→その場で色・質感パレットを開いて即適用するための簡易パレット
        window.openQuickPalette = function(x, y) {
            const grid = document.getElementById('quick-palette-grid');
            grid.innerHTML = '';
            if (stylePalette.length === 0) {
                grid.innerHTML = '<div style="grid-column: 1 / -1; color: var(--text-muted); font-size: 0.85em;">保存されたパレットがありません(プロパティタブの「+追加」で登録できます)</div>';
            } else {
                stylePalette.forEach((style) => {
                    let cell = document.createElement('div');
                    cell.className = 'palette-cell';
                    cell.style.background = `linear-gradient(to right, ${style.c} 33%, ${style.m} 33% 66%, ${style.e} 66%)`;
                    cell.title = 'クリックで適用';
                    addTextureBadge(cell, style.t);
                    cell.onclick = () => {
                        applyStyle(style);
                        document.getElementById('quick-palette-popup').style.display = 'none';
                        showToast('色・質感を変更しました');
                    };
                    grid.appendChild(cell);
                });
            }
            const popup = document.getElementById('quick-palette-popup');
            popup.style.display = 'block';
            let px = x, py = y;
            if (px + popup.offsetWidth > window.innerWidth) px -= popup.offsetWidth;
            if (py + popup.offsetHeight > window.innerHeight) py -= popup.offsetHeight;
            popup.style.left = px + 'px'; popup.style.top = py + 'px';
        };

        window.handleColorDragStart = function(e, inputId) { 
            e.dataTransfer.setData('text/plain', document.getElementById(inputId).value); 
        }
        window.handleColorDrop = function(e, inputId) {
            e.preventDefault(); 
            let color = e.dataTransfer.getData('text/plain');
            if (color && color.startsWith('#')) { 
                let input = document.getElementById(inputId); input.value = color; 
                updateProps({target: input}); saveState(); 
            }
        };

        window.handleTextureUpload = function(e) {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = function(evt) {
                const img = new Image();
                img.onload = function() {
                    const maxS = 512;
                    let w = img.width, h = img.height;
                    if(w > maxS || h > maxS) {
                        const ratio = Math.min(maxS/w, maxS/h);
                        w *= ratio; h *= ratio;
                    }
                    const c = document.createElement('canvas');
                    c.width = w; c.height = h;
                    c.getContext('2d').drawImage(img, 0, 0, w, h);
                    const dataUrl = c.toDataURL('image/jpeg', 0.85); 
                    
                    document.getElementById('texturePreview').src = dataUrl;
                    document.getElementById('texturePreview').style.display = 'block';
                    
                    const texImg = new Image();
                    texImg.onload = function() {
                        if (selectedStrokeItem && !selectedStrokeItem.isEmpty()) {
                            selectedStrokeItem.data.customTexture = dataUrl; selectedStrokeItem.data.customTextureObj = texImg;
                            const spot = selectedStrokeItem.parent;
                            if (spot && spot.data.isRendered) renderFluffySpot(spot);
                            saveState();
                        } else if (selectedSpots.length > 0) {
                            selectedSpots.forEach(spot => {
                                getStrokes(spot).forEach(s => { s.data.customTexture = dataUrl; s.data.customTextureObj = texImg; });
                                if(spot.data.isRendered) renderFluffySpot(spot);
                            });
                            saveState();
                        }
                    };
                    texImg.src = dataUrl;
                };
                img.src = evt.target.result;
            };
            reader.readAsDataURL(file);
            e.target.value = ''; 
        };

        window.clearTexture = function() {
            document.getElementById('texturePreview').style.display = 'none';
            document.getElementById('texturePreview').src = '';
            if (selectedStrokeItem && !selectedStrokeItem.isEmpty()) {
                selectedStrokeItem.data.customTexture = null; selectedStrokeItem.data.customTextureObj = null;
                const spot = selectedStrokeItem.parent;
                if (spot && spot.data.isRendered) renderFluffySpot(spot);
                saveState();
            } else if (selectedSpots.length > 0) {
                selectedSpots.forEach(spot => {
                    getStrokes(spot).forEach(s => { s.data.customTexture = null; s.data.customTextureObj = null; });
                    if(spot.data.isRendered) renderFluffySpot(spot);
                });
                saveState();
            }
        };

        const draftLayer = new paper.Layer({ name: 'draftLayer' }); 
        const uiLayer = new paper.Layer({ name: 'uiLayer' }); 
        let adjustMarkers = new paper.Group({name: 'adjustMarkers'}); uiLayer.addChild(adjustMarkers);

        function createNewLayer() {
            layerCounter++; let newLayer = new paper.Layer({ name: 'Layer_' + Date.now() });
            newLayer.data = { displayName: 'レイヤー ' + layerCounter, isVisible: true, id: Date.now() + Math.random() };
            appLayers.unshift(newLayer); draftLayer.bringToFront(); uiLayer.bringToFront(); return newLayer;
        }
        activeLayer = createNewLayer(); selectedLayers = [activeLayer]; saveState(); 

        const uiBrushSize = document.getElementById('uiBrushSize');
        const uiCenterCol = document.getElementById('uiCenterCol');
        const uiMidCol = document.getElementById('uiMidCol');
        const uiEdgeCol = document.getElementById('uiEdgeCol');
        const uiPattern = document.getElementById('uiPattern');
        const uiBlur = document.getElementById('uiBlur');
        const uiDepth = document.getElementById('uiDepth');
        const uiLightAngle = document.getElementById('uiLightAngle');
        const uiLightIntensity = document.getElementById('uiLightIntensity');
        const uiHillHeight = document.getElementById('uiHillHeight');
        const uiTexture = document.getElementById('uiTexture');
        const propPanel = document.getElementById('spot-properties');
        
        uiBrushSize.oninput = (e) => { brushRadius = parseInt(e.target.value); document.getElementById('valBrush').innerText = brushRadius; };

        document.getElementById('uiUseWebGL').addEventListener('change', () => {
            // GPU/CPUの切替を今表示中のスポットにも即座に反映する
            appLayers.forEach(layer => {
                layer.children.forEach(spot => {
                    if (spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot);
                });
            });
        });

        window.generateMidColor = function() {
            let c = uiCenterCol.value; let e = uiEdgeCol.value;
            let r = Math.round((parseInt(c.substring(1,3), 16) + parseInt(e.substring(1,3), 16)) / 2).toString(16).padStart(2, '0');
            let g = Math.round((parseInt(c.substring(3,5), 16) + parseInt(e.substring(3,5), 16)) / 2).toString(16).padStart(2, '0');
            let b = Math.round((parseInt(c.substring(5,7), 16) + parseInt(e.substring(5,7), 16)) / 2).toString(16).padStart(2, '0');
            uiMidCol.value = '#' + r + g + b; updateProps({target: uiMidCol}); saveState();
        };

        window.updateProps = (e) => {
            if (e && e.target) {
                const valElem = document.getElementById(e.target.id.replace('ui', 'val'));
                if (valElem) valElem.innerText = e.target.value;
                if(e.target.type === 'color') {
                    if(e.target.id === 'uiCenterCol') document.getElementById('dragHandleCenter').style.backgroundColor = e.target.value;
                    if(e.target.id === 'uiMidCol') document.getElementById('dragHandleMid').style.backgroundColor = e.target.value;
                    if(e.target.id === 'uiEdgeCol') document.getElementById('dragHandleEdge').style.backgroundColor = e.target.value;
                }
            }
            const applyStyleFields = (target) => {
                target.centerCol = uiCenterCol.value; target.midCol = uiMidCol.value; target.edgeCol = uiEdgeCol.value;
                target.patternType = uiPattern.value || 'none';
                target.blurAmt = parseInt(uiBlur.value); target.depthAmt = parseInt(uiDepth.value);
                target.lightAngle = parseInt(uiLightAngle.value) || 135;
                target.lightIntensity = parseInt(uiLightIntensity.value) || 80;
                target.hillHeight = parseInt(uiHillHeight.value) || 5;
                target.texture = uiTexture.value || 'pearl';
            };
            if (selectedStrokeItem && !selectedStrokeItem.isEmpty()) {
                // 個別選択中の筆跡だけを更新する
                applyStyleFields(selectedStrokeItem.data);
                const spot = selectedStrokeItem.parent;
                if (spot && spot.data.isRendered) renderFluffySpot(spot);
            } else if (selectedSpots.length > 0) {
                // 筆跡が個別選択されていない場合は、選択中スポットの全筆跡へまとめて適用(従来の一括変更)
                selectedSpots.forEach(spot => {
                    getStrokes(spot).forEach(strokePath => applyStyleFields(strokePath.data));
                    if (spot.data.isRendered) renderFluffySpot(spot);
                });
            }
        };

        window.switchTab = function(tabId) {
            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === tabId));
            document.querySelectorAll('.tab-content').forEach(content => content.classList.toggle('active', content.id === tabId));
        };

        // ==========================================
        // 筆跡(Stroke)ヘルパー: スポットは複数の筆跡をまとめる入れ物
        // ==========================================
        function getStrokes(spotGroup) {
            return spotGroup.children.filter(c => c.name && c.name.startsWith('Stroke_'));
        }
        // 全筆跡を結合した外形を返す(当たり判定・書き出し・塗りつぶし用の一時パス。使用後はremove()すること)
        function getSpotUnionPath(spotGroup) {
            const strokes = getStrokes(spotGroup);
            if (strokes.length === 0) return new paper.Path();
            let union = strokes[0].clone();
            for (let i = 1; i < strokes.length; i++) {
                let clone = strokes[i].clone();
                let temp = union.unite(clone);
                union.remove(); clone.remove();
                union = temp;
            }
            return union;
        }
        function captureCurrentStyle() {
            return {
                centerCol: uiCenterCol.value, midCol: uiMidCol.value, edgeCol: uiEdgeCol.value,
                patternType: uiPattern.value || 'none',
                blurAmt: parseInt(uiBlur.value) || 1, depthAmt: parseInt(uiDepth.value) || 3,
                lightAngle: parseInt(uiLightAngle.value) || 135, lightIntensity: parseInt(uiLightIntensity.value) || 80,
                hillHeight: parseInt(uiHillHeight.value) || 5, texture: uiTexture.value || 'pearl',
                id: Date.now() + Math.random()
            };
        }

        // 指定座標の直下にある「筆跡」を、手前(前面)のレイヤー・スポットから順に探す
        function hitTestStrokeAt(point) {
            for (let li = 0; li < appLayers.length; li++) { // 配列の先頭ほど手前(最前面)のレイヤー
                const layer = appLayers[li];
                if (layer.data.isVisible === false) continue;
                const spots = layer.children.filter(c => c.name && c.name.startsWith('Spot_'));
                for (let si = spots.length - 1; si >= 0; si--) { // 子の並びは末尾ほど手前
                    const spot = spots[si];
                    if (spot.data.isVisible === false) continue;
                    const strokes = getStrokes(spot);
                    for (let ki = strokes.length - 1; ki >= 0; ki--) {
                        const strokePath = strokes[ki];
                        if (!strokePath.isEmpty() && strokePath.contains(point)) return { spot, stroke: strokePath };
                    }
                }
            }
            return null;
        }

        // 選択した筆跡のプロパティパネルを、その筆跡自身のデータに同期する
        function syncPropsPanelToStroke(strokePath) {
            const data = strokePath.data; if (!data) return;
            uiCenterCol.value = data.centerCol || '#ffffff';
            uiMidCol.value    = data.midCol    || '#8ab4cc';
            uiEdgeCol.value   = data.edgeCol   || '#1a2a3a';
            document.getElementById('dragHandleCenter').style.backgroundColor = uiCenterCol.value;
            document.getElementById('dragHandleMid').style.backgroundColor    = uiMidCol.value;
            document.getElementById('dragHandleEdge').style.backgroundColor   = uiEdgeCol.value;
            uiBlur.value  = data.blurAmt  ?? 1;  document.getElementById('valBlur').innerText  = uiBlur.value;
            uiDepth.value = data.depthAmt ?? 3;  document.getElementById('valDepth').innerText = uiDepth.value;
            uiLightAngle.value     = data.lightAngle     || 135; document.getElementById('valLightAngle').innerText     = uiLightAngle.value;
            uiLightIntensity.value = data.lightIntensity || 80;  document.getElementById('valLightIntensity').innerText = uiLightIntensity.value;
            uiHillHeight.value     = data.hillHeight     || 5;   document.getElementById('valHillHeight').innerText     = uiHillHeight.value;
            uiTexture.value  = data.texture     || 'pearl';
            uiPattern.value  = data.patternType || 'none';
            const preview = document.getElementById('texturePreview');
            if (data.customTexture) { preview.src = data.customTexture; preview.style.display = 'block'; }
            else { preview.style.display = 'none'; preview.src = ''; }
        }

        // 筆跡を個別選択し、プロパティパネルに同期したうえでクイックパレットを開く(右クリック用)
        function selectStrokeForPalette(spot, strokePath, clientX, clientY) {
            commitVector();
            if (!selectedSpots.includes(spot)) selectedSpots = [spot];
            selectedStrokeItem = strokePath;
            selectedSegments = []; activeSegment = null; activeHandle = null; adjustTargetLocation = null;
            propPanel.style.opacity = '1'; propPanel.style.pointerEvents = 'auto';
            switchTab('tab-prop');
            syncPropsPanelToStroke(strokePath);
            updateVisuals();
            openQuickPalette(clientX, clientY);
        }

        function commitVector() {
            if (draftQueue.length === 0 || selectedSpots.length === 0) return;
            let targetSpot = selectedSpots[0];
            let parentLayer = targetSpot.parent; if(parentLayer) parentLayer.activate();

            for (let item of draftQueue) {
                if (item.mode === 'draw') {
                    // 新しい筆跡として、今のパレット設定を「自分だけのデータ」として持たせて追加する
                    item.path.visible = false; item.path.fillColor = null; item.path.strokeColor = null; item.path.strokeWidth = 0;
                    item.path.name = 'Stroke_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
                    item.path.data = captureCurrentStyle();
                    targetSpot.addChild(item.path);
                } else if (item.mode === 'erase') {
                    // 既存の各筆跡から消しゴム範囲を個別に減算する(それぞれの質感データは維持)
                    getStrokes(targetSpot).forEach(strokePath => {
                        if (strokePath.isEmpty()) return;
                        let result = strokePath.subtract(item.path);
                        const oldData = strokePath.data; const oldName = strokePath.name;
                        const wasSelected = (selectedStrokeItem === strokePath);
                        strokePath.remove();
                        if (!result.isEmpty()) {
                            result.name = oldName; result.data = oldData; result.visible = false; result.fillColor = null; result.strokeColor = null; result.strokeWidth = 0; targetSpot.addChild(result);
                            if (wasSelected) selectedStrokeItem = result;
                        } else {
                            result.remove();
                            if (wasSelected) selectedStrokeItem = null;
                        }
                    });
                    item.path.remove();
                }
            }
            draftQueue = [];
            if (targetSpot.data.isRendered) renderFluffySpot(targetSpot);
            updateVisuals(); saveState(); 
        }

        function getPathSegments(path) {
            let segs = [];
            if (path instanceof paper.CompoundPath) {
                path.children.forEach(child => { if (child.segments) segs = segs.concat(child.segments); });
            } else if (path.segments) { segs = path.segments; }
            return segs;
        }

        function updateVisuals() {
            const layerContainer = document.getElementById('layer-list-container');
            layerContainer.innerHTML = '';
            appLayers.forEach((layer, l_idx) => {
                let div = document.createElement('div'); div.className = 'list-item' + (selectedLayers.includes(layer) ? ' selected' : '');
                
                div.draggable = true;
                div.ondragstart = (e) => { e.dataTransfer.setData('layerId', layer.data.id); e.dataTransfer.setData('type', 'layer'); };
                div.ondragover = (e) => { 
                    e.preventDefault(); 
                    if(e.dataTransfer.types.includes('type') && e.dataTransfer.getData('type') !== 'layer') return;
                    let r = div.getBoundingClientRect();
                    if(e.clientY < r.top + r.height/2) { div.classList.add('drag-over-top'); div.classList.remove('drag-over-bottom'); }
                    else { div.classList.add('drag-over-bottom'); div.classList.remove('drag-over-top'); }
                };
                div.ondragleave = (e) => { div.classList.remove('drag-over-top', 'drag-over-bottom'); };
                div.ondrop = (e) => {
                    e.preventDefault(); div.classList.remove('drag-over-top', 'drag-over-bottom');
                    if(e.dataTransfer.getData('type') !== 'layer') return;
                    let dragId = parseFloat(e.dataTransfer.getData('layerId'));
                    if (dragId === layer.data.id) return;
                    let dragLayer = appLayers.find(l => l.data.id === dragId);
                    if(!dragLayer) return;

                    let dragIdx = appLayers.indexOf(dragLayer);
                    let dropIdx = appLayers.indexOf(layer);
                    appLayers.splice(dragIdx, 1);
                    
                    let insertIdx = dropIdx;
                    let r = div.getBoundingClientRect();
                    if(e.clientY > r.top + r.height/2) insertIdx++; 
                    if(dragIdx < dropIdx && insertIdx > 0) insertIdx--; 
                    
                    appLayers.splice(insertIdx, 0, dragLayer);
                    appLayers.slice().reverse().forEach(l => l.bringToFront());
                    draftLayer.bringToFront(); uiLayer.bringToFront();
                    updateVisuals(); saveState();
                };

                let chk = document.createElement('input'); chk.type = 'checkbox'; chk.checked = layer.data.isVisible !== false;
                chk.onclick = (e) => { e.stopPropagation(); layer.visible = chk.checked; layer.data.isVisible = chk.checked; updateSelectionBounds(); };
                let span = document.createElement('span'); span.className = 'item-name'; span.innerText = layer.data.displayName + (layer.opacity < 1 ? ` (${Math.round(layer.opacity*100)}%)` : '');
                div.appendChild(chk); div.appendChild(span);
                
                div.onclick = (e) => {
                    commitVector();
                    if(e.ctrlKey || e.metaKey) {
                        if(selectedLayers.includes(layer)) selectedLayers = selectedLayers.filter(l => l!==layer); else selectedLayers.push(layer);
                    } else selectedLayers = [layer];
                    activeLayer = selectedLayers.length > 0 ? selectedLayers[0] : appLayers[0];
                    clearSelection(); 
                };
                div.oncontextmenu = (e) => {
                    e.preventDefault(); e.stopPropagation();
                    if(!selectedLayers.includes(layer)) { selectedLayers = [layer]; activeLayer = layer; updateVisuals(); }
                    showContextMenu(e, 'layer', layer, false);
                };
                layerContainer.appendChild(div);
            });

            const spotContainer = document.getElementById('spot-list-container');
            spotContainer.innerHTML = '';
            if (activeLayer) {
                let spots = activeLayer.children.filter(i => i.name && i.name.startsWith('Spot_'));
                for (let i = spots.length - 1; i >= 0; i--) {
                    let spot = spots[i];
                    let div = document.createElement('div'); div.className = 'list-item' + (selectedSpots.includes(spot) ? ' selected' : '');
                    
                    div.draggable = true;
                    if(!spot.data.id) spot.data.id = Date.now() + Math.random();
                    div.ondragstart = (e) => { e.dataTransfer.setData('sourceId', spot.data.id); e.dataTransfer.setData('type', 'spot'); };
                    div.ondragover = (e) => { 
                        e.preventDefault(); 
                        e.dataTransfer.dropEffect = 'move'; 
                        let r = div.getBoundingClientRect();
                        if(e.clientY < r.top + r.height/2) { div.classList.add('drag-over-top'); div.classList.remove('drag-over-bottom'); }
                        else { div.classList.add('drag-over-bottom'); div.classList.remove('drag-over-top'); }
                    };
                    div.ondragleave = (e) => { div.classList.remove('drag-over-top', 'drag-over-bottom'); };
                    div.ondrop = (e) => {
                        e.preventDefault(); div.classList.remove('drag-over-top', 'drag-over-bottom');
                        if(e.dataTransfer.getData('type') !== 'spot') return;
                        let dragId = parseFloat(e.dataTransfer.getData('sourceId'));
                        if (dragId === spot.data.id) return;
                        let p = activeLayer;
                        let dragSpot = p.children.find(c => c.data && c.data.id === dragId);
                        let dropSpot = spot;
                        if (!dragSpot || !dropSpot) return;
                        
                        let r = div.getBoundingClientRect();
                        if(e.clientY < r.top + r.height/2) { dragSpot.insertAbove(dropSpot); } 
                        else { dragSpot.insertBelow(dropSpot); }
                        updateVisuals(); saveState();
                    };

                    let chk = document.createElement('input'); chk.type = 'checkbox'; chk.checked = spot.data.isVisible !== false;
                    chk.onclick = (e) => { e.stopPropagation(); spot.visible = chk.checked; spot.data.isVisible = chk.checked; updateSelectionBounds(); };
                    let span = document.createElement('span'); span.className = 'item-name'; span.innerText = spot.data.displayName + (spot.opacity < 1 ? ` (${Math.round(spot.opacity*100)}%)` : '');
                    div.appendChild(chk); div.appendChild(span);

                    div.onclick = (e) => {
                        commitVector(); 
                        if (['draw','erase','fill','move','rotate','scale','adjust'].indexOf(mode) === -1) setMode('select');
                        selectedStrokeItem = null;
                        
                        if(e.ctrlKey || e.metaKey) {
                            if(selectedSpots.includes(spot)) selectedSpots = selectedSpots.filter(s => s!==spot); else selectedSpots.push(spot);
                        } else selectedSpots = [spot];
                        
                        propPanel.style.opacity = selectedSpots.length > 0 ? '1' : '0.5';
                        propPanel.style.pointerEvents = selectedSpots.length > 0 ? 'auto' : 'none';
                        updateVisuals();
                    };
                    div.oncontextmenu = (e) => {
                        e.preventDefault(); e.stopPropagation();
                        if(!selectedSpots.includes(spot)) { selectedSpots = [spot]; updateVisuals(); }
                        showContextMenu(e, 'spot', spot, false);
                    };
                    spotContainer.appendChild(div);
                }
            }

            paper.project.deselectAll();
            adjustMarkers.removeChildren();

            appLayers.forEach(layer => {
                layer.children.forEach(spot => {
                    if (!spot.name || !spot.name.startsWith('Spot_')) return;
                    const renderGrp = spot.children['renderGroup'];
                    const strokes = getStrokes(spot);

                    // 個別の筆跡は普段は非表示にし、合成済みのrenderGroupだけを見せる
                    strokes.forEach(s => { s.visible = false; s.selected = false; });
                    const oldFlat = spot.children['flatPreview']; if (oldFlat) oldFlat.remove();

                    if (spot.data.isVisible === false || layer.data.isVisible === false) {
                        if (renderGrp) renderGrp.visible = false; return;
                    }

                    if (mode === 'adjust' && selectedStrokeItem && selectedStrokeItem.parent === spot && !selectedStrokeItem.isEmpty()) {
                        // 調整モード: 選択中の筆跡だけを表示してベジェ編集点を出す
                        selectedStrokeItem.visible = true; selectedStrokeItem.selected = false;
                        selectedStrokeItem.strokeColor = '#00aaff'; selectedStrokeItem.strokeWidth = 1.5; selectedStrokeItem.fillColor = 'rgba(0,170,255,0.05)';
                        selectedStrokeItem.bringToFront();

                        let segs = getPathSegments(selectedStrokeItem);
                        segs.forEach(seg => {
                            let isSel = selectedSegments.includes(seg);
                            seg.selected = isSel; 
                            if (!isSel) {
                                let dot = new paper.Path.Circle({center: seg.point, radius: 3, fillColor: '#ffffff', strokeColor: '#555555', strokeWidth: 1});
                                adjustMarkers.addChild(dot);
                            }
                        });
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.5; }
                    } else if (spot.data.isRendered === false) {
                        // 3D表示OFF(ベクター化)のスポットは、全筆跡の外形をフラット表示する
                        let union = getSpotUnionPath(spot);
                        if (!union.isEmpty()) {
                            union.name = 'flatPreview'; union.fillColor = 'rgba(200, 200, 200, 0.5)'; union.strokeColor = null;
                            spot.addChild(union);
                        } else { union.remove(); }
                        if (renderGrp) renderGrp.visible = false;
                    } else {
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = (['draw','erase','fill'].includes(mode) && selectedSpots.includes(spot)) ? 0.8 : 1.0; }
                    }
                });
            });

            if (['select','move','rotate','scale'].includes(mode)) updateSelectionBounds();
            else if (selectionRect) { selectionRect.remove(); selectionRect = null; }
            
            paper.view.update(); 
        }

        window.setMode = function(newMode) {
            commitVector(); 
            mode = newMode;
            if (brushCursor) { brushCursor.remove(); brushCursor = null; }
            
            if (mode !== 'adjust') {
                activeSegment = null; activeHandle = null; adjustTargetLocation = null;
                selectedSegments = [];
                paper.project.deselectAll();
            }
            
            document.querySelectorAll('.tool-btn').forEach(btn => btn.classList.remove('active'));
            let btn = document.getElementById('btn-' + mode);
            if(btn) btn.classList.add('active');

            const texts = { 'draw': '描く', 'erase': '消す', 'fill':'塗る', 'select': '選択・複数選択', 'move': '移動', 'rotate':'回転', 'scale':'拡縮', 'adjust': 'ベジェ調整' };
            document.getElementById('mode-text').innerText = texts[mode] || mode;
            document.getElementById('myCanvas').style.cursor = (['select','move','rotate','scale'].includes(mode)) ? 'default' : (['draw','erase'].includes(mode) ? 'none' : 'crosshair');
            updateVisuals();
        };

        document.getElementById('tab-layers').addEventListener('contextmenu', (e) => {
            e.preventDefault(); if (e.target.closest('.list-item')) return;
            showContextMenu(e, 'layer', null, true);
        });
        document.getElementById('tab-spots').addEventListener('contextmenu', (e) => {
            e.preventDefault(); if (e.target.closest('.list-item')) return;
            showContextMenu(e, 'spot', null, true);
        });

        function showContextMenu(e, type, item, isBackground = false) {
            cmTargetType = type; cmTargetItem = item;
            lastContextMenuPos = { x: e.clientX, y: e.clientY };
            const menu = document.getElementById('context-menu');
            const items = menu.children;
            for(let i=0; i<items.length; i++) {
                let action = items[i].getAttribute('data-action');
                if(!action) continue;
                if (isBackground) {
                    if (action === 'add' || action === 'paste') items[i].style.display = 'block'; else items[i].style.display = 'none';
                } else {
                    if (action === 'add' || action === 'sep1') items[i].style.display = 'none'; 
                    else if (type === 'layer' && ['bringFront', 'bringForward', 'sendBackward', 'sendBack', 'sep_order', 'quickPalette'].includes(action)) items[i].style.display = 'none';
                    else items[i].style.display = 'block';
                }
            }
            menu.style.display = 'block';
            let x = e.clientX; let y = e.clientY;
            if(x + menu.offsetWidth > window.innerWidth) x -= menu.offsetWidth;
            if(y + menu.offsetHeight > window.innerHeight) y -= menu.offsetHeight;
            menu.style.left = x + 'px'; menu.style.top = y + 'px';
        }

        window.addEventListener('click', (e) => { document.querySelectorAll('.popup-menu').forEach(m => m.style.display = 'none'); });

        window.cmAction = function(action) {
            if (action === 'quickPalette') {
                if (cmTargetType === 'spot' && cmTargetItem) {
                    if (!selectedSpots.includes(cmTargetItem)) { selectedSpots = [cmTargetItem]; }
                    selectedStrokeItem = null; // スポット全体への一括適用にするため、個別筆跡の選択は解除
                    updateVisuals();
                    openQuickPalette(lastContextMenuPos.x, lastContextMenuPos.y);
                }
                return;
            }
            if(cmTargetType === 'layer') handleLayerAction(action); else if(cmTargetType === 'spot') handleSpotAction(action);
        }

        function handleLayerAction(action) {
            if(action === 'add') { activeLayer = createNewLayer(); selectedLayers = [activeLayer]; clearSelection(); saveState(); } 
            else if(action === 'cut' || action === 'copy') {
                if(selectedLayers.length > 0) { clipboardData = selectedLayers[0].exportJSON(); clipboardType = 'layer'; if(action === 'cut') { selectedLayers[0].remove(); appLayers = appLayers.filter(l => l !== selectedLayers[0]); saveState(); } }
            } else if(action === 'paste') {
                if(clipboardType === 'layer' && clipboardData) {
                    let newL = new paper.Layer(); newL.importJSON(clipboardData); layerCounter++; newL.data.displayName = 'ペーストされたレイヤー ' + layerCounter; newL.data.id = Date.now() + Math.random();
                    appLayers.unshift(newL); activeLayer = newL; selectedLayers = [newL];
                    newL.children.forEach(spot => { if(spot.name && spot.data.isRendered) renderFluffySpot(spot); }); saveState();
                }
            } else if(action === 'rename') {
                if(selectedLayers.length > 0) { let newName = prompt('新しい名前を入力:', selectedLayers[0].data.displayName); if(newName) { selectedLayers[0].data.displayName = newName; saveState(); } }
            } else if(action === 'delete') {
                selectedLayers.forEach(l => { l.remove(); appLayers = appLayers.filter(al => al !== l); });
                selectedLayers = []; activeLayer = appLayers.length > 0 ? appLayers[0] : createNewLayer(); saveState();
            } else if(action === 'opacity') {
                if(selectedLayers.length > 0) { let op = prompt('透明度を 0.0 ~ 1.0 の間で入力:', selectedLayers[0].opacity); if(op !== null && !isNaN(parseFloat(op))) { selectedLayers.forEach(l => l.opacity = parseFloat(op)); saveState(); } }
            } else if(action === 'merge') {
                if(selectedLayers.length > 1) {
                    let mergedLayer = createNewLayer(); mergedLayer.data.displayName = '結合レイヤー ' + layerCounter;
                    selectedLayers.slice().reverse().forEach(l => { let spots = l.children.filter(i => i.name && i.name.startsWith('Spot_')).slice(); spots.forEach(s => mergedLayer.addChild(s)); l.remove(); appLayers = appLayers.filter(al => al !== l); });
                    selectedLayers = [mergedLayer]; activeLayer = mergedLayer; saveState();
                } else alert("結合するにはCtrlキー(MacはCmd)を押しながら複数のレイヤーを選択してください。");
            }
            updateVisuals();
        }

        function handleSpotAction(action) {
            commitVector();
            if(action === 'add') { execAction('addSpot'); } 
            else if(action === 'cut' || action === 'copy') {
                if(selectedSpots.length > 0) { clipboardData = selectedSpots[0].exportJSON(); clipboardType = 'spot'; if(action === 'cut') execAction('delete'); }
            } else if(action === 'paste') {
                if(clipboardType === 'spot' && clipboardData && activeLayer) {
                    activeLayer.activate(); let pasted = new paper.Group(); pasted.importJSON(clipboardData); pasted.name = 'Spot_' + Date.now();
                    pasted.data.displayName = pasted.data.displayName + ' (コピー)'; pasted.position.x += 20; pasted.position.y += 20; pasted.data.id = Date.now() + Math.random();
                    activeLayer.addChild(pasted); if (pasted.data.isRendered) renderFluffySpot(pasted);
                    selectedStrokeItem = null; selectedSpots = [pasted]; saveState();
                }
            } else if(action === 'rename') {
                if(selectedSpots.length > 0) { let newName = prompt('新しい名前を入力:', selectedSpots[0].data.displayName); if(newName) { selectedSpots[0].data.displayName = newName; saveState(); } }
            } else if(action === 'delete') { execAction('delete'); } 
            else if(action === 'opacity') {
                if(selectedSpots.length > 0) { let op = prompt('透明度を 0.0 ~ 1.0 の間で入力:', selectedSpots[0].opacity); if(op !== null && !isNaN(parseFloat(op))) { selectedSpots.forEach(s => s.opacity = parseFloat(op)); saveState(); } }
            } else if(action === 'merge') {
                if(selectedSpots.length > 1) execAction('unite'); else alert("結合するにはCtrlキー(MacはCmd)を押しながら複数のスポットを選択してください。");
            } 
            else if(action === 'bringFront') { selectedSpots.forEach(s => s.bringToFront()); saveState(); }
            else if(action === 'bringForward') { selectedSpots.forEach(spot => { let parent = spot.parent; let idx = parent.children.indexOf(spot); if(idx < parent.children.length - 1) parent.insertChild(idx + 1, spot); }); saveState(); }
            else if(action === 'sendBackward') { selectedSpots.forEach(spot => { let parent = spot.parent; let idx = parent.children.indexOf(spot); if(idx > 0) parent.insertChild(idx - 1, spot); }); saveState(); }
            else if(action === 'sendBack') { selectedSpots.forEach(spot => { let parent = spot.parent; parent.insertChild(0, spot); }); saveState(); }
            updateVisuals();
        }

        function rasterizePathToMask(basePath, W, H, offX, offY) {
            const mc = document.createElement('canvas');
            mc.width = W; mc.height = H;
            const mctx = mc.getContext('2d');
            mctx.clearRect(0, 0, W, H);
            let pathData = '';
            try {
                if (basePath instanceof paper.CompoundPath) {
                    pathData = basePath.children.map(c => c.pathData || '').join(' ').trim();
                } else {
                    pathData = (basePath.pathData || '').trim();
                }
            } catch(e) { pathData = ''; }
            if (!pathData) return mctx.getImageData(0, 0, W, H);
            mctx.save();
            mctx.translate(-offX, -offY);
            try {
                const p2d = new Path2D(pathData);
                mctx.fillStyle = 'rgba(255,255,255,1)';
                mctx.fill(p2d, 'nonzero');
            } catch(e) {}
            mctx.restore();
            return mctx.getImageData(0, 0, W, H);
        }

        function computeDistanceField(maskData, W, H) {
            const INF = 1e9;
            const seedX = new Int32Array(W * H).fill(-1);
            const seedY = new Int32Array(W * H).fill(-1);
            for (let y = 0; y < H; y++) {
                for (let x = 0; x < W; x++) {
                    const idx = y * W + x;
                    if (maskData.data[idx * 4 + 3] <= 128) continue;
                    const isEdge = (
                        (x === 0 || maskData.data[(y*W+x-1)*4+3] <= 128) ||
                        (x === W-1 || maskData.data[(y*W+x+1)*4+3] <= 128) ||
                        (y === 0 || maskData.data[((y-1)*W+x)*4+3] <= 128) ||
                        (y === H-1 || maskData.data[((y+1)*W+x)*4+3] <= 128)
                    );
                    if (isEdge) { seedX[idx] = x; seedY[idx] = y; }
                }
            }
            const tempSX = new Int32Array(W * H);
            const tempSY = new Int32Array(W * H);
            let step = 1;
            while (step < Math.max(W, H)) step <<= 1;
            step >>= 1;
            while (step >= 1) {
                for (let y = 0; y < H; y++) {
                    for (let x = 0; x < W; x++) {
                        const idx = y * W + x;
                        tempSX[idx] = seedX[idx];
                        tempSY[idx] = seedY[idx];
                        let bestDist = (seedX[idx] >= 0) ? (x - seedX[idx]) * (x - seedX[idx]) + (y - seedY[idx]) * (y - seedY[idx]) : INF;
                        for (let dy = -1; dy <= 1; dy++) {
                            for (let dx = -1; dx <= 1; dx++) {
                                if (dx === 0 && dy === 0) continue;
                                const nx2 = x + dx * step;
                                const ny2 = y + dy * step;
                                if (nx2 < 0 || nx2 >= W || ny2 < 0 || ny2 >= H) continue;
                                const nidx = ny2 * W + nx2;
                                if (seedX[nidx] < 0) continue;
                                const d = (x - seedX[nidx]) * (x - seedX[nidx]) + (y - seedY[nidx]) * (y - seedY[nidx]);
                                if (d < bestDist) { bestDist = d; tempSX[idx] = seedX[nidx]; tempSY[idx] = seedY[nidx]; }
                            }
                        }
                    }
                }
                seedX.set(tempSX); seedY.set(tempSY);
                step >>= 1;
            }
            const dist = new Float32Array(W * H);
            for (let i = 0; i < W * H; i++) {
                if (maskData.data[i * 4 + 3] <= 128) { dist[i] = 0; continue; }
                if (seedX[i] < 0) { dist[i] = 0; continue; }
                const ix = i % W, iy = Math.floor(i / W);
                dist[i] = Math.sqrt((ix - seedX[i]) * (ix - seedX[i]) + (iy - seedY[i]) * (iy - seedY[i]));
            }
            return dist;
        }

        // ==========================================
        // Three.jsによるGLBエクスポート (テクスチャ対応)
        // ==========================================
        function exportGLB() {
            if (typeof THREE === 'undefined') { alert("Three.jsが読み込まれていません。"); return; }
            if (selectedSpots.length === 0) { alert('3Dエクスポートするスポットを選択してください。'); return; }
            
            const spotGroup = selectedSpots[0];
            const strokes = getStrokes(spotGroup);
            if (strokes.length === 0) { alert('書き出す筆跡がありません。'); return; }
            // glTFは1メッシュにつき1マテリアルのため、一番上(最後に描いた)筆跡の質感を代表として使用します
            const data = strokes[strokes.length - 1].data;
            const basePath = getSpotUnionPath(spotGroup);
            if (basePath.isEmpty()) { basePath.remove(); return; }

            const bounds = basePath.bounds;
            const pad = 10;
            const W = Math.ceil(bounds.width) + pad * 2;
            const H = Math.ceil(bounds.height) + pad * 2;
            const offX = Math.floor(bounds.left) - pad;
            const offY = Math.floor(bounds.top) - pad;

            const maskData = rasterizePathToMask(basePath, W, H, offX, offY);
            basePath.remove(); // 結合用の一時パスなので後片付け
            const dist = computeDistanceField(maskData, W, H);

            let maxDist = 0;
            for (let i = 0; i < dist.length; i++) if (dist[i] > maxDist) maxDist = dist[i];
            if (maxDist < 1) maxDist = 1;

            const curvePow = 0.2 + ((data.blurAmt ?? 1) / 80.0) * 1.3;
            const hillScale = Math.max(0.05, (data.hillHeight || 5) / 100.0);
            let texType = 0;
            if (data.texture === 'crystal') texType = 1;
            if (data.texture === 'cloud') texType = 2;

            const segX = Math.min(W, 150);
            const segY = Math.min(H, 150);

            const vertsCountX = segX + 1;
            const vertsCountY = segY + 1;
            const totalTopVerts = vertsCountX * vertsCountY;
            const heightGrid = new Float32Array(totalTopVerts);
            const spacingX = W / segX; 
            const spacingY = H / segY; 

            for (let iy = 0; iy <= segY; iy++) {
                const ty = iy / segY;
                const py = Math.min(H - 1, Math.floor(ty * H));
                for (let ix = 0; ix <= segX; ix++) {
                    const tx = ix / segX;
                    const px = Math.min(W - 1, Math.floor(tx * W));
                    const d = dist[py * W + px];
                    const nd = d / maxDist;
                    const h = getCpuHeight(nd, tx * W, py * H, W, H, texType, curvePow, hillScale);
                    heightGrid[iy * vertsCountX + ix] = h * 50.0;
                }
            }

            const vertices = []; const normals = []; const uvs = [];

            for (let iy = 0; iy <= segY; iy++) {
                const ty = iy / segY;
                for (let ix = 0; ix <= segX; ix++) {
                    const tx = ix / segX;
                    const idx = iy * vertsCountX + ix;
                    const x = (tx * W) - (W / 2);
                    const y = -((ty * H) - (H / 2));
                    const z = heightGrid[idx];
                    vertices.push(x, y, z);
                    uvs.push(tx, 1.0 - ty);

                    const ixL = Math.max(0, ix - 1); const ixR = Math.min(vertsCountX - 1, ix + 1);
                    const iyU = Math.max(0, iy - 1); const iyD = Math.min(vertsCountY - 1, iy + 1);
                    const hL = heightGrid[iy * vertsCountX + ixL]; const hR = heightGrid[iy * vertsCountX + ixR];
                    const hU = heightGrid[iyU * vertsCountX + ix]; const hD = heightGrid[iyD * vertsCountX + ix];
                    const dx = (hR - hL) / ( (ixR - ixL) * spacingX + 1e-9 );
                    const dy = (hD - hU) / ( (iyD - iyU) * spacingY + 1e-9 );
                    let nx = -dx, ny = -dy, nz = 1.0;
                    const len = Math.sqrt(nx*nx + ny*ny + nz*nz);
                    normals.push(nx/len, ny/len, nz/len);
                }
            }

            const bottomOffset = totalTopVerts;
            for (let iy = 0; iy <= segY; iy++) {
                const ty = iy / segY;
                for (let ix = 0; ix <= segX; ix++) {
                    const tx = ix / segX;
                    vertices.push((tx * W) - (W / 2), -((ty * H) - (H / 2)), 0);
                    uvs.push(tx, 1.0 - ty);
                    normals.push(0, 0, -1);
                }
            }

            const indices = [];
            for (let iy = 0; iy < segY; iy++) {
                for (let ix = 0; ix < segX; ix++) {
                    const i0 = iy * vertsCountX + ix; const i1 = i0 + 1;
                    const i2 = i0 + vertsCountX; const i3 = i2 + 1;
                    indices.push(i0, i1, i2); indices.push(i2, i1, i3);
                }
            }
            for (let iy = 0; iy < segY; iy++) {
                for (let ix = 0; ix < segX; ix++) {
                    const bi0 = bottomOffset + (iy * vertsCountX + ix); const bi1 = bi0 + 1;
                    const bi2 = bi0 + vertsCountX; const bi3 = bi2 + 1;
                    indices.push(bi2, bi1, bi0); indices.push(bi3, bi1, bi2);
                }
            }

            function topIndex(ix, iy) { return iy * vertsCountX + ix; } 
            function bottomIndex(ix, iy) { return bottomOffset + iy * vertsCountX + ix; } 
            for (let iy = 0; iy < vertsCountY - 1; iy++) {
                const t0 = topIndex(0, iy), t1 = topIndex(0, iy+1); const b0 = bottomIndex(0, iy), b1 = bottomIndex(0, iy+1);
                indices.push(t1, t0, b0); indices.push(b0, b1, t1);
                const t2 = topIndex(segX, iy), t3 = topIndex(segX, iy+1); const b2 = bottomIndex(segX, iy), b3 = bottomIndex(segX, iy+1);
                indices.push(t2, t3, b2); indices.push(b2, t3, b3);
            }
            for (let ix = 0; ix < vertsCountX - 1; ix++) {
                const t0 = topIndex(ix, 0), t1 = topIndex(ix+1, 0); const b0 = bottomIndex(ix, 0), b1 = bottomIndex(ix+1, 0);
                indices.push(t0, t1, b0); indices.push(b0, t1, b1);
                const t2 = topIndex(ix, segY), t3 = topIndex(ix+1, segY); const b2 = bottomIndex(ix, segY), b3 = bottomIndex(ix+1, segY);
                indices.push(t3, t2, b2); indices.push(b2, t2, b3);
            }

            const geometry = new THREE.BufferGeometry();
            geometry.setAttribute('position', new THREE.Float32BufferAttribute(vertices, 3));
            geometry.setAttribute('normal', new THREE.Float32BufferAttribute(normals, 3));
            geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
            geometry.setIndex(indices);

            const material = new THREE.MeshStandardMaterial({
                color: new THREE.Color(data.midCol),
                roughness: data.blurAmt ? (data.blurAmt / 80.0) : 0.3,
                metalness: 0.5
            });

            if (data.customTextureObj && data.customTextureObj.complete) {
                const tex = new THREE.Texture(data.customTextureObj);
                tex.wrapS = THREE.RepeatWrapping; tex.wrapT = THREE.RepeatWrapping; tex.needsUpdate = true;
                material.map = tex; material.color = new THREE.Color(0xffffff); 
                executeExport();
            } else { executeExport(); }

            function executeExport() {
                const mesh = new THREE.Mesh(geometry, material);
                const exporter = new THREE.GLTFExporter();
                exporter.parse(mesh, function (glb) {
                    const blob = new Blob([glb], { type: 'application/octet-stream' });
                    const url = URL.createObjectURL(blob);
                    const a = document.createElement('a'); a.href = url; a.download = 'puffy_model.glb'; a.click(); URL.revokeObjectURL(url);
                }, { binary: true });
            }
        }

        // ==========================================
        // CPU Noise / Voronoi Math Functions
        // ==========================================
        function cpuHash21(x, y) {
            let h = Math.sin(x * 12.9898 + y * 78.233) * 43758.5453;
            return h - Math.floor(h);
        }
        function cpuNoise(x, y) {
            let i_x = Math.floor(x), i_y = Math.floor(y);
            let f_x = x - i_x, f_y = y - i_y;
            let u_x = f_x*f_x*(3.0-2.0*f_x), u_y = f_y*f_y*(3.0-2.0*f_y);
            let a = cpuHash21(i_x, i_y), b = cpuHash21(i_x+1, i_y);
            let c = cpuHash21(i_x, i_y+1), d = cpuHash21(i_x+1, i_y+1);
            return a*(1-u_x)*(1-u_y) + b*u_x*(1-u_y) + c*(1-u_x)*u_y + d*u_x*u_y;
        }
        function cpuVoronoi(x, y) {
            let n_x = Math.floor(x), n_y = Math.floor(y);
            let f_x = x - n_x, f_y = y - n_y;
            let m = 8.0;
            for(let j=-1; j<=1; j++) {
                for(let i=-1; i<=1; i++) {
                    let g_x = i, g_y = j;
                    let p_x = n_x + g_x, p_y = n_y + g_y;
                    let h3_x = (p_x*0.1031 + p_y*0.1030) - Math.floor(p_x*0.1031 + p_y*0.1030);
                    let h3_y = (p_x*0.1030 + p_y*0.0973) - Math.floor(p_x*0.1030 + p_y*0.0973);
                    let h3_z = (p_x*0.0973 + p_y*0.1031) - Math.floor(p_x*0.0973 + p_y*0.1031);
                    let dot1 = h3_x*(h3_y+33.33) + h3_y*(h3_z+33.33) + h3_z*(h3_x+33.33);
                    h3_x += dot1; h3_y += dot1; h3_z += dot1;
                    let ox = (h3_x+h3_y)*h3_z; ox = ox - Math.floor(ox);
                    let oy = (h3_x+h3_z)*h3_y; oy = oy - Math.floor(oy);
                    let r_x = g_x + ox - f_x;
                    let r_y = g_y + oy - f_y;
                    let d = r_x*r_x + r_y*r_y;
                    if(d < m) m = d;
                }
            }
            return Math.sqrt(m);
        }
        function getCpuHeight(origNd, ux, uy, W, H, texType, curvePow, hillScale) {
            let nd = origNd;
            if (texType === 1) { // Crystal
                let v = cpuVoronoi(ux * 0.04, uy * 0.04);
                nd = Math.max(0.0, nd - v * 0.15);
                nd = Math.floor(nd * 6.0) / 6.0;
            } else if (texType === 2) { // Cloud
                let n = cpuNoise(ux * 0.03, uy * 0.03) * 0.5 + cpuNoise(ux * 0.06, uy * 0.06) * 0.25;
                nd = Math.max(0.0, nd + (n - 0.3) * 0.4 * nd);
            }
            return Math.pow(Math.max(0.0, nd), curvePow) * hillScale;
        }

        // ==========================================
        // WebGL (GLSL) レンダリング処理
        // ==========================================
        function renderBumpLitWebGL(W, H, maskImageData, dist, data) {
            let maxDist = 0;
            for (let i = 0; i < dist.length; i++) if (dist[i] > maxDist) maxDist = dist[i];
            if (maxDist < 1) maxDist = 1;

            const hillScale = Math.max(0.05, (data.hillHeight || 5) / 100.0);
            const curvePow = 0.2 + ((data.blurAmt ?? 1) / 80.0) * 1.3;
            const depthScale = Math.max(0.01, (data.depthAmt ?? 3) / 18.0);

            const hAt1 = Math.pow(1/maxDist, curvePow);
            const hAt2 = Math.pow(2/maxDist, curvePow);
            const dhPerPx = Math.max(hAt2 - hAt1, 0.00001);
            const bumpStr = (Math.tan(50 * Math.PI / 180) * depthScale * hillScale) / dhPerPx;

            const lightAng = (data.lightAngle || 135) * Math.PI / 180;
            const li = Math.max(0.0, (data.lightIntensity || 80) / 80.0);
            const Lx = Math.cos(lightAng)*0.6, Ly = -Math.sin(lightAng)*0.6, Lz = 0.8;
            const Llen = Math.sqrt(Lx*Lx+Ly*Ly+Lz*Lz);
            const lx=Lx/Llen, ly=Ly/Llen, lz=Lz/Llen;
            const Hlen2 = Math.sqrt(lx*lx+ly*ly+(lz+1)*(lz+1));
            const Hhx=lx/Hlen2, Hhy=ly/Hlen2, Hhz=(lz+1)/Hlen2;

            const hex2rgb = (h) => {
                h=(h||'#888888').replace('#','');
                return new THREE.Vector3(parseInt(h.slice(0,2),16)/255, parseInt(h.slice(2,4),16)/255, parseInt(h.slice(4,6),16)/255);
            };

            const tt = data.texture || 'pearl';
            const SP={slime:50, waterdrop:90, liquidmetal:150, crystal:80, cloud:10, water:80,sticker:40,chrome:200,gold:60,mercury:300,brushed:15,pearl:30,none:20};
            const SS={slime:1.5, waterdrop:2.5, liquidmetal:3.0, crystal:2.0, cloud:0.3, water:1.8,sticker:0.9,chrome:2.5,gold:1.8,mercury:3.5,brushed:0.4,pearl:1.2,none:0.2};
            const RS={slime:0.8, waterdrop:0.4, liquidmetal:0.9, crystal:0.5, cloud:0.1, water:0.5,sticker:0.6,chrome:0.7,gold:0.4,mercury:0.8,brushed:0.1,pearl:1.0,none:0.1};
            
            let texType = 0;
            if (tt === 'crystal') texType = 1;
            else if (tt === 'cloud') texType = 2;

            const renderer = getWebGLRenderer();
            renderer.setSize(W, H);
            const scene = new THREE.Scene();
            const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);

            const distArray = new Float32Array(W * H * 4);
            for(let i=0; i<W*H; i++) {
                distArray[i*4] = dist[i] / maxDist; 
                distArray[i*4+1] = maskImageData.data[i*4+3] / 255.0; 
                distArray[i*4+2] = 0; distArray[i*4+3] = 1;
            }
            const distTex = new THREE.DataTexture(distArray, W, H, THREE.RGBAFormat, THREE.FloatType);
            distTex.minFilter = THREE.NearestFilter; distTex.magFilter = THREE.NearestFilter; distTex.needsUpdate = true;

            let customTex = null; let texSize = new THREE.Vector2(1,1);
            if(data.customTextureObj && data.customTextureObj.complete) {
                customTex = new THREE.Texture(data.customTextureObj);
                customTex.wrapS = THREE.RepeatWrapping; customTex.wrapT = THREE.RepeatWrapping;
                customTex.minFilter = THREE.LinearFilter; customTex.needsUpdate = true;
                texSize.set(data.customTextureObj.naturalWidth || 1, data.customTextureObj.naturalHeight || 1);
            }

            const material = new THREE.ShaderMaterial({
                uniforms: {
                    uDistTex: { value: distTex },
                    uTex: { value: customTex },
                    uHasTex: { value: customTex ? 1 : 0 },
                    uTexSize: { value: texSize },
                    uTexType: { value: texType },
                    uHillScale: { value: hillScale },
                    uCurvePow: { value: curvePow },
                    uBumpStr: { value: bumpStr },
                    uColorC: { value: hex2rgb(data.centerCol) },
                    uColorM: { value: hex2rgb(data.midCol) },
                    uColorE: { value: hex2rgb(data.edgeCol) },
                    uLightDir: { value: new THREE.Vector3(lx, ly, lz) },
                    uHalfVec: { value: new THREE.Vector3(Hhx, Hhy, Hhz) },
                    uSpecPow: { value: SP[tt]||30 },
                    uSpecStr: { value: SS[tt]||1.2 },
                    uRimStr: { value: RS[tt]||1.0 },
                    uLightInt: { value: li },
                    uRes: { value: new THREE.Vector2(W, H) }
                },
                vertexShader: `
                    varying vec2 vUv;
                    void main() { vUv = uv; gl_Position = vec4(position, 1.0); }
                `,
                fragmentShader: `
                    uniform sampler2D uDistTex;
                    uniform sampler2D uTex;
                    uniform int uHasTex;
                    uniform int uTexType;
                    uniform vec2 uTexSize;
                    uniform float uHillScale;
                    uniform float uCurvePow;
                    uniform float uBumpStr;
                    uniform vec3 uColorC; uniform vec3 uColorM; uniform vec3 uColorE;
                    uniform vec3 uLightDir; uniform vec3 uHalfVec;
                    uniform float uSpecPow; uniform float uSpecStr; uniform float uRimStr;
                    uniform float uLightInt; uniform vec2 uRes;
                    varying vec2 vUv;

                    vec2 hash22(vec2 p) {
                        vec3 p3 = fract(vec3(p.xyx) * vec3(.1031, .1030, .0973));
                        p3 += dot(p3, p3.yzx+33.33);
                        return fract((p3.xx+p3.yz)*p3.zy);
                    }
                    float voronoi(vec2 x) {
                        vec2 n = floor(x); vec2 f = fract(x); float m = 8.0;
                        for(int j=-1; j<=1; j++)
                        for(int i=-1; i<=1; i++) {
                            vec2 g = vec2(float(i),float(j));
                            vec2 o = hash22(n + g);
                            vec2 r = g + o - f;
                            float d = dot(r,r);
                            if(d<m) m=d;
                        }
                        return sqrt(m);
                    }
                    float hash21(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
                    float snoise(vec2 p) {
                        vec2 i = floor(p); vec2 f = fract(p);
                        vec2 u = f*f*(3.0-2.0*f);
                        return mix(mix(hash21(i+vec2(0.0,0.0)), hash21(i+vec2(1.0,0.0)), u.x),
                                   mix(hash21(i+vec2(0.0,1.0)), hash21(i+vec2(1.0,1.0)), u.x), u.y);
                    }
                    float getH(vec2 uv) {
                        float nd = max(0.0, texture2D(uDistTex, uv).r);
                        if (uTexType == 1 && nd > 0.0) {
                            float v = voronoi(uv * uRes.x * 0.04);
                            nd = max(0.0, nd - v * 0.15);
                            nd = floor(nd * 6.0) / 6.0;
                        } else if (uTexType == 2 && nd > 0.0) {
                            float n = snoise(uv * uRes.x * 0.03) * 0.5 + snoise(uv * uRes.x * 0.06) * 0.25;
                            nd = max(0.0, nd + (n - 0.3) * 0.4 * nd);
                        }
                        return pow(nd, uCurvePow) * uHillScale;
                    }

                    void main() {
                        vec2 texUv = vec2(vUv.x, 1.0 - vUv.y);
                        float alpha = texture2D(uDistTex, texUv).g;
                        if (alpha <= 0.5) { gl_FragColor = vec4(0.0); return; }

                        float hC = getH(texUv);
                        vec2 px = vec2(1.0 / uRes.x, 1.0 / uRes.y);
                        float hR = getH(texUv + vec2(px.x, 0.0));
                        float hL = getH(texUv - vec2(px.x, 0.0));
                        float hDn = getH(texUv + vec2(0.0, px.y));
                        float hUp = getH(texUv - vec2(0.0, px.y));

                        float sx = -(hR - hL) * 0.5 * uBumpStr;
                        float sy = -(hDn - hUp) * 0.5 * uBumpStr; 
                        vec3 N = normalize(vec3(sx, sy, 1.0));

                        float NdotL = max(dot(N, uLightDir), 0.0);
                        float NdotV = max(N.z, 0.001);
                        float NdotH = max(dot(N, uHalfVec), 0.0);

                        float spec = pow(NdotH, uSpecPow) * uSpecStr;
                        float rim = pow(max(1.0 - NdotV, 0.0), 3.0) * uRimStr;

                        float safeHill = max(0.0001, uHillScale);
                        float t = clamp((hC * 2.0) / safeHill, 0.0, 1.0);
                        vec3 baseColor = mix(uColorE, uColorM, t);

                        if (uHasTex == 1) {
                            vec2 tcUv = mod(texUv * vec2(uRes.x / uTexSize.x, uRes.y / uTexSize.y), 1.0);
                            vec4 tc = texture2D(uTex, tcUv);
                            baseColor = mix(baseColor, tc.rgb, tc.a * 0.8);
                        }

                        float dome = 0.2 + 0.8 * clamp(hC / safeHill, 0.0, 1.0);
                        float diff = NdotL * 0.7 + 0.3;

                        vec3 finalColor = (baseColor * diff * dome + uColorC * (spec + rim)) * uLightInt;
                        gl_FragColor = vec4(clamp(finalColor, 0.0, 1.0), alpha);
                    }
                `
            });

            const plane = new THREE.Mesh(new THREE.PlaneGeometry(2, 2), material);
            scene.add(plane); renderer.render(scene, camera);

            const outCanvas = document.createElement('canvas');
            outCanvas.width = W; outCanvas.height = H;
            outCanvas.getContext('2d').drawImage(renderer.domElement, 0, 0);

            distTex.dispose(); material.dispose(); plane.geometry.dispose(); if(customTex) customTex.dispose();
            return outCanvas;
        }

        // CPUベースのフォールバックレンダラー
        function renderBumpLit(W, H, maskImageData, dist, data) {
            let maxDist = 0;
            for (let i = 0; i < dist.length; i++) if (dist[i] > maxDist) maxDist = dist[i];
            if (maxDist < 1) maxDist = 1;

            const hillScale = Math.max(0.05, (data.hillHeight || 5) / 100.0);
            const curvePow = 0.2 + ((data.blurAmt ?? 1) / 80.0) * 1.3;
            const depthScale = Math.max(0.01, (data.depthAmt ?? 3) / 18.0);
            
            let texType = 0;
            if (data.texture === 'crystal') texType = 1;
            else if (data.texture === 'cloud') texType = 2;

            const height = new Float32Array(W * H);
            for (let y = 0; y < H; y++) {
                for (let x = 0; x < W; x++) {
                    const i = y * W + x;
                    if (maskImageData.data[i * 4 + 3] <= 128) { height[i] = 0; continue; }
                    const nd = dist[i] / maxDist;
                    height[i] = getCpuHeight(nd, x, y, W, H, texType, curvePow, hillScale);
                }
            }

            const hAt1 = Math.pow(1/maxDist, curvePow);
            const hAt2 = Math.pow(2/maxDist, curvePow);
            const dhPerPx = Math.max(hAt2 - hAt1, 0.00001);
            const bumpStr = (Math.tan(50 * Math.PI / 180) * depthScale * hillScale) / dhPerPx;

            const Nx = new Float32Array(W * H);
            const Ny = new Float32Array(W * H);
            const Nz = new Float32Array(W * H);
            for (let y = 0; y < H; y++) {
                for (let x = 0; x < W; x++) {
                    const idx = y * W + x;
                    if (maskImageData.data[idx * 4 + 3] <= 128) { Nz[idx]=1; continue; }
                    const hC  = height[idx];
                    const hR  = (x < W-1) ? height[y*W+x+1] : hC;
                    const hL  = (x > 0)   ? height[y*W+x-1] : hC;
                    const hDn = (y < H-1) ? height[(y+1)*W+x] : hC;
                    const hUp = (y > 0)   ? height[(y-1)*W+x] : hC;
                    let sx = -(hR - hL) * 0.5 * bumpStr;
                    let sy = -(hDn - hUp) * 0.5 * bumpStr;
                    const nlen = Math.sqrt(sx*sx + sy*sy + 1.0) + 1e-9;
                    Nx[idx] = sx/nlen; Ny[idx] = sy/nlen; Nz[idx] = 1.0/nlen;
                }
            }

            const lightAng = (data.lightAngle || 135) * Math.PI / 180;
            const li = Math.max(0.0, (data.lightIntensity || 80) / 80.0);
            const Lx = Math.cos(lightAng)*0.6, Ly = -Math.sin(lightAng)*0.6, Lz = 0.8;
            const Llen = Math.sqrt(Lx*Lx+Ly*Ly+Lz*Lz);
            const lx=Lx/Llen, ly=Ly/Llen, lz=Lz/Llen;
            const Hlen2 = Math.sqrt(lx*lx+ly*ly+(lz+1)*(lz+1));
            const Hhx=lx/Hlen2, Hhy=ly/Hlen2, Hhz=(lz+1)/Hlen2;

            function hex2rgb(h) {
                h=(h||'#888888').replace('#','');
                return [parseInt(h.slice(0,2),16)/255,parseInt(h.slice(2,4),16)/255,parseInt(h.slice(4,6),16)/255];
            }
            const cH=hex2rgb(data.centerCol||'#ffffff');
            const cM=hex2rgb(data.midCol||'#8ab4cc');
            const cE=hex2rgb(data.edgeCol||'#1a2a3a');

            const tt=data.texture||'pearl';
            const SP={slime:50, waterdrop:90, liquidmetal:150, crystal:80, cloud:10, water:80,sticker:40,chrome:200,gold:60,mercury:300,brushed:15,pearl:30,none:20};
            const SS={slime:1.5, waterdrop:2.5, liquidmetal:3.0, crystal:2.0, cloud:0.3, water:1.8,sticker:0.9,chrome:2.5,gold:1.8,mercury:3.5,brushed:0.4,pearl:1.2,none:0.2};
            const RS={slime:0.8, waterdrop:0.4, liquidmetal:0.9, crystal:0.5, cloud:0.1, water:0.5,sticker:0.6,chrome:0.7,gold:0.4,mercury:0.8,brushed:0.1,pearl:1.0,none:0.1};
            const specPow=SP[tt]||30, specStr=SS[tt]||1.2, rimStr=RS[tt]||1.0;

            let texPixels=null, texW=0, texH=0;
            if(data.customTextureObj&&data.customTextureObj.complete){
                const tc=document.createElement('canvas');
                texW=tc.width=data.customTextureObj.naturalWidth||1;
                texH=tc.height=data.customTextureObj.naturalHeight||1;
                tc.getContext('2d').drawImage(data.customTextureObj,0,0);
                texPixels=tc.getContext('2d').getImageData(0,0,texW,texH).data;
            }

            const outCanvas=document.createElement('canvas');
            outCanvas.width=W; outCanvas.height=H;
            const outCtx=outCanvas.getContext('2d');
            const imgData=outCtx.createImageData(W,H);
            const px=imgData.data;

            for(let y=0;y<H;y++) for(let x=0;x<W;x++){
                const i=y*W+x;
                const alpha=maskImageData.data[i*4+3];
                if(alpha<=128){px[i*4+3]=0;continue;}
                const ht=height[i] / hillScale; 
                const nx=Nx[i],ny=Ny[i],nz=Nz[i];
                const NdotL=Math.max(nx*lx+ny*ly+nz*lz,0);
                const NdotV=Math.max(nz,0.001);
                const NdotH=Math.max(nx*Hhx+ny*Hhy+nz*Hhz,0);
                const spec=Math.pow(NdotH,specPow)*specStr;
                const rim=Math.pow(Math.max(1-NdotV,0),3)*rimStr;
                const t=Math.min(ht*2,1);
                let br=cE[0]+(cM[0]-cE[0])*t;
                let bg=cE[1]+(cM[1]-cE[1])*t;
                let bb=cE[2]+(cM[2]-cE[2])*t;
                if(texPixels&&texW>0){
                    const tx=Math.floor((x/W)*texW)%texW, ty2=Math.floor((y/H)*texH)%texH;
                    const ta=texPixels[(ty2*texW+tx)*4+3]/255, ti=(ty2*texW+tx)*4;
                    br=br*(1-ta*0.8)+(texPixels[ti]/255)*ta*0.8;
                    bg=bg*(1-ta*0.8)+(texPixels[ti+1]/255)*ta*0.8;
                    bb=bb*(1-ta*0.8)+(texPixels[ti+2]/255)*ta*0.8;
                }
                const dome=0.2+0.8*ht;
                const diff=NdotL*0.7+0.3;
                px[i*4+0]=Math.min(Math.max((br*diff*dome+cH[0]*(spec+rim))*li*255,0),255);
                px[i*4+1]=Math.min(Math.max((bg*diff*dome+cH[1]*(spec+rim))*li*255,0),255);
                px[i*4+2]=Math.min(Math.max((bb*diff*dome+cH[2]*(spec+rim))*li*255,0),255);
                px[i*4+3]=alpha;
            }
            outCtx.putImageData(imgData,0,0);
            return outCanvas;
        }

        function renderStrokeToCanvas(strokePath, W, H, offX, offY) {
            const maskImageData = rasterizePathToMask(strokePath, W, H, offX, offY);
            let hasPixels = false;
            for (let i = 3; i < maskImageData.data.length; i += 4) { if (maskImageData.data[i] > 128) { hasPixels = true; break; } }
            if (!hasPixels) return null;
            const dist = computeDistanceField(maskImageData, W, H);
            const data = strokePath.data;
            let canvas;
            if (document.getElementById('uiUseWebGL').checked && typeof THREE !== 'undefined') {
                try { canvas = renderBumpLitWebGL(W, H, maskImageData, dist, data); }
                catch(e) { console.warn("WebGL render failed, falling back to CPU", e); canvas = renderBumpLit(W, H, maskImageData, dist, data); }
            } else {
                canvas = renderBumpLit(W, H, maskImageData, dist, data);
            }
            return canvas;
        }

        function renderLiquidMetalSpot(spotGroup) {
            const oldRender = spotGroup.children['renderGroup'];
            if (oldRender) oldRender.remove();
            spotGroup.data.isRendered = true;
            const strokes = getStrokes(spotGroup);
            if (strokes.length === 0) return;

            let unionPath = getSpotUnionPath(spotGroup);
            if (unionPath.isEmpty()) { unionPath.remove(); return; }
            const bounds = unionPath.bounds;
            unionPath.remove();

            const pad = 20;
            const W = Math.ceil(bounds.width)  + pad * 2;
            const H = Math.ceil(bounds.height) + pad * 2;
            const offX = Math.floor(bounds.left) - pad;
            const offY = Math.floor(bounds.top)  - pad;

            const compositeCanvas = document.createElement('canvas');
            compositeCanvas.width = W; compositeCanvas.height = H;
            const compositeCtx = compositeCanvas.getContext('2d');

            // 筆跡ごとに個別の質感・色でレンダリングし、描いた順に重ねて合成する
            // (筆跡の境目では、それぞれ独立した丸みが付くため、重なり具合によって継ぎ目が見えることがあります)
            strokes.forEach(strokePath => {
                if (strokePath.isEmpty()) { strokePath._renderCache = null; return; }
                const canvas = renderStrokeToCanvas(strokePath, W, H, offX, offY);
                if (!canvas) { strokePath._renderCache = null; return; }
                strokePath._renderCache = { canvas, offX, offY };
                compositeCtx.drawImage(canvas, 0, 0);
            });

            const renderGroup = new paper.Group({ name: 'renderGroup' });
            const raster = new paper.Raster(compositeCanvas);
            raster.position = new paper.Point(offX + W/2, offY + H/2);
            renderGroup.addChild(raster);
            spotGroup.addChild(renderGroup);
        }

        function renderFluffySpot(spotGroup) { renderLiquidMetalSpot(spotGroup); }

        // ==========================================
        // リアルタイムプレビュー (ドラッグ中も即座に立体化する)
        // ==========================================
        let previewRAFPending = false;
        function scheduleFramePreview(fn) {
            if (previewRAFPending) return; // 1フレームにつき1回だけ実行して負荷を抑える
            previewRAFPending = true;
            requestAnimationFrame(() => { previewRAFPending = false; fn(); });
        }

        // draw/erase でドラッグ中: まだ確定していない筆跡込みの見た目をプレビューする
        function getLiveStrokeBounds(spotGroup) {
            let b = null;
            getStrokes(spotGroup).forEach(s => { if (!s.isEmpty()) b = b ? b.unite(s.bounds) : s.bounds.clone(); });
            draftQueue.forEach(item => { if (item.path && !item.path.isEmpty()) b = b ? b.unite(item.path.bounds) : item.path.bounds.clone(); });
            if (currentDraftPath && !currentDraftPath.isEmpty()) b = b ? b.unite(currentDraftPath.bounds) : currentDraftPath.bounds.clone();
            return b;
        }

        function rasterizeMaskFromPath(path, W, H, offX, offY) {
            const mc = document.createElement('canvas');
            mc.width = W; mc.height = H;
            const mctx = mc.getContext('2d');
            mctx.translate(-offX, -offY);
            let pathData = '';
            try {
                if (path instanceof paper.CompoundPath) pathData = path.children.map(c => c.pathData || '').join(' ').trim();
                else pathData = (path.pathData || '').trim();
            } catch(e) { pathData = ''; }
            if (pathData) {
                try { const p2d = new Path2D(pathData); mctx.fillStyle = 'rgba(255,255,255,1)'; mctx.fill(p2d, 'nonzero'); } catch(e) {}
            }
            return mctx.getImageData(0, 0, W, H);
        }

        // 既に確定している筆跡は(あれば)キャッシュ済みのレンダリング結果をそのまま貼り付けて高速化する
        function drawCachedOrFreshStroke(ctx, strokePath, W, H, offX, offY) {
            if (strokePath._renderCache && strokePath._renderCache.canvas) {
                const c = strokePath._renderCache;
                ctx.drawImage(c.canvas, c.offX - offX, c.offY - offY);
            } else {
                const canvas = renderStrokeToCanvas(strokePath, W, H, offX, offY);
                if (canvas) ctx.drawImage(canvas, 0, 0);
            }
        }

        // draw/erase でドラッグ中のプレビュー(まだ確定していない筆跡込み)
        function renderLivePreview(spotGroup) {
            if (!spotGroup || !spotGroup.data) return;
            const bounds = getLiveStrokeBounds(spotGroup);
            if (!bounds || bounds.width < 1 || bounds.height < 1) return;
            // 極端に大きいスポットはドラッグ中の負荷が大きいため、確定時(マウスアップ)のレンダーに任せる
            if (bounds.width * bounds.height > 900 * 900) return;

            const pad = 20;
            const W = Math.ceil(bounds.width) + pad * 2;
            const H = Math.ceil(bounds.height) + pad * 2;
            const offX = Math.floor(bounds.left) - pad;
            const offY = Math.floor(bounds.top) - pad;

            const compositeCanvas = document.createElement('canvas');
            compositeCanvas.width = W; compositeCanvas.height = H;
            const ctx = compositeCanvas.getContext('2d');

            // 既存の確定済み筆跡(キャッシュ利用で高速)
            getStrokes(spotGroup).forEach(strokePath => drawCachedOrFreshStroke(ctx, strokePath, W, H, offX, offY));

            if (mode === 'draw' && currentDraftPath && !currentDraftPath.isEmpty()) {
                // 描いている最中の筆跡を「今のパレット設定」でプレビュー
                currentDraftPath.data = captureCurrentStyle();
                const canvas = renderStrokeToCanvas(currentDraftPath, W, H, offX, offY);
                if (canvas) ctx.drawImage(canvas, 0, 0);
            } else if (mode === 'erase' && currentDraftPath && !currentDraftPath.isEmpty()) {
                // 消しているエリアを簡易マスクでプレビュー(実際の減算は確定時に筆跡ごとに正確に行う)
                const eraseMask = rasterizeMaskFromPath(currentDraftPath, W, H, offX, offY);
                const imgData = ctx.getImageData(0, 0, W, H);
                for (let i = 3; i < imgData.data.length; i += 4) { if (eraseMask.data[i] > 128) imgData.data[i] = 0; }
                ctx.putImageData(imgData, 0, 0);
            }

            const oldRender = spotGroup.children['renderGroup'];
            if (oldRender) oldRender.remove();
            const renderGroup = new paper.Group({ name: 'renderGroup' });
            const raster = new paper.Raster(compositeCanvas);
            raster.position = new paper.Point(offX + W/2, offY + H/2);
            renderGroup.addChild(raster);
            renderGroup.opacity = 0.8; // ドロー中の見た目(updateVisualsの表示ルール)に合わせる
            spotGroup.addChild(renderGroup);
        }

        // adjustモードでドラッグ中のプレビュー: 編集中の筆跡だけ再計算し、他はキャッシュを使い回す
        function renderAdjustLivePreview(spotGroup, editingStroke) {
            if (!spotGroup || !editingStroke) return;
            let bounds = null;
            getStrokes(spotGroup).forEach(s => { if (!s.isEmpty()) bounds = bounds ? bounds.unite(s.bounds) : s.bounds.clone(); });
            if (!bounds || bounds.width < 1 || bounds.height < 1) return;
            if (bounds.width * bounds.height > 900 * 900) return;

            const pad = 20;
            const W = Math.ceil(bounds.width) + pad * 2;
            const H = Math.ceil(bounds.height) + pad * 2;
            const offX = Math.floor(bounds.left) - pad;
            const offY = Math.floor(bounds.top) - pad;

            const compositeCanvas = document.createElement('canvas');
            compositeCanvas.width = W; compositeCanvas.height = H;
            const ctx = compositeCanvas.getContext('2d');

            getStrokes(spotGroup).forEach(strokePath => {
                if (strokePath === editingStroke) return; // 編集中のものは最後に重ねて描く
                drawCachedOrFreshStroke(ctx, strokePath, W, H, offX, offY);
            });
            if (!editingStroke.isEmpty()) {
                const liveCanvas = renderStrokeToCanvas(editingStroke, W, H, offX, offY);
                if (liveCanvas) ctx.drawImage(liveCanvas, 0, 0);
            }

            const oldRender = spotGroup.children['renderGroup'];
            if (oldRender) oldRender.remove();
            const renderGroup = new paper.Group({ name: 'renderGroup' });
            const raster = new paper.Raster(compositeCanvas);
            raster.position = new paper.Point(offX + W/2, offY + H/2);
            renderGroup.addChild(raster);
            spotGroup.addChild(renderGroup);
        }

        // ドラッグ中に1フレームおきで立体化プレビューを更新する共通ヘルパー
        // fromLiveStroke=true: draw/erase の未確定の筆跡込みでプレビュー(重いので簡易サイズ制限あり)
        // fromLiveStroke=false: adjust用。選択中の筆跡(selectedStrokeItem)だけ再計算してプレビュー
        function scheduleLiveSpotPreview(spotGroup, fromLiveStroke) {
            if (!spotGroup || !spotGroup.data || !spotGroup.data.isRendered) return;
            scheduleFramePreview(() => {
                if (fromLiveStroke) {
                    renderLivePreview(spotGroup);
                } else if (selectedStrokeItem && !selectedStrokeItem.isEmpty()) {
                    renderAdjustLivePreview(spotGroup, selectedStrokeItem);
                }
            });
        }

        function createTaperedPath(pointsData, maxRadius) {
            if (pointsData.length < 2) return new paper.Path.Circle({ center: pointsData[0].point, radius: Math.max(0.5, maxRadius / 2 * pointsData[0].pressure) });
            let spinePoints = pointsData.map(p => p.point);
            let spine = new paper.Path({ segments: spinePoints }); spine.simplify(2); let totalLength = spine.length;
            if (totalLength < 1) { spine.remove(); return new paper.Path.Circle({ center: spinePoints[0], radius: Math.max(0.5, maxRadius / 2 * pointsData[0].pressure) }); }
            let outline = new paper.Path(); let samples = Math.max(10, Math.floor(totalLength / 3)); 
            let topPoints = []; let bottomPoints = [];
            for (let i = 0; i <= samples; i++) {
                let t = i / samples; let offset = t * totalLength;
                let point = spine.getPointAt(offset); let normal = spine.getNormalAt(offset);
                if (!point || !normal) continue;
                let ratio = t * (pointsData.length - 1); let idx = Math.floor(ratio); let rem = ratio - idx;
                let pressure = 1;
                if (idx < pointsData.length - 1) { pressure = pointsData[idx].pressure * (1 - rem) + pointsData[idx+1].pressure * rem; } 
                else { pressure = pointsData[idx].pressure; }
                let radius = Math.max(0.5, Math.sin(t * Math.PI) * maxRadius * pressure);
                topPoints.push(point.add(normal.multiply(radius))); bottomPoints.unshift(point.subtract(normal.multiply(radius)));
            }
            outline.addSegments(topPoints); outline.addSegments(bottomPoints); outline.closed = true; outline.simplify(2);
            spine.remove(); return outline;
        }

        const tool = new paper.Tool();
        let strokePoints = []; let currentDraftPath = null;
        let activeSegment = null; let activeHandle = null;
        let actionCenter = null; let startAngle = 0; let startDist = 0;
        let adjustTargetLocation = null;
        let brushCursor = null; // 描く/消すモードで表示するブラシサイズのガイド円

        tool.onMouseDown = function(event) {
            if (event.event.button === 2) { 
                if (mode === 'adjust' && selectedStrokeItem && !selectedStrokeItem.isEmpty() && selectedStrokeItem.contains(event.point)) {
                    // 編集中の筆跡の上で右クリック→ベジェ制御点メニュー
                    let hitResultPath = selectedStrokeItem.hitTest(event.point, { segments: true, curve: true, stroke: true, tolerance: 8 });
                    if (hitResultPath) {
                        if (hitResultPath.type === 'segment') { activeSegment = hitResultPath.segment; adjustTargetLocation = null; }
                        else if (hitResultPath.type === 'curve' || hitResultPath.type === 'stroke') { adjustTargetLocation = hitResultPath.location; activeSegment = null; }
                    } else { adjustTargetLocation = null; }
                    
                    const menu = document.getElementById('adjust-menu');
                    const itemAdd = document.getElementById('am-add'); const itemDel = document.getElementById('am-delete');
                    itemAdd.style.display = adjustTargetLocation ? 'block' : 'none'; itemDel.style.display = activeSegment ? 'block' : 'none';
                    if (adjustTargetLocation || activeSegment) {
                        menu.style.display = 'block'; let x = event.event.clientX; let y = event.event.clientY;
                        if(x + menu.offsetWidth > window.innerWidth) x -= menu.offsetWidth; if(y + menu.offsetHeight > window.innerHeight) y -= menu.offsetHeight;
                        menu.style.left = x + 'px'; menu.style.top = y + 'px';
                    }
                    return;
                }
                // それ以外は、右クリックした位置の筆跡を選択してパレットから質感を変更できるようにする
                const hit = hitTestStrokeAt(event.point);
                if (hit) { selectStrokeForPalette(hit.spot, hit.stroke, event.event.clientX, event.event.clientY); }
                return;
            }

            const hitResult = paper.project.hitTest(event.point, { fill: true, stroke: true, tolerance: 4 });
            if (!hitResult && ['select', 'move', 'rotate', 'scale'].includes(mode)) { isPanning = true; return; }
            isPanning = false;

            if (mode === 'select') {
                if (hitResult) {
                    let item = hitResult.item; let spotItem = null;
                    while(item) { if(item.name && item.name.startsWith('Spot_')) { spotItem = item; break; } item = item.parent; } 
                    if(spotItem) selectSpot(spotItem, event.modifiers.control || event.modifiers.command); else clearSelection();
                } else clearSelection(); return;
            }

            if (mode === 'adjust') {
                if (!selectedStrokeItem || selectedStrokeItem.isEmpty()) return;
                let hitResultPath = selectedStrokeItem.hitTest(event.point, { segments: true, handles: true, tolerance: 8 });
                if (hitResultPath) {
                    if (hitResultPath.type === 'segment') {
                        if (event.modifiers.control || event.modifiers.command) {
                            let idx = selectedSegments.indexOf(hitResultPath.segment);
                            if (idx >= 0) selectedSegments.splice(idx, 1); else selectedSegments.push(hitResultPath.segment);
                        } else { if (!selectedSegments.includes(hitResultPath.segment)) selectedSegments = [hitResultPath.segment]; }
                        activeSegment = hitResultPath.segment; activeHandle = null;
                    }
                    else if (hitResultPath.type === 'handle-in' || hitResultPath.type === 'handle-out') {
                        activeHandle = hitResultPath.type; activeSegment = hitResultPath.segment;
                        if (!selectedSegments.includes(activeSegment)) selectedSegments = [activeSegment];
                    }
                } else {
                    let curveHit = selectedStrokeItem.hitTest(event.point, { curve: true, stroke: true, tolerance: 8 });
                    if (!curveHit || !(event.modifiers.control || event.modifiers.command)) { selectedSegments = []; activeSegment = null; activeHandle = null; }
                }
                updateVisuals(); return;
            }

            if (mode === 'fill') {
                if (selectedSpots.length === 0) return;
                commitVector(); let targetSpot = selectedSpots[0];
                let obstaclePath = new paper.Path();
                appLayers.forEach(layer => {
                    if (layer.data.isVisible !== false) {
                        layer.children.forEach(spot => {
                            if (spot.name && spot.name.startsWith('Spot_') && spot.data.isVisible !== false) {
                                let b = getSpotUnionPath(spot);
                                if (!b.isEmpty()) { let temp = obstaclePath.isEmpty() ? b.clone() : obstaclePath.unite(b); obstaclePath.remove(); obstaclePath = temp; }
                                b.remove();
                            }
                        });
                    }
                });
                let bgRect = new paper.Path.Rectangle(paper.view.bounds.expand(2000)); let inverted = bgRect.subtract(obstaclePath); obstaclePath.remove();
                let targetRegion = null;
                if (inverted instanceof paper.CompoundPath) {
                    let candidates = [];
                    for (let i = 0; i < inverted.children.length; i++) { if (inverted.children[i].contains(event.point)) candidates.push(inverted.children[i]); }
                    if (candidates.length > 0) { candidates.sort((a, b) => Math.abs(a.area) - Math.abs(b.area)); targetRegion = candidates[0]; }
                } else if (inverted.contains(event.point)) { targetRegion = inverted; }
                if (targetRegion) {
                    // 塗りつぶした領域は、今のパレット設定を持つ新しい筆跡として追加する
                    let newStroke = targetRegion.clone();
                    newStroke.name = 'Stroke_' + Date.now() + '_' + Math.floor(Math.random() * 100000);
                    newStroke.data = captureCurrentStyle();
                    newStroke.visible = false; newStroke.fillColor = null; newStroke.strokeColor = null; newStroke.strokeWidth = 0;
                    targetSpot.addChild(newStroke);
                    if (targetSpot.data.isRendered) renderFluffySpot(targetSpot); updateVisuals(); saveState();
                }
                inverted.remove(); bgRect.remove(); return;
            }

            if (mode === 'move' || mode === 'rotate' || mode === 'scale') {
                if (selectedSpots.length === 0) return;
                let bounds = selectedSpots[0].bounds; for(let i=1; i<selectedSpots.length; i++) bounds = bounds.unite(selectedSpots[i].bounds);
                actionCenter = bounds.center; startAngle = (event.point.subtract(actionCenter)).angle; startDist = event.point.getDistance(actionCenter); return;
            }

            if (selectedSpots.length === 0) { alert("ドローするスポットを追加するか選択してください。"); setMode('select'); return; }
            if (brushCursor) { brushCursor.remove(); brushCursor = null; }
            draftLayer.activate(); 
            let press = event.event.pressure !== undefined && event.event.pressure > 0 ? event.event.pressure : 1;
            strokePoints = [{ point: event.point, pressure: press }];
            currentDraftPath = new paper.Path.Circle({ center: event.point, radius: 1, fillColor: mode === 'draw' ? 'rgba(200, 200, 200, 0.6)' : 'rgba(255, 68, 68, 0.6)' });
        };

        tool.onMouseDrag = function(event) {
            if (isPanning) { 
                let ws = document.getElementById('workspace'); ws.scrollLeft -= event.event.movementX || 0; ws.scrollTop -= event.event.movementY || 0; return; 
            }
            if (mode === 'select' || mode === 'move') { selectedSpots.forEach(spot => spot.position = spot.position.add(event.delta)); updateSelectionBounds(); return; }
            if (mode === 'rotate' && actionCenter) {
                let currentAngle = (event.point.subtract(actionCenter)).angle; let deltaAngle = currentAngle - startAngle;
                selectedSpots.forEach(spot => spot.rotate(deltaAngle, actionCenter)); startAngle = currentAngle; updateSelectionBounds(); return;
            }
            if (mode === 'scale' && actionCenter && startDist > 0) {
                let currentDist = event.point.getDistance(actionCenter); let scaleFactor = currentDist / startDist;
                selectedSpots.forEach(spot => spot.scale(scaleFactor, actionCenter)); startDist = currentDist; updateSelectionBounds(); return;
            }
            if (mode === 'adjust') {
                if (activeHandle === 'handle-in' && activeSegment) { activeSegment.handleIn = activeSegment.handleIn.add(event.delta); }
                else if (activeHandle === 'handle-out' && activeSegment) { activeSegment.handleOut = activeSegment.handleOut.add(event.delta); }
                else if (selectedSegments.length > 0) { selectedSegments.forEach(seg => { seg.point = seg.point.add(event.delta); }); }
                if (selectedStrokeItem && selectedStrokeItem.parent) scheduleLiveSpotPreview(selectedStrokeItem.parent, false);
                return;
            }
            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                if (event.point.getDistance(strokePoints[strokePoints.length - 1].point) > 2) {
                    let press = event.event.pressure !== undefined && event.event.pressure > 0 ? event.event.pressure : 1;
                    strokePoints.push({ point: event.point, pressure: press }); currentDraftPath.remove();
                    currentDraftPath = createTaperedPath(strokePoints, brushRadius);
                    currentDraftPath.fillColor = mode === 'draw' ? 'rgba(200, 200, 200, 0.6)' : 'rgba(255, 68, 68, 0.6)';
                    if (selectedSpots.length > 0) scheduleLiveSpotPreview(selectedSpots[0], true);
                }
            }
        };

        tool.onMouseUp = function(event) {
            if (isPanning) { isPanning = false; return; }
            if (mode === 'adjust') {
                if (selectedStrokeItem && selectedStrokeItem.parent && selectedStrokeItem.parent.data.isRendered) renderFluffySpot(selectedStrokeItem.parent);
                updateSelectionBounds(); activeHandle = null; updateVisuals(); saveState(); return;
            }
            if ((mode === 'move' || mode === 'rotate' || mode === 'scale') && selectedSpots.length > 0 && actionCenter) {
                selectedSpots.forEach(spot => { if (spot.data.isRendered) renderFluffySpot(spot); }); actionCenter = null; saveState();
            }
            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                currentDraftPath.simplify(2); draftQueue.push({ path: currentDraftPath, mode: mode });
                currentDraftPath = null; strokePoints = [];
                // ストローク確定と同時にベクター統合・3Dレンダーを自動実行(ボタン操作は不要)
                commitVector();
            }
        };

        tool.onMouseMove = function(event) {
            if (brushCursor) { brushCursor.remove(); brushCursor = null; }
            if (mode === 'draw' || mode === 'erase') {
                uiLayer.activate();
                brushCursor = new paper.Path.Circle({
                    center: event.point, radius: brushRadius,
                    strokeColor: mode === 'draw' ? 'rgba(79,195,247,0.9)' : 'rgba(255,68,68,0.9)',
                    strokeWidth: 1.5 / paper.view.zoom,
                    dashArray: [4 / paper.view.zoom, 3 / paper.view.zoom]
                });
                brushCursor.data = { isHelper: true };
                paper.view.update();
            }
        };

        workspaceElem.addEventListener('mouseleave', () => {
            if (brushCursor) { brushCursor.remove(); brushCursor = null; paper.view.update(); }
        });

        window.adjustMenuAction = function(action) {
            document.getElementById('adjust-menu').style.display = 'none';
            const spot = selectedStrokeItem && selectedStrokeItem.parent;
            if(action === 'add' && adjustTargetLocation) {
                let newCurve = adjustTargetLocation.curve.divideAt(0.5); 
                if(newCurve) { selectedSegments = [newCurve.segment1]; activeSegment = newCurve.segment1; }
                if(spot && spot.data.isRendered) renderFluffySpot(spot);
                updateVisuals(); saveState();
            } else if(action === 'delete' && activeSegment) {
                activeSegment.remove(); activeSegment = null; selectedSegments = [];
                if(spot && spot.data.isRendered) renderFluffySpot(spot);
                updateVisuals(); saveState();
            }
            adjustTargetLocation = null;
        };

        function selectSpot(spot, isMulti) {
            commitVector(); 
            if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
            selectedStrokeItem = null;
            if (!isMulti) selectedSpots = [];
            if (!selectedSpots.includes(spot)) selectedSpots.push(spot);
            else if (isMulti) selectedSpots = selectedSpots.filter(s => s !== spot);

            propPanel.style.opacity = selectedSpots.length > 0 ? '1' : '0.5';
            propPanel.style.pointerEvents = selectedSpots.length > 0 ? 'auto' : 'none';
            
            if (selectedSpots.length > 0 && selectedSpots[0].parent && selectedSpots[0].parent.name && selectedSpots[0].parent.name.startsWith('Layer_')) {
                activeLayer = selectedSpots[0].parent; selectedLayers = [activeLayer];
            }
            if (selectedSpots.length > 0) switchTab('tab-prop'); // 選んだらすぐ色・質感パレットを触れるようにする
            updateVisuals(); 
        }

        function clearSelection() {
            commitVector();
            if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
            selectedStrokeItem = null;
            selectedSpots = []; propPanel.style.opacity = '0.5'; propPanel.style.pointerEvents = 'none'; updateVisuals();
        }

        function updateSelectionBounds() {
            if (selectionRect) { selectionRect.remove(); selectionRect = null; }
            if (selectedSpots.length === 0 || mode === 'adjust') return; 
            uiLayer.activate(); let bounds = selectedSpots[0].bounds;
            for(let i=1; i<selectedSpots.length; i++) bounds = bounds.unite(selectedSpots[i].bounds);
            selectionRect = new paper.Path.Rectangle(bounds);
            selectionRect.strokeColor = '#7289da'; selectionRect.strokeWidth = 2 / paper.view.zoom; selectionRect.dashArray = [4 / paper.view.zoom, 4 / paper.view.zoom];
        }

        window.execAction = function(action) {
            if (action === 'undo') { if (undoStack.length > 1) { redoStack.push(undoStack.pop()); restoreState(undoStack[undoStack.length - 1]); updateUndoRedoButtons(); showToast('元に戻しました'); } return; }
            if (action === 'redo') { if (redoStack.length > 0) { let state = redoStack.pop(); undoStack.push(state); restoreState(state); updateUndoRedoButtons(); showToast('やり直しました'); } return; }
            commitVector(); 
            if (action === 'addSpot') {
                if(!activeLayer) { alert('レイヤーがありません。'); return; }
                spotCounter++; const spotGroup = new paper.Group({ name: 'Spot_' + Date.now() });
                spotGroup.data = { isRendered: true, isVisible: true, displayName: 'スポット ' + spotCounter, id: Date.now() + Math.random() };
                activeLayer.addChild(spotGroup);
                selectSpot(spotGroup, false); setMode('draw'); saveState();
            }
            else if (action === 'vectorize') {
                // 3Dレンダリングを一時OFFにして、下絵のベクター形状のみを表示する(重い時や形状だけ調整したい時用)
                if (selectedSpots.length > 0) {
                    selectedSpots.forEach(spot => {
                        const rg = spot.children['renderGroup']; if (rg) rg.remove();
                        spot.data.isRendered = false;
                    });
                    saveState();
                }
                updateVisuals();
            }
            else if (action === 'render') {
                // 選択中のスポットを今すぐ3Dレンダリング(自動立体化をOFFにしていた場合の再開・手動更新用)
                if (selectedSpots.length > 0) { selectedSpots.forEach(spot => renderFluffySpot(spot)); saveState(); }
                updateVisuals();
            }
            else if (action === 'delete') { selectedSpots.forEach(s => s.remove()); clearSelection(); saveState(); }
            else if (action === 'unite') {
                if (selectedSpots.length < 2) return;
                let targetLayer = selectedSpots[0].parent;
                spotCounter++;
                let newSpot = new paper.Group({ name: 'Spot_' + Date.now() });
                newSpot.data = { isRendered: true, isVisible: true, displayName: '統合スポット ' + spotCounter, id: Date.now() + Math.random() };
                // 各スポットの筆跡を、それぞれの質感データを保持したまま新しいスポットへ移す
                selectedSpots.forEach(spot => { getStrokes(spot).forEach(strokePath => newSpot.addChild(strokePath)); });
                selectedSpots.forEach(s => s.remove());
                clearSelection();
                targetLayer.addChild(newSpot);
                renderFluffySpot(newSpot);
                selectSpot(newSpot, false); saveState();
            }
        };

        window.fileAction = function(action) {
            commitVector();
            if (action === 'new') {
                if(confirm('現在の作業内容は失われます。新規作成しますか?')) {
                    appLayers.forEach(l => l.remove()); appLayers = []; draftLayer.removeChildren();
                    spotCounter = 0; layerCounter = 0; clearSelection(); activeLayer = createNewLayer(); execAction('addSpot');
                }
            } else if (action === 'save') {
                let exportData = { layers: appLayers.map(l => l.exportJSON()) }; const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData));
                const a = document.createElement('a'); a.setAttribute("href", dataStr); a.setAttribute("download", "fluffy_project.json"); a.click();
            } else if (action === 'open') { document.getElementById('fileLoader').click(); } 
            else if (action === 'png' || action === 'svg') {
                const oldMode = mode; setMode('select'); clearSelection(); paper.project.deselectAll(); 
                let previousZoom = currentZoom; changeZoom(null); 
                appLayers.forEach(layer => {
                    layer.children.forEach(spot => {
                        if (spot.name && spot.name.startsWith('Spot_') && spot.data.isVisible !== false) {
                            if (!spot.data.isRendered) renderFluffySpot(spot);
                            getStrokes(spot).forEach(s => { s.visible = false; s.selected = false; });
                            const flat = spot.children['flatPreview']; if (flat) flat.remove();
                            let renderGrp = spot.children['renderGroup']; if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = spot.opacity; }
                        }
                    });
                });
                uiLayer.visible = false; draftLayer.visible = false; paper.view.update(); 
                setTimeout(() => {
                    if (action === 'png') { const a = document.createElement('a'); a.href = document.getElementById('myCanvas').toDataURL('image/png'); a.download = 'fluffy_export.png'; a.click(); } 
                    else if (action === 'svg') {
                        let svgStr = paper.project.exportSVG({asString: true}); let blob = new Blob([svgStr], {type: "image/svg+xml;charset=utf-8"});
                        let url = URL.createObjectURL(blob); let a = document.createElement('a'); a.href = url; a.download = 'fluffy_export.svg'; a.click(); URL.revokeObjectURL(url);
                    }
                    uiLayer.visible = true; draftLayer.visible = true; changeZoom(previousZoom); setMode(oldMode); 
                }, 100);
            } else if (action === 'glb') {
                exportGLB();
            } else if (action === 'resize') {
                const w = prompt('新しい幅を入力', baseWidth); const h = prompt('新しい高さを入力', baseHeight);
                if (w && h) { baseWidth = parseInt(w); baseHeight = parseInt(h); changeZoom(1); }
            }
        };

        document.getElementById('fileLoader').addEventListener('change', function(e) {
            const file = e.target.files[0]; if (!file) return; const reader = new FileReader();
            reader.onload = function(evt) {
                try {
                    let parsed = JSON.parse(evt.target.result); appLayers.forEach(l => l.remove()); appLayers = [];
                    if(parsed.layers) {
                        parsed.layers.forEach(lData => { let newL = new paper.Layer(); newL.importJSON(lData); appLayers.push(newL);
                            newL.children.forEach(spot => { if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot); });
                        });
                    }
                    activeLayer = appLayers[0]; selectedLayers = [activeLayer]; clearSelection(); saveState();
                } catch(err) { alert("ファイルの読み込みに失敗しました。"); }
            }; reader.readAsText(file); this.value = ''; 
        });

        document.addEventListener('keydown', (e) => {
            if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
                e.preventDefault(); const panStep = 30; let ws = document.getElementById('workspace');
                if (e.key === 'ArrowUp') ws.scrollTop -= panStep; if (e.key === 'ArrowDown') ws.scrollTop += panStep;
                if (e.key === 'ArrowLeft') ws.scrollLeft -= panStep; if (e.key === 'ArrowRight') ws.scrollLeft += panStep; return;
            }
            if (e.key === 'PageUp') { e.preventDefault(); changeZoom(1.2); }
            if (e.key === 'PageDown') { e.preventDefault(); changeZoom(1 / 1.2); }
            if (e.key === 'Delete') { if(selectedSpots.length > 0) cmAction('delete'); }
            if (e.ctrlKey || e.metaKey) {
                if (e.key.toLowerCase() === 'c') { e.preventDefault(); cmAction('copy'); }
                if (e.key.toLowerCase() === 'v') { e.preventDefault(); cmAction('paste'); }
                if (e.key.toLowerCase() === 'x') { e.preventDefault(); cmAction('cut'); }
                if (e.key.toLowerCase() === 'j') { e.preventDefault(); cmAction('merge'); }
                if (e.key.toLowerCase() === 's') { e.preventDefault(); fileAction('save'); }
                if (e.key.toLowerCase() === 'z') { e.preventDefault(); execAction('undo'); }
                if (e.key.toLowerCase() === 'y') { e.preventDefault(); execAction('redo'); }
            }
        });
        
        changeZoom(null); 
        execAction('addSpot');

    </script>
</body>
</html>

いいなと思ったら応援しよう!

ピックアップされています

便利なツール

  • 95本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとか要望は、 コメント欄か、Xのリプライ欄に書いてみて下さい。 ひまをみて対応します。 (未管理著作物裁定制度に定められた問い合わせも受付中。)
描いたところが立体になる「SlimePainter」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1