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



画像


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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Slime Painter 1.0</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.17/paper-full.min.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); }
        .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; }
        .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 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; }
    </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('obj')" style="color:var(--accent); font-weight:bold;">OBJ出力 (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>
    </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" onclick="switchTab('tab-layers')">レイヤー</div>
                <div class="tab-btn" onclick="switchTab('tab-spots')">スポット</div>
                <div class="tab-btn" 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;">※右クリックでメニューを表示</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;">※右クリックでメニューを表示</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="water">💧 水滴・スライム</option>
                            <option value="sticker">🏷️ モコモコシール</option>
                            <option value="chrome">🪞 クロムメタル</option>
                            <option value="gold">✨ ゴールド</option>
                            <option value="mercury">🌊 マーキュリー</option>
                            <option value="brushed">🔩 ブラッシュドメタル</option>
                            <option value="pearl">🫧 パール</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>
                        <div style="display:flex; justify-content:space-between; align-items:center;">
                            <label style="font-size: 0.8em; color: var(--text-muted);">グラデーションパレット</label>
                            <button class="action-btn-outline" style="padding:2px 5px; font-size:0.7em; margin:0; width:auto;" onclick="openPaletteLibrary()">もっと見る...</button>
                        </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">90</span>%</label>
                        <input type="range" id="uiLightIntensity" min="10" max="200" value="90" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>
                    
                    <div class="prop-group">
                        <label>丘の高さ (盛り上がり): <span id="valHillHeight">75</span></label>
                        <input type="range" id="uiHillHeight" min="5" max="100" value="75" oninput="updateProps({target: this})" onchange="saveState()">
                    </div>

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

                    <div class="prop-group">
                        <label>反射の粗さ: <span id="valBlur">55</span></label>
                        <input type="range" id="uiBlur" min="1" max="80" value="55" 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')">■ ベクター化</button>
                        <button class="action-btn action-btn-green" style="flex: 1.5; padding: 8px 5px; margin-top: 0;" onclick="execAction('render')">💎 質感レンダー</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-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">

    <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 clipboardData = null;
        let clipboardType = ''; 

        let selectionRect = null;
        let isPanning = false;
        let cmTargetType = ''; 
        let cmTargetItem = null;

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

        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;
        });

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

        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 gradientPalette = JSON.parse(localStorage.getItem('liquid_metal_palette_v1')) || [
            {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'},
        ];

        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'},
            {c: '#f8f8f8', m: '#404040', e: '#080808'}, {c: '#ffffe0', m: '#e0d000', e: '#303000'},
            {c: '#ffe0f8', m: '#c040a0', e: '#280020'}, {c: '#e0fff8', m: '#20b090', e: '#003828'},
        ];

        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.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
                cell.onclick = () => {
                    uiCenterCol.value = colors.c; uiMidCol.value = colors.m; uiEdgeCol.value = colors.e;
                    updateProps({target: uiCenterCol}); updateProps({target: uiMidCol}); updateProps({target: uiEdgeCol});
                    saveState(); document.getElementById('palette-dialog').style.display='none';
                };
                grid.appendChild(cell);
            });
            document.getElementById('palette-dialog').style.display='flex';
        };

        function renderPalette() {
            const grid = document.getElementById('palette-grid');
            grid.innerHTML = '';
            gradientPalette.forEach((colors, i) => {
                let cell = document.createElement('div');
                cell.className = 'palette-cell';
                cell.style.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
                cell.draggable = true;
                cell.title = "ドラッグ&ドロップで3色セットを適用・保存";
                cell.ondragstart = (e) => { e.dataTransfer.setData('application/json', JSON.stringify(colors)); };
                cell.ondragover = (e) => e.preventDefault();
                cell.ondrop = (e) => {
                    e.preventDefault(); let jsonData = e.dataTransfer.getData('application/json'); 
                    if (jsonData) {
                        try {
                            let parsed = JSON.parse(jsonData);
                            if (parsed.c && parsed.m && parsed.e) {
                                gradientPalette[i] = parsed; localStorage.setItem('liquid_metal_palette_v1', JSON.stringify(gradientPalette));
                                renderPalette();
                            }
                        } catch(err) {}
                    }
                };
                grid.appendChild(cell);
            });
        }
        renderPalette();

        window.handleColorDragStart = function(e, inputId) { 
            e.dataTransfer.setData('text/plain', document.getElementById(inputId).value); 
            e.dataTransfer.setData('source_id', 'current_colors');
            let colors = { c: uiCenterCol.value, m: uiMidCol.value, e: uiEdgeCol.value };
            e.dataTransfer.setData('application/json', JSON.stringify(colors));
        }
        
        window.handleColorDrop = function(e, inputId) {
            e.preventDefault(); let jsonData = e.dataTransfer.getData('application/json');
            if (jsonData) {
                try {
                    let parsed = JSON.parse(jsonData);
                    if (parsed.c && parsed.m && parsed.e) {
                        uiCenterCol.value = parsed.c; uiMidCol.value = parsed.m; uiEdgeCol.value = parsed.e;
                        updateProps({target: uiCenterCol}); updateProps({target: uiMidCol}); updateProps({target: uiEdgeCol}); saveState(); return;
                    }
                } catch(err) {}
            }
            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 (selectedSpots.length > 0) {
                            selectedSpots.forEach(spot => {
                                spot.data.customTexture = dataUrl;
                                spot.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 (selectedSpots.length > 0) {
                selectedSpots.forEach(spot => {
                    spot.data.customTexture = null;
                    spot.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 };
            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; };

        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;
                }
            }
            if (selectedSpots.length > 0) {
                selectedSpots.forEach(spot => {
                    spot.data.centerCol = uiCenterCol.value; spot.data.midCol = uiMidCol.value; spot.data.edgeCol = uiEdgeCol.value;
                    spot.data.patternType = uiPattern.value || 'none';
                    spot.data.blurAmt = parseInt(uiBlur.value); spot.data.depthAmt = parseInt(uiDepth.value);
                    spot.data.lightAngle = parseInt(uiLightAngle.value) || 135;
                    spot.data.lightIntensity = parseInt(uiLightIntensity.value) || 90;
                    spot.data.hillHeight = parseInt(uiHillHeight.value) || 75;
                    spot.data.texture = uiTexture.value || 'water';
                    if(spot.data.isRendered) renderFluffySpot(spot);
                });
            }
        };

        window.switchTab = function(tabId) {
            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
            event.target.classList.add('active'); document.getElementById(tabId).classList.add('active');
        };

        function commitVector() {
            if (draftQueue.length === 0 || selectedSpots.length === 0) return;
            let targetSpot = selectedSpots[0]; let basePath = targetSpot.children['basePath'];
            if (!basePath) return;

            let parentLayer = targetSpot.parent; if(parentLayer) parentLayer.activate();
            let newBasePath = basePath.clone(); newBasePath.visible = true;
            
            for (let item of draftQueue) {
                if (item.mode === 'draw') {
                    let temp = newBasePath.isEmpty() ? item.path.clone() : newBasePath.unite(item.path);
                    newBasePath.remove(); newBasePath = temp;
                } else if (item.mode === 'erase' && !newBasePath.isEmpty()) {
                    let temp = newBasePath.subtract(item.path); newBasePath.remove(); newBasePath = temp;
                }
                item.path.remove(); 
            }
            draftQueue = []; 
            targetSpot.children['basePath'].remove(); newBasePath.name = 'basePath'; targetSpot.addChild(newBasePath);
            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 => {
                let div = document.createElement('div'); div.className = 'list-item' + (selectedLayers.includes(layer) ? ' selected' : '');
                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' : '');
                    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');
                        
                        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;
                    let base = spot.children['basePath'];
                    let renderGrp = spot.children['renderGroup'];
                    
                    if (spot.data.isVisible === false || layer.data.isVisible === false) {
                        base.visible = false; base.selected = false;
                        if(renderGrp) renderGrp.visible = false; return;
                    }

                    base.selected = false; base.strokeColor = null; base.strokeWidth = 0; base.fillColor = null;

                    if (mode === 'adjust' && selectedSpots.includes(spot)) {
                        base.visible = true; base.selected = false; 
                        base.strokeColor = '#00aaff'; base.strokeWidth = 1.5; base.fillColor = 'rgba(0,170,255,0.05)';
                        base.bringToFront(); 
                        
                        let segs = getPathSegments(base);
                        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 ((mode === 'draw' || mode === 'erase' || mode === 'fill') && selectedSpots.includes(spot)) {
                        base.visible = true; base.fillColor = 'rgba(255, 255, 255, 0.01)'; 
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.8; }
                    } else {
                        base.visible = false; 
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
                    }

                    if (!spot.data.isRendered && mode !== 'adjust') {
                        base.visible = true; base.fillColor = 'rgba(200, 200, 200, 0.5)'; 
                        if (renderGrp) renderGrp.visible = false;
                    }
                });
            });

            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 (mode !== 'adjust') {
                activeSegment = null; activeHandle = null; adjustTargetLocation = null;
                selectedSegments = [];
                // selectedSpots は維持する(モード切替で選択解除しない)
                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' : '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;
            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'].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(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;
                    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; 
                    activeLayer.addChild(pasted); if (pasted.data.isRendered) renderFluffySpot(pasted);
                    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 hexToRgb01(hex) {
            let r = parseInt(hex.slice(1,3),16)/255;
            let g = parseInt(hex.slice(3,5),16)/255;
            let b = parseInt(hex.slice(5,7),16)/255;
            return [r,g,b];
        }

        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;
        }

        function buildNormalMapFromDist(dist, W, H, hillStrength, curvePow) {
            let maxDist = 0;
            for (let i = 0; i < dist.length; i++) { if (dist[i] > maxDist) maxDist = dist[i]; }
            if (maxDist < 1) maxDist = 1;
            const height = new Float32Array(W * H);
            for (let i = 0; i < W * H; i++) {
                const nd = dist[i] / maxDist;
                height[i] = nd > 0 ? Math.pow(nd, 1.0 / curvePow) : 0;
            }
            const bumpScale = hillStrength * maxDist * 6.0;
            const normalMap = new Uint8ClampedArray(W * H * 4);
            for (let y = 0; y < H; y++) {
                for (let x = 0; x < W; x++) {
                    const idx = y * W + x;
                    if (height[idx] === 0) {
                        normalMap[idx*4+0] = 128; normalMap[idx*4+1] = 128; normalMap[idx*4+2] = 255; normalMap[idx*4+3] = 0; continue;
                    }
                    const hR = (x+1 < W) ? height[y*W + x+1] : 0;
                    const hL = (x-1 >= 0) ? height[y*W + x-1] : 0;
                    const hD = (y+1 < H) ? height[(y+1)*W + x] : 0;
                    const hU = (y-1 >= 0) ? height[(y-1)*W + x] : 0;
                    const dhdx = (hR - hL) * 0.5; const dhdy = (hD - hU) * 0.5;
                    const sx = -dhdx * bumpScale; const sy = -dhdy * bumpScale;
                    const len = Math.sqrt(sx*sx + sy*sy + 1.0);
                    const nx = sx / len; const ny = sy / len; const nz = 1.0 / len;
                    normalMap[idx*4+0] = Math.round((nx * 0.5 + 0.5) * 255);
                    normalMap[idx*4+1] = Math.round((ny * 0.5 + 0.5) * 255);
                    normalMap[idx*4+2] = Math.round((nz * 0.5 + 0.5) * 255);
                    normalMap[idx*4+3] = Math.round(height[idx] * 255);
                }
            }
            return normalMap;
        }

        // ==========================================
        // ユーザー提供の完全改良版 OBJエクスポート処理
        // ==========================================
        function exportOBJ() {
            if (selectedSpots.length === 0) { alert('3Dエクスポートするスポットを選択してください。'); return; }
            const spotGroup = selectedSpots[0];
            const data = spotGroup.data;
            const basePath = spotGroup.children['basePath'];
            if (!basePath || basePath.isEmpty()) 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);
            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 = 2.0; 
            const hillZ = (data.hillHeight || 60) / 100.0 * 50.0; 

            let objStr = "# Liquid Metal Painter 3D Export - improved\n";
            objStr += "o PuffySticker\n";

            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 = nd > 0 ? Math.pow(nd, 1.0 / curvePow) : 0; 
                    const z = h * hillZ; 

                    const x = (tx * W) - (W / 2);
                    const y = -((ty * H) - (H / 2));

                    objStr += `v ${x.toFixed(6)} ${y.toFixed(6)} ${z.toFixed(6)}\n`;
                    objStr += `vt ${tx.toFixed(6)} ${(1.0 - ty).toFixed(6)}\n`;

                    const idx = iy * vertsCountX + ix;
                    heightGrid[idx] = z;
                }
            }

            const normals = new Float32Array(totalTopVerts * 3); 
            for (let iy = 0; iy <= segY; iy++) {
                for (let ix = 0; ix <= segX; ix++) {
                    const idx = iy * vertsCountX + ix;
                    
                    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;
                    let ny = -dy;
                    let nz = 1.0;
                    const len = Math.sqrt(nx*nx + ny*ny + nz*nz) + 1e-12;
                    nx /= len; ny /= len; nz /= len;

                    normals[idx*3+0] = nx;
                    normals[idx*3+1] = ny;
                    normals[idx*3+2] = nz;

                    objStr += `vn ${nx.toFixed(6)} ${ny.toFixed(6)} ${nz.toFixed(6)}\n`;
                }
            }

            for (let iy = 0; iy < segY; iy++) {
                for (let ix = 0; ix < segX; ix++) {
                    const i0 = iy * vertsCountX + ix + 1; 
                    const i1 = i0 + 1;
                    const i2 = i0 + vertsCountX;
                    const i3 = i2 + 1;
                    objStr += `f ${i0}/${i0}/${i0} ${i1}/${i1}/${i1} ${i2}/${i2}/${i2}\n`;
                    objStr += `f ${i2}/${i2}/${i2} ${i1}/${i1}/${i1} ${i3}/${i3}/${i3}\n`;
                }
            }

            const bottomOffset = totalTopVerts; 
            for (let iy = 0; iy <= segY; iy++) {
                for (let ix = 0; ix <= segX; ix++) {
                    const tx = ix / segX;
                    const ty = iy / segY;
                    const x = (tx * W) - (W / 2);
                    const y = -((ty * H) - (H / 2));
                    const zBottom = 0.0; 
                    objStr += `v ${x.toFixed(6)} ${y.toFixed(6)} ${zBottom.toFixed(6)}\n`;
                    objStr += `vt ${tx.toFixed(6)} ${(1.0 - ty).toFixed(6)}\n`;
                    objStr += `vn 0.000000 0.000000 -1.000000\n`;
                }
            }

            for (let iy = 0; iy < segY; iy++) {
                for (let ix = 0; ix < segX; ix++) {
                    const bi0 = bottomOffset + (iy * vertsCountX + ix) + 1;
                    const bi1 = bi0 + 1;
                    const bi2 = bi0 + vertsCountX;
                    const bi3 = bi2 + 1;
                    
                    objStr += `f ${bi2}/${bi2}/${bi2} ${bi1}/${bi1}/${bi1} ${bi0}/${bi0}/${bi0}\n`;
                    objStr += `f ${bi3}/${bi3}/${bi3} ${bi1}/${bi1}/${bi1} ${bi2}/${bi2}/${bi2}\n`;
                }
            }

            function topIndex(ix, iy) { return (iy * vertsCountX + ix) + 1; } 
            function bottomIndex(ix, iy) { return bottomOffset + (iy * vertsCountX + ix) + 1; } 

            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);
                    objStr += `f ${t1}/${t1}/${t1} ${t0}/${t0}/${t0} ${b0}/${b0}/${b0}\n`;
                    objStr += `f ${b0}/${b0}/${b0} ${b1}/${b1}/${b1} ${t1}/${t1}/${t1}\n`;
                }
                {
                    const t0 = topIndex(segX, iy), t1 = topIndex(segX, iy+1);
                    const b0 = bottomIndex(segX, iy), b1 = bottomIndex(segX, iy+1);
                    objStr += `f ${t0}/${t0}/${t0} ${t1}/${t1}/${t1} ${b0}/${b0}/${b0}\n`;
                    objStr += `f ${b0}/${b0}/${b0} ${t1}/${t1}/${t1} ${b1}/${b1}/${b1}\n`;
                }
            }
            
            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);
                    objStr += `f ${t0}/${t0}/${t0} ${t1}/${t1}/${t1} ${b0}/${b0}/${b0}\n`;
                    objStr += `f ${b0}/${b0}/${b0} ${t1}/${t1}/${t1} ${b1}/${b1}/${b1}\n`;
                }
                {
                    const t0 = topIndex(ix, segY), t1 = topIndex(ix+1, segY);
                    const b0 = bottomIndex(ix, segY), b1 = bottomIndex(ix+1, segY);
                    objStr += `f ${t1}/${t1}/${t1} ${t0}/${t0}/${t0} ${b0}/${b0}/${b0}\n`;
                    objStr += `f ${b0}/${b0}/${b0} ${t0}/${t0}/${t0} ${b1}/${b1}/${b1}\n`;
                }
            }

            const blob = new Blob([objStr], { type: "text/plain" });
            const url = URL.createObjectURL(blob);
            const a = document.createElement('a'); a.href = url; a.download = 'puffy_model.obj'; a.click(); URL.revokeObjectURL(url);

            console.log("OBJ exported: vertices top =", totalTopVerts, ", bottom offset =", bottomOffset);
        }

        function renderLiquidMetalSpot(spotGroup) {
            const oldRender = spotGroup.children['renderGroup'];
            if (oldRender) oldRender.remove();
            spotGroup.data.isRendered = true;
            const data = spotGroup.data;
            const basePath = spotGroup.children['basePath'];
            if (!basePath || basePath.isEmpty()) return;

            const bounds = basePath.bounds;
            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 maskImageData = rasterizePathToMask(basePath, W, H, offX, offY);
            let maskPixels = 0;
            for (let i = 3; i < maskImageData.data.length; i += 4) {
                if (maskImageData.data[i] > 128) maskPixels++;
            }
            if (maskPixels === 0) return;

            const dist = computeDistanceField(maskImageData, W, H);
            const finalCanvas = renderBumpLit(W, H, maskImageData, dist, data);

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

            if (selectedSpots.length === 1 && selectedSpots[0] === spotGroup) {
                uiCenterCol.value = data.centerCol || '#ffffff';
                uiMidCol.value    = data.midCol    || '#8ab4cc';
                uiEdgeCol.value   = data.edgeCol   || '#1a2a3a';
                document.getElementById('dragHandleCenter').style.backgroundColor = data.centerCol || '#ffffff';
                document.getElementById('dragHandleMid').style.backgroundColor    = data.midCol    || '#8ab4cc';
                document.getElementById('dragHandleEdge').style.backgroundColor   = data.edgeCol   || '#1a2a3a';
                uiBlur.value  = data.blurAmt  ?? 55;  document.getElementById('valBlur').innerText  = data.blurAmt  ?? 55;
                uiDepth.value = data.depthAmt ?? 12;  document.getElementById('valDepth').innerText = data.depthAmt ?? 12;
                uiLightAngle.value     = data.lightAngle     || 135; document.getElementById('valLightAngle').innerText     = data.lightAngle     || 135;
                uiLightIntensity.value = data.lightIntensity || 90; document.getElementById('valLightIntensity').innerText = data.lightIntensity || 90;
                uiHillHeight.value     = data.hillHeight     || 75; document.getElementById('valHillHeight').innerText     = data.hillHeight     || 75;
                uiTexture.value  = data.texture     || 'water';
                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 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;

            // hillHeight(5-100): 丘の高さ。大きいほど高く盛り上がる
            const hillScale = Math.max(0.05, (data.hillHeight || 75) / 100.0);
            // blurAmt(1-80): 反射の粗さ。curvePowに反映(小さい=急峻、大きい=なだらか)
            const curvePow = 0.2 + ((data.blurAmt ?? 55) / 80.0) * 1.3;
            // depthAmt(3-50): エッジの鋭さ。bumpStrの係数に反映
            const depthScale = Math.max(0.3, (data.depthAmt ?? 12) / 18.0);

            // 高さマップ: pow(nd, curvePow) * hillScale
            const height = new Float32Array(W * H);
            for (let i = 0; i < W * H; i++) {
                if (maskImageData.data[i * 4 + 3] <= 128) { height[i] = 0; continue; }
                const nd = dist[i] / maxDist;
                height[i] = Math.pow(nd, curvePow) * hillScale;
            }

            // 法線: hillScaleを除いた純粋な形状カーブでdhPerPxを計算し、
            // bumpStrに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;
                }
            }

            // 光源方向(lightAngle)
            const lightAng = (data.lightAngle || 45) * Math.PI / 180;
            const li = Math.max(0.4, (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||'water';
            const SP={water:80,sticker:40,chrome:200,gold:60,mercury:300,brushed:15,pearl:30,none:20};
            const SS={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={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]||60, specStr=SS[tt]||1.0, rimStr=RS[tt]||0.3;

            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; // 正規化: 0〜1
                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 renderFluffySpot(spotGroup) { renderLiquidMetalSpot(spotGroup); }

        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;

        tool.onMouseDown = function(event) {
            if (event.event.button === 2) { 
                if (mode === 'adjust' && selectedSpots.length > 0) {
                    let targetSpot = selectedSpots[0];
                    let hitResultPath = targetSpot.children['basePath'].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 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 (selectedSpots.length === 0) return;
                let bp = selectedSpots[0].children['basePath']; let hitResultPath = bp.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 = bp.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 basePath = targetSpot.children['basePath'];
                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 = spot.children['basePath'];
                                if (b && !b.isEmpty()) { let temp = obstaclePath.isEmpty() ? b.clone() : obstaclePath.unite(b); obstaclePath.remove(); obstaclePath = temp; }
                            }
                        });
                    }
                });
                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 united = basePath.isEmpty() ? targetRegion.clone() : basePath.unite(targetRegion);
                    targetSpot.children['basePath'].remove(); united.name = 'basePath'; targetSpot.addChild(united);
                    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; }
            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); }); }
                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)';
                }
            }
        };

        tool.onMouseUp = function(event) {
            if (isPanning) { isPanning = false; return; }
            if (mode === 'adjust') {
                if (selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
                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 = [];
            }
        };

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

        function selectSpot(spot, isMulti) {
            commitVector(); 
            if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
            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];
            }
            updateVisuals(); 
        }

        function clearSelection() {
            commitVector();
            if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
            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]); } return; }
            if (action === 'redo') { if (redoStack.length > 0) { let state = redoStack.pop(); undoStack.push(state); restoreState(state); } return; }
            commitVector(); 
            if (action === 'addSpot') {
                if(!activeLayer) { alert('レイヤーがありません。'); return; }
                spotCounter++; const spotGroup = new paper.Group({ name: 'Spot_' + Date.now() });
                spotGroup.data = { isRendered: false, isVisible: true, displayName: 'スポット ' + spotCounter, centerCol: uiCenterCol.value, midCol: uiMidCol.value, edgeCol: uiEdgeCol.value, patternType: uiPattern.value || 'none', blurAmt: parseInt(uiBlur.value), depthAmt: parseInt(uiDepth.value), lightAngle: parseInt(uiLightAngle.value) || 135, lightIntensity: parseInt(uiLightIntensity.value) || 90, hillHeight: parseInt(uiHillHeight.value) || 75, texture: uiTexture.value || 'water' };
                const basePath = new paper.Path({ name: 'basePath', visible: false });
                spotGroup.addChild(basePath); activeLayer.addChild(spotGroup);
                selectSpot(spotGroup, false); setMode('draw'); saveState();
            }
            else if (action === 'vectorize') { updateVisuals(); }
            else if (action === 'render') { if (selectedSpots.length > 0) { selectedSpots.forEach(spot => renderFluffySpot(spot)); setMode('select'); saveState(); } }
            else if (action === 'delete') { selectedSpots.forEach(s => s.remove()); clearSelection(); saveState(); }
            else if (action === 'unite') {
                if (selectedSpots.length < 2) return;
                let combinedPath = selectedSpots[0].children['basePath'].clone();
                for (let i = 1; i < selectedSpots.length; i++) {
                    let path2 = selectedSpots[i].children['basePath'].clone();
                    if (!path2.isEmpty()) { let temp = combinedPath.isEmpty() ? path2.clone() : combinedPath.unite(path2); combinedPath.remove(); path2.remove(); combinedPath = temp;
                    } else { path2.remove(); }
                }
                let newData = Object.assign({}, selectedSpots[0].data); let targetLayer = selectedSpots[0].parent;
                selectedSpots.forEach(s => s.remove()); clearSelection(); spotCounter++;
                let newSpot = new paper.Group({ name: 'Spot_' + Date.now() }); newSpot.data = newData; newSpot.data.displayName = '統合スポット ' + spotCounter;
                combinedPath.name = 'basePath'; newSpot.addChild(combinedPath);
                if (newData.isRendered) renderFluffySpot(newSpot); targetLayer.addChild(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);
                            let base = spot.children['basePath']; if (base) { base.visible = false; base.selected = false; base.strokeColor = null; base.strokeWidth = 0; }
                            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 === 'obj') {
                exportOBJ();
            } 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>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
描いたところが立体になる「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