GLSLシェーダーで遊ぶ「Glsl Viewer」

【更新履歴】

 ・2026/2/28 バージョン1.0公開。
  (中略)
 ・2026/3/1 バージョン2.4公開。(キーフレーム追加)
 ・2026/3/2 バージョン2.5公開。(ツリービュー追加)
 ・2026/3/4 バージョン2.6公開。(AIによる最適化追加)
 ・2026/3/5 バージョン2.7公開。(子ベースを扇状に変更)

画像

《このツールの概要》


・本ツールは、器となる「ベース」に、 
 様々な効果を持った「要素」を組み合わせていくことで、 
 リアルタイムにエフェクトを構築・確認できる
 GLSLシェーダーエディタです。

・作成したエフェクトは、
 ゲーム開発用のスプライトシート(連番画像)や
 GLSLシェーダーコードとして出力できます。


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

https://drive.google.com/drive/folders/1I79w7SBhawE2Rq0m2po8RxdOkHeLu4aR?ths=true


《操作説明書》

(1) 画面構成


メニューバー:(画面上)
・ファイル操作、AI生成、設定などを行います。

ツールバー:(画面上)
・ベース、見た目、動き、特殊効果などのノードを
 追加するためのボタンが並んでいます。

左パネル(OBJECTS):(画面左)
・作成した大元ベースのリストが表示されます。
・キャンバスへドラッグ&ドロップして、複製を配置できます。

編集エリア:(画面中央)
・ベースやパラメータを配置して、
 エフェクトを組み立てていくレイアウト領域です。
・F1キーを押すと、ツリービュー表示に切り替えられます。
 (再びF1キーを押すと、元のレイアウト表示に戻ります。)


プレビュー:(画面右)
画面右下にあり、作成したエフェクトの実際の見え方と、
生成されたGLSLコードを確認できます。

タイムライン:(画面下)
・アニメーションの時間軸と、キーフレームを管理します。

(2) 基本的な使い方


・ツールバー上にある「新規ベース」ボタンを押すと、
 レイアウト上にベース(ミシン線の円)が配置されます。

・ツールバー上にある要素(色、形、動きなど)ボタンを押すと、
 レイアウト上に要素(絵文字)が追加されます。

・追加した要素をドラッグして、
 ベースの中に入れてドロップすると、
 要素がベースの中に配置されます。

・要素をダブルクリックすると、調整パネルが開くので、
 ここで色とか速度といったパラメータを調節できます。

・このパネルは、パネルの上か、または、
 別の要素をダブルクリックすると閉じます。

⋯大元リスト上にあるベース名を
 レイアウト上にドラッグ&ドロップすると、
 その大元ベースの分身(参照インスタンス)を配置できます。
 (分身のパラメータは、大元と常に同じになります。)

(3) 編集エリアの操作(キャンバスとツリービュー)


・F1キー、または、ステータスバーの
 「ツリービュー切替」ボタンを押すと、
 要素が線でつながった「レイアウト表示」と、
 階層構造が分かりやすい「ツリービュー表示」が切り替わります。

・ツリービューモードで表示中に、
 ベース名の行をダブルクリックすると、
 そのベースの内容を展開、または、折りたたむことができます。

・ツリービューモードで表示中に、
 項目(子ベース、または要素)をドラッグ&ドロップすることで、
 別のベースの中に入れたり、外に出したりして親子関係を変更できます。

・背景の何もない領域をダブルクリックすると、
 ツリービューを閉じて、レイアウト表示に戻ります。

⋯ベース、または、要素を右クリックすると、
 ポップアップメニューが開き、
 カット、コピー、ペースト、削除を行うことができます。

(4) タイムラインとキーフレーム


・タイムライン上の何もない場所で右クリックし、
 「キーフレームを追加」を選ぶと、
 その時点での配置状態が記録されます。

・追加されたキーフレームは
 マーカー(小さな丸い点)として表示されます。

・マーカーをダブルクリックすると、
 そのキーフレームの時点のデータがレイアウト上に表示され、
 位置などを編集することができます。

・キーフレームごとにベースの配置位置を変更すると、
 再生時に、その間が自動的に補間され、滑らかに移動します。

⋯マーカー上で右クリックすると、
 キーフレーム単位でのコピーや削除が可能です。

⋯左パネルのリストでベースを選択すると、
 そのベースが最初に登場するキーフレームへ
 自動的にジャンプします。

(5) AI生成機能


・メニューの「AI生成」から、AIに言葉で指示を出して
 カスタムGLSLコードを自動生成させることができます。

APIモード:
・GeminiのAPIキーを入力して直接生成します。
 (※APIキーを取得するには、課金サービスへの加入が必要です。)

ローカルLLMモード:
・ご自身のPCで動くllama.cppサーバーに接続して生成します。
・起動コマンドのコピーや、接続テストを行うボタンが付いています。

手動モード:
・ウィザードに従ってプロンプトをコピーし、
 ブラウザ上のAIに生成させたコードを手動で貼り付けて
 カスタム要素のソースコードを作成し、適用させます。

・生成されたコード内に
 名前が「param_」で始まる変数が定義されていると、
 調整パネルに自動的に専用のスライダーが追加されます。

(6) 保存と出力機能


・ファイルメニューの「上書き保存」や「別名で保存」から、
 作成したプロジェクト全体をパソコンに保存できます。

⋯ツールバー右側の「Template ➕ 登録」ボタンを使うと、
 選択中のノード構成をテンプレートとして登録し、
 ライブラリファイルとして個別に保存できます。

・ファイルメニューの「画像出力」を選ぶと、
 作成したアニメーションをゲーム開発などで
 使いやすいスプライトシート(連番画像)として
 書き出すことができます。

(7)要素の種類


【見た目】

(1) 📐形:    
    ・円や多角形を作り、大きさを決めます。

(2) 🎨色:    
    ・色相・彩度・明度を調整して色をつけます。

(3) 🖼️画像:    
    ・PC内の画像ファイルを読み込んで表示します。

(4) 🎞️アニメ:    
    ・横に繋がったスプライトシート画像を読み込み、    
     パラパラアニメとして再生します。

【特殊】

(1) ⏳時間制御:    
    ・発生を遅らせたり、
     一定時間で消滅(フェードアウト)させたり、    
     ループする寿命を決めます。

(2) 💫残像:    
    ・動いた軌跡に光の帯や残像を残します。

(3) 🌊空間の歪み:    
    ・背景を波打つように歪ませ、    
     衝撃波や陽炎を表現します。

(4) 🌟フィルター:    
    ・ドット絵風にモザイク化したり、    
     発光(グロー)させたりします。

(5) ✨パーティクル:    
    ・指定した形や画像を、火花のように複数に分裂させて
     弾け飛ばします。

【動き】

(1) ➡️直線移動:    
   ・上下左右に移動し続けます。

(2) ↔️反復移動:    
   ・指定した角度に向かって、行ったり来たりします。

(3) 🪀バウンド:    
   ・地面で跳ねるような動きをします。

(4) ⤴️放物線:    
   ・重力に従って放物線を描いて飛びます。

(5) 🌀渦巻き:    
   ・回転しながら徐々に広がっていきます。

(6) 🔄空間回転:
   ・空間そのものを回します。   
   (親に付けると、子が衛星のように公転します)

(7) 🎯ターゲット追尾:    
   ・指定した座標に向かって徐々に曲がりながら進みます。

(8) 🧲イージング:    
   ・動きの始まりや終わりを滑らかに加減速させます。

(9) 🫨揺らぎ:
   ・炎の揺れのような、ランダムで不規則な動きを加えます。

(10) 🪨落下と弾み:
   ・重力で落下しながら回転します。

(8)便利機能


(1) テンプレート登録:   

 ・お気に入りのエフェクトができた際、   
  ベースを選択してツールバーの「➕登録」を押すと、   
  ボタンとして保存されます。

  次回以降、ワンクリックで同じものを呼び出せます。

(2) レイアウトの移動とズーム:   
  
 ・レイアウト上の何もない場所をドラッグすると、   
  画面をスクロールできます。

 ・キーボードの「PageUp」「PageDown」キーで
  ズームイン・ズームアウトが可能です。


※ローカルLLMによるAI生成モードを使用する際には、
 「Local Test Browser」を使用してください。↓

・ローカルLLMについては、こちらも参考にしてください。↓



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

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GLSL Viewer 2.7</title>
    <style>
        body { margin: 0; display: flex; flex-direction: column; height: 100vh; background: #0a0a10; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; user-select: none; -webkit-user-select: none;}
        
        #menubar { flex-shrink: 0; height: 30px; background: #0f0f15; border-bottom: 1px solid #333; display: flex; align-items: center; padding: 0 10px; font-size: 13px; z-index: 1000; }
        .menu-item { position: relative; padding: 0 15px; cursor: pointer; height: 100%; display: flex; align-items: center; color: #ccc; }
        .menu-item:hover { background: #222; color: #00ffcc; }
        .submenu { position: absolute; top: 30px; left: 0; background: #1a1a20; border: 1px solid #444; display: none; flex-direction: column; min-width: 180px; z-index: 1000; box-shadow: 0 5px 15px rgba(0,0,0,0.8); }
        .menu-item:hover .submenu { display: flex; }
        .sub-item { padding: 10px 15px; color: #ccc; cursor: pointer; border-bottom: 1px solid #222;}
        .sub-item:hover { background: #00ffcc; color: #000; font-weight: bold; }
        .ctx-sep { height: 1px; background: #444; margin: 4px 0; }
        #status-bar { margin-left: auto; color: #666; font-size: 11px; padding-right: 10px; display: flex; align-items: center; gap: 10px;}

        #main-area { display: flex; flex-grow: 1; flex-shrink: 1; min-height: 0; overflow: hidden; } 
        
        #left-panel { width: 160px; background: #111; border-right: 2px solid #333; display: flex; flex-direction: column; flex-shrink: 0; min-height: 0; z-index: 20;}
        .panel-header { padding: 8px; font-weight: bold; background: #222; border-bottom: 1px solid #444; font-size: 11px; text-align: center; color: #aaa; letter-spacing: 1px; flex-shrink: 0;}
        #object-list { display: flex; flex-direction: column; gap: 6px; padding: 8px; overflow-y: auto; flex-grow: 1; min-height: 0; }
        .obj-list-item { padding: 8px 10px; background: #1a1a22; border: 1px solid #445; border-radius: 4px; cursor: grab; display: flex; align-items: center; gap: 8px; font-size: 12px; transition: 0.1s;}
        .obj-list-item:hover { background: #2a2a33; border-color: #00ffcc; }
        .obj-list-item:active { cursor: grabbing; }

        #editor { flex-grow: 1; display: flex; flex-direction: column; border-right: 4px solid #222; position: relative; min-width: 0; min-height: 0;}
        
        #toolbar { padding: 8px; background: #151520; display: flex; flex-direction: column; gap: 6px; z-index: 10; box-shadow: 0 4px 10px rgba(0,0,0,0.8); overflow-y: auto; max-height: 180px; flex-shrink: 0;}
        .toolbar-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
        .btn-group { display: flex; gap: 4px; border: 1px solid #333; padding: 4px; border-radius: 6px; background: #111; align-items: center; white-space: nowrap;}
        .btn-group span { font-size: 10px; font-weight: bold; color: #888; writing-mode: vertical-lr; text-align: center; margin-right: 2px;}
        button { font-size: 18px; cursor: pointer; background: #222; border: 1px solid #444; border-radius: 6px; transition: 0.1s; padding: 4px 6px; color: #fff; display: flex; align-items: center; gap: 4px;}
        button:hover { background: #333; transform: scale(1.05); }
        button:active { transform: scale(0.95); }
        
        #canvas-container { flex-grow: 1; flex-shrink: 1; min-height: 0; overflow: auto; position: relative; background: #050508; outline: none; }
        #ui-canvas { display: block; transform-origin: top left; }
        
        /* === ツリービュー用スタイル === */
        #tree-view-container { position: absolute; top: 0; left: 0; width: 100%; height: 100%; overflow: auto; background: #0a0a10; z-index: 50; display: none; }
        #tree-view-content { padding: 30px; min-height: 100%; box-sizing: border-box; }
        .tree-node { margin: 5px 0; border-radius: 8px; border: 1px solid #333; background: #151520; padding: 4px; transition: 0.1s; cursor: default; }
        .tree-node.base-node { border: 1px solid #555; background: #1a1a22; padding: 8px; }
        .tree-node.selected > .node-header { background: #2a2a33; border-color: #00ffcc; color: #00ffcc; box-shadow: inset 0 0 5px #00ffcc; border-radius: 4px; }
        .tree-node.drag-over { border-top: 2px solid #00ffcc; }
        .node-header { padding: 6px 12px; cursor: pointer; font-size: 13px; color: #ccc; user-select: none; border-radius: 4px; display: flex; align-items: center; gap: 8px; width: 100%; box-sizing: border-box;}
        .node-header:hover { background: #222; }
        .node-children { margin-top: 5px; padding-left: 20px; border-left: 2px dashed #444; min-height: 10px; }

        #preview { width: 40%; position: relative; background: #000; flex-shrink: 0; min-width: 300px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;}
        
        #log-panel { 
            position: absolute; bottom: 0; left: 0; width: 100%; height: 200px; max-height: 40%;
            background: rgba(0,0,0,0.85); color: #00ffcc; font-family: 'Courier New', monospace; 
            font-size: 11px; padding: 10px; padding-top: 35px; box-sizing: border-box; 
            overflow-y: auto; pointer-events: auto; user-select: text; -webkit-user-select: text;
            border-top: 1px solid #333; z-index: 100;
        }

        #param-panel {
            position: absolute; display: none; background: rgba(15, 15, 20, 0.95);
            border: 1px solid #555; border-radius: 8px; padding: 12px;
            color: white; z-index: 100; pointer-events: auto;
            box-shadow: 0 10px 30px rgba(0,0,0,0.8); width: 220px;
            transform: translate(-50%, 20px);
            user-select: none;
        }
        #param-title { font-weight: bold; margin-bottom: 8px; border-bottom: 1px solid #444; padding-bottom: 4px; text-align: center; font-size: 12px; color: #aaa;}
        .param-row { display: flex; flex-direction: column; margin-bottom: 6px; }
        .param-row label { font-size: 10px; color: #ccc; margin-bottom: 2px; display: flex; justify-content: space-between;}
        .param-row input[type="range"] { width: 100%; cursor: pointer; margin: 0; accent-color: #00ffcc;}
        .param-row input[type="file"] { width: 100%; color: #fff; font-size: 10px; }

        #timeline-bar { flex-shrink: 0; height: 35px; background: #151520; border-top: 1px solid #333; display: flex; align-items: center; padding: 0 15px; gap: 15px; position: relative;}
        #timeline-bar button { font-size: 14px; padding: 4px 8px; background: #00ffcc; color: #000; border: none; font-weight: bold; border-radius:4px;}
        #timeline-container { flex-grow: 1; position: relative; height: 100%; display: flex; align-items: center; margin: 0 10px; cursor: pointer; }
        #time-slider { width: 100%; position: absolute; z-index: 1; cursor: pointer; accent-color: #00ffcc; margin: 0;}
        #kf-container { position: absolute; width: 100%; height: 100%; pointer-events: none; z-index: 2; }
        
        .kf-marker {
            position: absolute; top: 50%; transform: translate(-50%, -50%);
            width: 10px; height: 10px; background: #888; border: 2px solid #222;
            border-radius: 50%; cursor: pointer; pointer-events: auto; z-index: 10;
            transition: 0.1s;
        }
        .kf-marker:hover { background: #00ffcc; transform: translate(-50%, -50%) scale(1.3); }
        .kf-marker.selected { background: #00ffcc; border-color: #fff; box-shadow: 0 0 8px #00ffcc; transform: translate(-50%, -50%) scale(1.2); }

        #time-display { font-family: monospace; font-size: 14px; color: #00ffcc; width: 40px; text-align: right;}

        .ctx-menu { position: absolute; display: none; background: rgba(25, 25, 30, 0.95); border: 1px solid #555; border-radius: 6px; padding: 4px 0; color: white; z-index: 200; box-shadow: 0 5px 15px rgba(0,0,0,0.8); min-width: 140px; font-size: 12px; }
        .ctx-item { padding: 8px 16px; cursor: pointer; transition: background 0.1s; }
        .ctx-item:hover { background: #00ffcc; color: #000; font-weight: bold; }
        
        #template-container { display: flex; gap: 4px; padding-left: 4px; flex-wrap: wrap;}
        .tpl-btn { font-size: 12px; padding: 4px 10px; background: #1a2a3a; border-color: #00ffcc; color: #00ffcc; }
        .tpl-btn:hover { background: #00ffcc; color: #000; }

        .dialog-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 3000; justify-content: center; align-items: center; }
        .dialog-box { background: #1a1a20; border: 1px solid #00ffcc; padding: 20px; border-radius: 8px; min-width: 300px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); }
        .dialog-box h3 { margin-top: 0; color: #00ffcc; font-size: 16px; border-bottom: 1px solid #333; padding-bottom: 10px; }
        .dialog-row { margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
        .dialog-row input[type="number"], .dialog-row input[type="text"], .dialog-row input[type="password"] { width: 80px; background: #000; color: #fff; border: 1px solid #555; padding: 4px; text-align: left; }
        .dialog-btns { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
        .dialog-btns button { font-size: 14px; padding: 6px 16px; cursor: pointer;}
    </style>
</head>
<body>

    <div id="menubar">
        <div class="menu-item">ファイル
            <div class="submenu">
                <div class="sub-item" onclick="fileOps.new()">📄 新規作成</div>
                <div class="sub-item" onclick="fileOps.open()">📂 開く</div>
                <div class="sub-item" onclick="fileOps.save()">💾 上書き保存</div>
                <div class="sub-item" onclick="fileOps.saveAs()">💾 別名で保存</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="fileOps.exportGLSL()">📜 GLSL出力</div>
                <div class="sub-item" onclick="fileOps.exportSpriteSheet()" style="color:#00ffcc; font-weight:bold;">🎬 画像出力</div>
            </div>
        </div>
        <div class="menu-item">編集
            <div class="submenu">
                <div class="sub-item" onclick="undo()">⏪ 元に戻す (Undo)</div>
                <div class="sub-item" onclick="redo()">⏩ やり直し (Redo)</div>
            </div>
        </div>
        <div class="menu-item" style="color:#00ffcc; font-weight:bold;">AI生成
            <div class="submenu">
                <div class="sub-item" onclick="openDialog('dlg-ai-api')">🤖 API</div>
                <div class="sub-item" onclick="openDialog('dlg-ai-local')">🖥️ ローカルLLM</div>
                <div class="sub-item" onclick="openWizard('dlg-ai-manual')">✍️ 手動</div>
            </div>
        </div>
        <div class="menu-item" style="color:#ffcc00; font-weight:bold;">🔧 GLSL最適化
            <div class="submenu">
                <div class="sub-item" onclick="openOptimizeDialog('api')">🤖 API (最適化)</div>
                <div class="sub-item" onclick="openOptimizeDialog('local')">🖥️ ローカルLLM (最適化)</div>
                <div class="sub-item" onclick="openOptimizeDialog('manual')">✍️ 手動 (最適化)</div>
            </div>
        </div>
        <div class="menu-item">設定
            <div class="submenu">
                <div class="sub-item" onclick="openDialog('dlg-layout')">📏 レイアウト</div>
                <div class="sub-item" onclick="openDialog('dlg-timeline')">⏱️ タイムライン</div>
            </div>
        </div>
        <div id="status-bar">
            <button onclick="toggleViewMode()" style="font-size:11px; padding:2px 8px; background:#223; color:#00ffcc; border:1px solid #445; border-radius:4px; margin-right:10px; cursor:pointer;" title="ショートカット: F1キー">🌲 ツリービュー切替(F1)</button>
            <label style="cursor:pointer; display:flex; align-items:center; gap:4px; color:#aaa;"><input type="checkbox" id="snap-toggle">📏グリッドスナップ</label>
            <span style="color:#00ffcc;">🔍ズーム: <span id="zoom-val">100%</span></span>
            <span id="proj-name" style="margin-left: 10px;">新規プロジェクト</span>
        </div>
    </div>

    <div id="main-area">
        <div id="left-panel">
            <div class="panel-header">OBJECTS (大元)</div>
            <div id="object-list"></div>
            <div style="font-size:10px; color:#666; padding:10px; text-align:center; flex-shrink: 0;">↑ドラッグしてキャンバスへ配置</div>
        </div>

        <div id="editor">
            <div id="toolbar">
                <div class="toolbar-row">
                    <div class="btn-group"><span>ベース</span>
                        <button onclick="addNode('BASE')" title="大元の魔法陣を新規作成">🎇新規ベース</button>
                    </div>
                    <div class="btn-group"><span>見た目</span>
                        <button onclick="addNode('SHAPE')" title="形(多角形)">📐</button>
                        <button onclick="addNode('COLOR')" title="色(HSV)">🎨</button>
                        <button onclick="addNode('IMAGE')" title="画像(テクスチャ)読み込み">🖼️</button>
                        <button onclick="addNode('ANIMATION')" title="アニメ(スプライト)読み込み">🎞️</button>
                        <button onclick="addNode('CUSTOM')" title="生GLSLコード入力" style="background:#055;">💻カスタム</button>
                    </div>
                    <div class="btn-group"><span>特殊</span>
                        <button onclick="addNode('TIME')" title="時間制御(寿命/フェード)">⏳</button>
                        <button onclick="addNode('TRAIL')" title="残像・軌跡">💫</button>
                        <button onclick="addNode('FILTER_DISTORT')" title="空間の歪み(衝撃波)">🌊</button>
                        <button onclick="addNode('FILTER')" title="フィルター(発光/モザイク)">🌟</button>
                        <button onclick="addNode('PARTICLE')" title="パーティクル(爆発/破片)">✨</button>
                    </div>
                </div>
                <div class="toolbar-row">
                    <div class="btn-group"><span>動き</span>
                        <button onclick="addNode('M_LINEAR')" title="直線移動">➡️</button>
                        <button onclick="addNode('M_SINE')" title="反復移動">↔️</button>
                        <button onclick="addNode('M_BOUNCE')" title="バウンド">🪀</button>
                        <button onclick="addNode('M_PARABOLA')" title="放物線">⤴️</button>
                        <button onclick="addNode('M_SPIRAL')" title="渦巻き">🌀</button>
                        <button onclick="addNode('M_ROTATE')" title="円軌道(公転)">🔄</button>
                        <button onclick="addNode('M_HOMING')" title="ターゲット追尾">🎯</button>
                        <button onclick="addNode('M_EASING')" title="イージング(加減速)">🧲</button>
                        <button onclick="addNode('M_SHAKE')" title="揺らぎ(炎など)">🫨</button>
                        <button onclick="addNode('M_FALL')" title="落下と弾み(岩など)">🪨</button>
                    </div>
                    <button onclick="clearNodes()" style="margin-left:auto; font-size:12px;">🗑️ 全クリア</button>
                </div>
                <div class="toolbar-row" style="border-top: 1px solid #333; padding-top: 4px;">
                    <div class="btn-group"><span style="color:#00ffcc;">Template</span>
                        <button onclick="saveTemplate()" title="選択中のオブジェクトをテンプレートとして保存" style="font-size:12px; background:#005544;">➕ 登録</button>
                        <button onclick="fileOps.open()" title="ファイルから読み込み" style="font-size:12px; background:#334455;">📥 読込</button>
                    </div>
                    <div id="template-container"></div>
                </div>
            </div>
            
            <div id="canvas-container">
                <canvas id="ui-canvas"></canvas>
                
                <div id="tree-view-container">
                    <div id="tree-view-content"></div>
                </div>

                <div id="param-panel"><div id="param-title">調整</div><div id="param-content"></div></div>
                <div id="ctx-menu-node" class="ctx-menu">
                    <div class="ctx-item" id="ctx-cut">✂️ カット</div><div class="ctx-item" id="ctx-copy">📄 コピー</div><div class="ctx-item" id="ctx-paste">📋 ペースト</div>
                    <div class="ctx-sep" id="ctx-sep-1"></div><div class="ctx-item" id="ctx-delete" style="color:#ff6666;">🗑️ 削除</div>
                </div>
            </div>
        </div>
        
        <div id="preview">
            <div id="log-panel" onwheel="event.stopPropagation()">
                <button onclick="copyGLSL()" style="position: absolute; top: 5px; right: 10px; font-size: 12px; background: #223; border: 1px solid #00ffcc; color: #00ffcc; padding: 4px 8px; border-radius: 4px; cursor: pointer;">📋 コードをコピー</button>
                <div id="glsl-content" style="white-space: pre-wrap; word-wrap: break-word;">// 💻「カスタムGLSL」宝石が追加されました!</div>
            </div>
        </div>
    </div>

    <div id="timeline-bar">
        <button id="play-btn" onclick="togglePlay()">⏸️</button>
        <div id="timeline-container">
            <input type="range" id="time-slider" min="0" max="10" step="0.01" value="0">
            <div id="kf-container"></div>
        </div>
        <span id="time-display">0.00</span>
    </div>

    <div id="ctx-menu-timeline" class="ctx-menu">
        <div class="ctx-item" id="ctx-tl-add" style="color:#00ffcc;">➕ キーフレームを追加</div>
        <div class="ctx-sep" id="ctx-tl-sep"></div>
        <div class="ctx-item" id="ctx-tl-cut">✂️ カット</div>
        <div class="ctx-item" id="ctx-tl-copy">📄 コピー</div>
        <div class="ctx-item" id="ctx-tl-paste">📋 ペースト</div>
        <div class="ctx-item" id="ctx-tl-delete" style="color:#ff6666;">🗑️ 削除</div>
    </div>

    <div id="dlg-layout" class="dialog-overlay">
        <div class="dialog-box">
            <h3>レイアウト設定</h3>
            <div class="dialog-row"><label>幅 (px):</label> <input type="number" id="inp-ly-w" value="2000" style="text-align:right;"></div>
            <div class="dialog-row"><label>高さ (px):</label> <input type="number" id="inp-ly-h" value="2000" style="text-align:right;"></div>
            <div class="dialog-row"><label>グリッドサイズ:</label> <input type="number" id="inp-ly-grid" value="50" style="text-align:right;"></div>
            <hr style="border-color:#444;">
            <div class="dialog-row"><label>上下左右に拡張(+px):</label> <input type="number" id="inp-ly-add" value="0" style="text-align:right;"></div>
            <div style="text-align:right;"><button onclick="addLayoutSize()" style="background:#005544; color:#fff; border:none; padding:4px 8px; border-radius:4px;">↑拡張を実行</button></div>
            <div class="dialog-btns">
                <button onclick="applyLayoutSettings()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">OK</button>
                <button onclick="closeDialog('dlg-layout')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <div id="dlg-timeline" class="dialog-overlay">
        <div class="dialog-box">
            <h3>タイムライン設定</h3>
            <div class="dialog-row"><label>長さ (秒):</label> <input type="number" id="inp-tl-len" step="0.1" value="10" style="text-align:right;"></div>
            <div class="dialog-row"><label>再生速度 (倍):</label> <input type="number" id="inp-tl-spd" step="0.1" value="1.0" style="text-align:right;"></div>
            <div class="dialog-row"><label>ループ再生:</label> <input type="checkbox" id="inp-tl-loop" checked style="width:auto;"></div>
            <div class="dialog-btns">
                <button onclick="applyTimelineSettings()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">OK</button>
                <button onclick="closeDialog('dlg-timeline')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <div id="dlg-ai-api" class="dialog-overlay">
        <div class="dialog-box" style="width: 400px;">
            <h3>🤖 APIモード (Gemini)</h3>
            <div class="dialog-row"><label>APIキー:</label> <input type="password" id="ai-api-key" style="width:250px;"></div>
            <div class="dialog-row" style="flex-direction: column; align-items: flex-start;">
                <label>作成したいエフェクトの要望:</label>
                <textarea id="ai-api-req" style="width:100%; height:80px; background:#000; color:#fff; margin-top:4px;" placeholder="例:下から上に燃え盛る青い炎"></textarea>
            </div>
            <div class="dialog-btns">
                <button id="btn-ai-api-exec" onclick="execAIApi()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">✨ 自動生成</button>
                <button onclick="cancelAIApi()" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>
    
    <div id="dlg-ai-local" class="dialog-overlay">
        <div class="dialog-box" style="width: 450px;">
            <h3>🖥️ ローカルLLMモード</h3>
            <p style="font-size:11px; color:#aaa; margin-top:-5px; margin-bottom:10px;">※ブラウザの制限上、ツールから直接サーバーの起動・停止はできません。<br>初回のみ起動コマンドをコピーしてコマンドプロンプト等で手動起動し、<br>サーバーが稼働した状態で「✨ 接続して生成」を押してください。</p>
            <div class="dialog-row"><label>Serverパス:</label> <input type="text" id="ai-local-exe" value="server.exe" style="width:250px;"></div>
            <div class="dialog-row"><label>モデルパス:</label> <input type="text" id="ai-local-model" value="model.gguf" style="width:250px;"></div>
            <div class="dialog-row"><label>オプション:</label> <input type="text" id="ai-local-opt" value="-c 2048 -ngl 33" style="width:250px;"></div>
            <div class="dialog-row"><label>API URL:</label> <input type="text" id="ai-local-url" value="http://127.0.0.1:8080/v1/chat/completions" style="width:250px;"></div>
            <div class="dialog-row" style="justify-content: flex-start; gap:10px; margin-bottom: 5px;">
                <label style="cursor:pointer; display:flex; align-items:center; gap:4px;">
                    <input type="checkbox" id="ai-local-ps"> PowerShellを使用 (先頭に &amp; を付与)
                </label>
            </div>
            <div style="text-align:right; margin-bottom: 10px; display:flex; justify-content: flex-end; gap:5px;">
                <button onclick="checkLocalServer()" style="font-size:11px; padding:4px 8px; background:#445566; color:#fff; border:none; border-radius:4px;">🔌 接続テスト</button>
                <button onclick="copyLocalCommand()" style="font-size:11px; padding:4px 8px; background:#334455; color:#fff; border:none; border-radius:4px;">📋 起動コマンドをコピー</button>
            </div>
            <div class="dialog-row" style="flex-direction: column; align-items: flex-start;">
                <label>作成したいエフェクトの要望:</label>
                <textarea id="ai-local-req" style="width:100%; height:60px; background:#000; color:#fff; margin-top:4px;" placeholder="例:画面中央で爆発するパーティクル"></textarea>
            </div>
            <div class="dialog-btns">
                <button id="btn-ai-local-exec" onclick="execAILocal()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">✨ 接続して生成</button>
                <button onclick="cancelAILocal()" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <div id="dlg-ai-manual" class="dialog-overlay">
        <div class="dialog-box" style="width: 500px;">
            <h3>✍️ 手動モード</h3>
            
            <div id="wizard-step-1">
                <p style="font-size:12px; color:#aaa;">ステップ1: どのようなエフェクトを作りたいか入力してください。</p>
                <textarea id="ai-manual-req" style="width:100%; height:80px; background:#000; color:#fff;"></textarea>
                <div class="dialog-btns">
                    <button onclick="wizardNext(2)" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">次へ ➡️</button>
                    <button onclick="closeDialog('dlg-ai-manual')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
                </div>
            </div>

            <div id="wizard-step-2" style="display:none;">
                <p style="font-size:12px; color:#aaa;">ステップ2: 以下のプロンプトをコピーし、ブラウザ版Gemini等に送信してください。</p>
                <textarea id="ai-manual-prompt" readonly style="width:100%; height:160px; background:#112; color:#00ffcc; font-size:11px; padding:4px; box-sizing:border-box;"></textarea>
                <div class="dialog-btns">
                    <button onclick="copyManualPrompt()" style="background:#55aaff; color:#fff; border:none; border-radius:4px;">📋 コピー</button>
                    <button onclick="wizardNext(3)" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">次へ ➡️</button>
                    <button onclick="wizardNext(1)" style="background:#444; color:#fff; border:none; border-radius:4px;">戻る</button>
                </div>
            </div>

            <div id="wizard-step-3" style="display:none;">
                <p style="font-size:12px; color:#aaa;">ステップ3: AIが生成したGLSLコードをここに貼り付けてください。</p>
                <textarea id="ai-manual-res" style="width:100%; height:160px; background:#000; color:#fff; font-size:11px; font-family:monospace; padding:4px; box-sizing:border-box;"></textarea>
                <div class="dialog-btns">
                    <button onclick="execAIManual()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">✨ コードを適用</button>
                    <button onclick="wizardNext(2)" style="background:#444; color:#fff; border:none; border-radius:4px;">戻る</button>
                </div>
            </div>
        </div>
    </div>

    <!-- ==========================================
         GLSL最適化ダイアログ (APIモード)
    =========================================== -->
    <div id="dlg-opt-api" class="dialog-overlay">
        <div class="dialog-box" style="width: 500px;">
            <h3>🔧 GLSL最適化 - APIモード (Gemini)</h3>
            <div class="dialog-row"><label>APIキー:</label> <input type="password" id="opt-api-key" style="width:300px;" placeholder="Gemini APIキー(AI生成と共用)"></div>
            <div class="dialog-row" style="flex-direction:column; align-items:flex-start;">
                <label>最適化の指示(省略可):</label>
                <textarea id="opt-api-hint" style="width:100%; height:50px; background:#000; color:#fff; margin-top:4px;" placeholder="例:パーティクルのループを削除、定数を畳み込む、など"></textarea>
            </div>
            <div class="dialog-row" style="flex-direction:column; align-items:flex-start;">
                <label style="color:#aaa; font-size:11px;">最適化対象のGLSL(現在のコードが自動で入ります):</label>
                <textarea id="opt-api-src" style="width:100%; height:120px; background:#0a0a14; color:#aaffcc; font-size:10px; font-family:monospace; margin-top:4px;" placeholder="GLSLコードを貼り付けるか、自動入力されます"></textarea>
            </div>
            <div class="dialog-btns">
                <button onclick="loadCurrentGLSLToOptimize('api')" style="background:#334455; color:#ccc; border:1px solid #555; border-radius:4px; font-size:12px;">📋 現在のGLSLを読込</button>
                <button id="btn-opt-api-exec" onclick="execOptimizeApi()" style="background:#ffcc00; color:#000; border:none; border-radius:4px;">⚡ 最適化実行</button>
                <button onclick="closeDialog('dlg-opt-api')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <!-- ==========================================
         GLSL最適化ダイアログ (ローカルLLMモード)
    =========================================== -->
    <div id="dlg-opt-local" class="dialog-overlay">
        <div class="dialog-box" style="width: 500px;">
            <h3>🔧 GLSL最適化 - ローカルLLMモード</h3>
            <p style="font-size:11px; color:#aaa; margin-top:-5px; margin-bottom:10px;">※AI生成メニューのローカルLLM設定(サーバーURL等)を共用します。<br>サーバーが稼働している状態で「⚡ 最適化実行」を押してください。</p>
            <div class="dialog-row" style="flex-direction:column; align-items:flex-start;">
                <label>最適化の指示(省略可):</label>
                <textarea id="opt-local-hint" style="width:100%; height:50px; background:#000; color:#fff; margin-top:4px;" placeholder="例:sampler2Dの参照を減らす、不要なブランチを削除、など"></textarea>
            </div>
            <div class="dialog-row" style="flex-direction:column; align-items:flex-start;">
                <label style="color:#aaa; font-size:11px;">最適化対象のGLSL(現在のコードが自動で入ります):</label>
                <textarea id="opt-local-src" style="width:100%; height:120px; background:#0a0a14; color:#aaffcc; font-size:10px; font-family:monospace; margin-top:4px;" placeholder="GLSLコードを貼り付けるか、自動入力されます"></textarea>
            </div>
            <div class="dialog-btns">
                <button onclick="loadCurrentGLSLToOptimize('local')" style="background:#334455; color:#ccc; border:1px solid #555; border-radius:4px; font-size:12px;">📋 現在のGLSLを読込</button>
                <button id="btn-opt-local-exec" onclick="execOptimizeLocal()" style="background:#ffcc00; color:#000; border:none; border-radius:4px;">⚡ 最適化実行</button>
                <button onclick="closeDialog('dlg-opt-local')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <!-- ==========================================
         GLSL最適化ダイアログ (手動モード)
    =========================================== -->
    <div id="dlg-opt-manual" class="dialog-overlay">
        <div class="dialog-box" style="width: 560px;">
            <h3>🔧 GLSL最適化 - 手動モード</h3>

            <div id="opt-wizard-step-1">
                <p style="font-size:12px; color:#aaa;">ステップ1: 最適化の指示を入力し、GLSLを読み込んでください。</p>
                <div style="margin-bottom:6px;">
                    <label style="font-size:11px; color:#ccc;">最適化の指示(省略可):</label>
                    <textarea id="opt-manual-hint" style="width:100%; height:50px; background:#000; color:#fff; margin-top:4px;" placeholder="例:定数を畳み込む、不要な変数を削除、など"></textarea>
                </div>
                <div style="margin-bottom:6px;">
                    <label style="font-size:11px; color:#aaa;">最適化対象のGLSL:</label>
                    <textarea id="opt-manual-src" style="width:100%; height:120px; background:#0a0a14; color:#aaffcc; font-size:10px; font-family:monospace; margin-top:4px;" placeholder="GLSLコードを貼り付けるか、読込ボタンで自動入力されます"></textarea>
                </div>
                <div class="dialog-btns" style="justify-content:space-between;">
                    <button onclick="loadCurrentGLSLToOptimize('manual')" style="background:#334455; color:#ccc; border:1px solid #555; border-radius:4px; font-size:12px;">📋 現在のGLSLを読込</button>
                    <div style="display:flex; gap:8px;">
                        <button onclick="optManualNext(2)" style="background:#ffcc00; color:#000; border:none; border-radius:4px;">次へ ➡️</button>
                        <button onclick="closeDialog('dlg-opt-manual')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
                    </div>
                </div>
            </div>

            <div id="opt-wizard-step-2" style="display:none;">
                <p style="font-size:12px; color:#aaa;">ステップ2: 以下のプロンプトをコピーし、ブラウザ版Gemini等に送信してください。</p>
                <textarea id="opt-manual-prompt" readonly style="width:100%; height:200px; background:#112; color:#00ffcc; font-size:10px; padding:4px; box-sizing:border-box;"></textarea>
                <div class="dialog-btns">
                    <button onclick="copyOptManualPrompt()" style="background:#55aaff; color:#fff; border:none; border-radius:4px;">📋 コピー</button>
                    <button onclick="optManualNext(3)" style="background:#ffcc00; color:#000; border:none; border-radius:4px;">次へ ➡️</button>
                    <button onclick="optManualNext(1)" style="background:#444; color:#fff; border:none; border-radius:4px;">戻る</button>
                </div>
            </div>

            <div id="opt-wizard-step-3" style="display:none;">
                <p style="font-size:12px; color:#aaa;">ステップ3: AIが生成した最適化済みGLSLコードをここに貼り付けてください。</p>
                <textarea id="opt-manual-res" style="width:100%; height:200px; background:#000; color:#fff; font-size:10px; font-family:monospace; padding:4px; box-sizing:border-box;"></textarea>
                <div class="dialog-btns">
                    <button onclick="applyOptimizedGLSL(document.getElementById('opt-manual-res').value); closeDialog('dlg-opt-manual');" style="background:#ffcc00; color:#000; border:none; border-radius:4px;">⚡ コードを適用</button>
                    <button onclick="optManualNext(2)" style="background:#444; color:#fff; border:none; border-radius:4px;">戻る</button>
                </div>
            </div>
        </div>
    </div>

    <input type="file" id="file-loader" accept=".json" style="display:none">

    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
    </script>

    <script type="module">
        import * as THREE from 'three';

        // ==========================================
        // 0. DOM参照・グローバル変数の初期化
        // ==========================================
        const previewContainer = document.getElementById('preview');
        const canvas = document.getElementById('ui-canvas');
        const container = document.getElementById('canvas-container');
        const ctx = canvas.getContext('2d');
        const paramPanel = document.getElementById('param-panel');
        const paramContent = document.getElementById('param-content');
        const paramTitle = document.getElementById('param-title');
        const ctxMenuNode = document.getElementById('ctx-menu-node');
        const timeSlider = document.getElementById('time-slider');
        const timeDisplay = document.getElementById('time-display');
        const playBtn = document.getElementById('play-btn');
        const objList = document.getElementById('object-list');
        const glslContent = document.getElementById('glsl-content');
        const zoomValDisplay = document.getElementById('zoom-val');
        const kfContainer = document.getElementById('kf-container');
        const tlCtxMenu = document.getElementById('ctx-menu-timeline');
        const treeViewContainer = document.getElementById('tree-view-container');
        const treeViewContent = document.getElementById('tree-view-content');

        // 表示モード ('canvas' or 'tree')
        let viewMode = 'canvas';

        // ★ キーフレーム管理変数 ★
        let keyframes = [
            { id: 'kf_' + Date.now(), time: 0.0, nodes: [] }
        ];
        let currentKfId = keyframes[0].id;
        let nodes = keyframes[0].nodes;

        let globalSelectedNode = null; 
        let history = []; let historyIndex = -1; let isRestoring = false;

        let layoutW = 2000; let layoutH = 2000; let gridSz = 50; let zoom = 1.0;
        let tDuration = 10.0; let tSpeed = 1.0; let tLoop = true;
        let isPlaying = true; let currentTime = 0.0; let isPreviewing = false;

        let aiSettings = {
            apiKey: '', localExe: 'server.exe', localModel: 'model.gguf',
            localOpt: '-c 2048 -ngl 33', localUrl: 'http://127.0.0.1:8080/v1/chat/completions', usePS: false
        };

        let draggedNode = null; let isDragging = false; let dragStartPos = {x:0, y:0}; let hoverTarget = null;
        let isPanning = false; let panStart = {x:0, y:0}; let scrollStart = {x:0, y:0};
        let lastClickTime = 0; let lastClickNode = null;
        let clipboardData = null; let ctxMousePos = {x:0, y:0};

        let targetKf = null; let tlCtxTime = 0.0; let kfClipboard = null;

        // 右クリックメニューの標準動作を抑制(入力エリア以外)
        window.addEventListener('contextmenu', (e) => {
            if(e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') {
                e.preventDefault();
            }
        });

        // パラメータパネル自身をダブルクリックで閉じる機能
        paramPanel.addEventListener('dblclick', (e) => {
            if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
            e.stopPropagation();
            closeParamPanel();
        });

        // ==========================================
        // 1. Three.js エンジンの初期化
        // ==========================================
        const scene = new THREE.Scene();
        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
        const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); 
        
        window.uniforms = { u_time: { value: 0.0 }, u_aspect: { value: 1.0 } };
        
        let pw = previewContainer.clientWidth || 300; let ph = previewContainer.clientHeight || 300;
        renderer.setSize(pw, ph); window.uniforms.u_aspect.value = pw / ph;
        previewContainer.appendChild(renderer.domElement);
        
        const geometry = new THREE.PlaneGeometry(2, 2);
        let mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({color: 0x000000}));
        scene.add(mesh);
        
        const clock = new THREE.Clock();

        const resizeObserver = new ResizeObserver(() => {
            const w = previewContainer.clientWidth; const h = previewContainer.clientHeight;
            if(w > 0 && h > 0) { renderer.setSize(w, h); if(window.uniforms) window.uniforms.u_aspect.value = w / h; }
        });
        resizeObserver.observe(previewContainer);

        const textureLoader = new THREE.TextureLoader(); const textures = {}; 
        function loadTexture(id, dataUrl) {
            if(!dataUrl) return;
            textureLoader.load(dataUrl, tex => { tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; textures[id] = tex; updateShader(); });
        }

        // ==========================================
        // 2. ノードの定義 (NODE_DEFS) 
        // ==========================================
        const NODE_DEFS = {
            BASE: { type: 'BASE', emoji: '🎇', color: '#444455', radiusClosed: 16, radiusOpen: 60 },
            COLOR: { type: 'PARAM', subtype: 'COLOR', emoji: '🎨', color: '#ff55aa', radius: 14, params: [
                { key: 'h', label: '色相 (Hue)', min: 0, max: 1, def: 0.1, step: 0.01 },
                { key: 's', label: '彩度 (Sat)', min: 0, max: 1, def: 0.8, step: 0.01 },
                { key: 'v', label: '明度 (Val)', min: 0, max: 2, def: 1.5, step: 0.01 }
            ]},
            SHAPE: { type: 'PARAM', subtype: 'SHAPE', emoji: '📐', color: '#55aaff', radius: 14, params: [
                { key: 'n', label: '何角形? (0=円)', min: 0, max: 8, def: 4, step: 1 },
                { key: 'size', label: '大きさ', min: 0.01, max: 0.5, def: 0.1, step: 0.01 }
            ]},
            IMAGE: { type: 'PARAM', subtype: 'IMAGE', emoji: '🖼️', color: '#ffaaaa', radius: 14, params: [
                { key: 'img', label: '画像', type: 'image', def: '' },
                { key: 'size', label: '大きさ', min: 0.01, max: 1.0, def: 0.2, step: 0.01 }
            ]},
            ANIMATION: { type: 'PARAM', subtype: 'ANIMATION', emoji: '🎞️', color: '#ff88aa', radius: 14, params: [
                { key: 'img', label: '横並び画像', type: 'image', def: '' },
                { key: 'frames', label: 'フレーム総数', min: 1, max: 64, def: 4, step: 1 },
                { key: 'speed', label: '1Fの表示(秒)', min: 0.01, max: 1.0, def: 0.1, step: 0.01 },
                { key: 'size', label: '大きさ', min: 0.01, max: 1.0, def: 0.2, step: 0.01 }
            ]},
            CUSTOM: { type: 'PARAM', subtype: 'CUSTOM', emoji: '💻', color: '#00ffaa', radius: 14, params: [
                { key: 'code', label: 'GLSL(変数:p, t_tr を使用)', type: 'textarea', def: '// param_変数名 でスライダー自動生成\nfloat param_speed = 10.0;\nfloat param_amp = 0.05;\n\nlocal_d = length(p) - 0.2 + sin(p.y*20.0 + t_tr*param_speed)*param_amp;\nlocal_col = vec3(1.0);' }
            ]},
            M_LINEAR: { type: 'PARAM', subtype: 'MOTION_LINEAR', emoji: '➡️', color: '#aaff55', radius: 14, params: [
                { key: 'vx', label: 'X移動 (右/左)', min: -5, max: 5, def: 0.5, step: 0.01 },
                { key: 'vy', label: 'Y移動 (上/下)', min: -5, max: 5, def: 0.0, step: 0.01 },
                { key: 'angle', label: '全体の角度', min: 0, max: 360, def: 0, step: 1 }
            ]},
            M_SINE: { type: 'PARAM', subtype: 'MOTION_SINE', emoji: '↔️', color: '#55ffaa', radius: 14, params: [
                { key: 'amp', label: '振幅', min: 0, max: 2, def: 0.5, step: 0.01 },
                { key: 'angle', label: '角度(斜め)', min: 0, max: 360, def: 0, step: 1 },
                { key: 'freq', label: '速さ', min: 0, max: 10, def: 2.0, step: 0.1 }
            ]},
            M_BOUNCE: { type: 'PARAM', subtype: 'MOTION_BOUNCE', emoji: '🪀', color: '#ddff55', radius: 14, params: [
                { key: 'height', label: '跳ね高さ', min: 0, max: 1, def: 0.3, step: 0.01 },
                { key: 'speed', label: '速さ', min: 0, max: 10, def: 4.0, step: 0.1 }
            ]},
            M_PARABOLA: { type: 'PARAM', subtype: 'MOTION_PARABOLA', emoji: '⤴️', color: '#aaddff', radius: 14, params: [
                { key: 'vx', label: 'X速度', min: -2, max: 2, def: 0.5, step: 0.01 },
                { key: 'vy', label: '初期Yジャンプ', min: 0, max: 3, def: 1.5, step: 0.01 },
                { key: 'g', label: '重力', min: 0, max: 5, def: 3.0, step: 0.1 },
                { key: 'angle', label: '射出角度の傾き', min: 0, max: 360, def: 0, step: 1 }
            ]},
            M_SPIRAL: { type: 'PARAM', subtype: 'MOTION_SPIRAL', emoji: '🌀', color: '#55ffdd', radius: 14, params: [
                { key: 'radius', label: '広がる速さ', min: 0, max: 1, def: 0.3, step: 0.01 },
                { key: 'spin', label: '回転速度', min: -10, max: 10, def: 5.0, step: 0.1 },
                { key: 'limit', label: '最大範囲', min: 0.1, max: 5.0, def: 2.0, step: 0.1 } 
            ]},
            M_ROTATE: { type: 'PARAM', subtype: 'MOTION_ROTATE', emoji: '🔄', color: '#cc88ff', radius: 14, params: [
                { key: 'radius', label: '半径(0で自転のみ)', min: 0, max: 5, def: 0.0, step: 0.01 },
                { key: 'speed', label: '回転速度', min: -10, max: 10, def: 2.0, step: 0.1 },
                { key: 'accel', label: '回転加速度', min: -5, max: 5, def: 0.0, step: 0.01 },
                { key: 'angle', label: '向き(初期角度)', min: 0, max: 360, def: 0, step: 1 }
            ]},
            M_EASING: { type: 'PARAM', subtype: 'MOTION_EASING', emoji: '🧲', color: '#55aa55', radius: 14, params: [
                { key: 'mode', label: '1:In 2:Out 3:Smooth', min: 1, max: 3, def: 3, step: 1 }
            ]},
            M_HOMING: { type: 'PARAM', subtype: 'MOTION_HOMING', emoji: '🎯', color: '#aa5555', radius: 14, params: [
                { key: 'tx', label: 'ターゲット X', min: -5, max: 5, def: 0.0, step: 0.01 },
                { key: 'ty', label: 'ターゲット Y', min: -5, max: 5, def: 0.0, step: 0.01 },
                { key: 'speed', label: '追尾の強さ', min: 0, max: 5, def: 1.0, step: 0.01 }
            ]},
            M_SHAKE: { type: 'PARAM', subtype: 'MOTION_SHAKE', emoji: '🫨', color: '#dd8855', radius: 14, params: [
                { key: 'amp', label: '揺れの強さ', min: 0, max: 1, def: 0.1, step: 0.01 },
                { key: 'speed', label: '速さ', min: 0, max: 50, def: 15.0, step: 0.1 }
            ]},
            M_FALL: { type: 'PARAM', subtype: 'MOTION_FALL', emoji: '🪨', color: '#888888', radius: 14, params: [
                { key: 'g', label: '落下速度', min: 0, max: 5, def: 2.0, step: 0.1 },
                { key: 'spin', label: '回転', min: -5, max: 5, def: 1.0, step: 0.1 }
            ]},
            TIME: { type: 'PARAM', subtype: 'TIME', emoji: '⏳', color: '#ffaa00', radius: 14, params: [
                { key: 'delay', label: '発生遅延(秒)', min: 0, max: 5, def: 0.0, step: 0.1 },
                { key: 'life', label: '寿命(ループ秒数)', min: 0.1, max: 10, def: 3.0, step: 0.1 },
                { key: 'fade', label: '時間で消滅(0=無 1=有)', min: 0, max: 1, def: 1.0, step: 1 }
            ]},
            FILTER: { type: 'PARAM', subtype: 'FILTER', emoji: '🌟', color: '#ddddff', radius: 14, params: [
                { key: 'pixel', label: 'ドット化(粗さ)', min: 0, max: 50, def: 0, step: 1 },
                { key: 'glow', label: '発光(Glow)強度', min: 0, max: 2, def: 0.0, step: 0.01 }
            ]},
            PARTICLE: { type: 'PARAM', subtype: 'PARTICLE', emoji: '✨', color: '#ffcc55', radius: 14, params: [
                { key: 'count', label: '破片の数', min: 1, max: 30, def: 10, step: 1 },
                { key: 'spread', label: '散らばり幅', min: 0, max: 2, def: 0.5, step: 0.01 },
                { key: 'speed', label: '弾ける速さ', min: 0, max: 5, def: 1.0, step: 0.1 }
            ]},
            TRAIL: { type: 'PARAM', subtype: 'TRAIL', emoji: '💫', color: '#ffdd55', radius: 14, params: [
                { key: 'count', label: '残像の数', min: 1, max: 20, def: 5, step: 1 },
                { key: 'interval', label: '間隔(秒)', min: 0.01, max: 0.5, def: 0.05, step: 0.01 },
                { key: 'fade', label: '減衰率(0-1)', min: 0.1, max: 1.0, def: 0.6, step: 0.01 }
            ]},
            FILTER_DISTORT: { type: 'PARAM', subtype: 'FILTER_DISTORT', emoji: '🌊', color: '#5555ff', radius: 14, params: [
                { key: 'freq', label: '波の細かさ', min: 1, max: 20, def: 5.0, step: 0.1 },
                { key: 'power', label: '歪みの強さ', min: 0, max: 1.0, def: 0.1, step: 0.01 },
                { key: 'speed', label: '波の速さ', min: 0, max: 10, def: 2.0, step: 0.1 }
            ]}
        };

        // ==========================================
        // 3. データ処理・UIヘルパー関数群
        // ==========================================
        function serializeNode(node) {
            let clone = { id: node.id, x: node.x, y: node.y, type: node.type, subtype: node.subtype, refId: node.refId, emoji: node.emoji, color: node.color, radiusClosed: node.radiusClosed, radiusOpen: node.radiusOpen, radius: node.radius, params: node.params, vals: node.vals, isOpen: node.isOpen };
            if(node.children) clone.children = node.children.map(c => serializeNode(c));
            return clone;
        }

        function deserializeNode(data, x, y, keepId = false, targetArray) {
            let newId = keepId && data.id ? data.id : Math.random().toString(36).substr(2, 9);
            let n = { ...data, id: newId, x: x !== undefined ? x : data.x, y: y !== undefined ? y : data.y, parent: null, children: [] };
            targetArray.push(n);
            if(data.children) { 
                data.children.forEach(cData => { 
                    let child = deserializeNode(cData, cData.x, cData.y, keepId, targetArray); 
                    child.parent = n; 
                    n.children.push(child); 
                }); 
            }
            return n;
        }

        function pushHistory() {
            if(isRestoring) return;
            history = history.slice(0, historyIndex + 1);
            let state = { 
                keyframes: keyframes.map(kf => ({ 
                    id: kf.id, 
                    time: kf.time, 
                    nodes: kf.nodes.filter(n=>!n.parent).map(serializeNode) 
                })), 
                currentKfId: currentKfId,
                layout: { w: layoutW, h: layoutH, g: gridSz }, 
                aiSettings: aiSettings 
            };
            history.push(JSON.stringify(state)); historyIndex++;
        }
        
        function applyState(state) {
            clearNodesCore();
            layoutW = state.layout.w; layoutH = state.layout.h; gridSz = state.layout.g;
            document.getElementById('inp-ly-w').value = layoutW; document.getElementById('inp-ly-h').value = layoutH; document.getElementById('inp-ly-grid').value = gridSz;
            
            if(state.aiSettings) {
                aiSettings = state.aiSettings;
                document.getElementById('ai-api-key').value = aiSettings.apiKey || '';
                document.getElementById('ai-local-exe').value = aiSettings.localExe || 'server.exe';
                document.getElementById('ai-local-model').value = aiSettings.localModel || 'model.gguf';
                document.getElementById('ai-local-opt').value = aiSettings.localOpt || '-c 2048 -ngl 33';
                document.getElementById('ai-local-url').value = aiSettings.localUrl || 'http://127.0.0.1:8080/v1/chat/completions';
                document.getElementById('ai-local-ps').checked = aiSettings.usePS || false;
            }

            keyframes = [];
            state.keyframes.forEach(kfData => {
                let newNodes = [];
                kfData.nodes.forEach(nData => deserializeNode(nData, nData.x, nData.y, true, newNodes));
                keyframes.push({ id: kfData.id, time: kfData.time, nodes: newNodes });
            });
            
            let targetKf = keyframes.find(k => k.id === state.currentKfId) || keyframes[0];
            selectKeyframe(targetKf.id);

            let texNodes = [];
            keyframes.forEach(kf => {
                texNodes = texNodes.concat(getAllNodes(kf.nodes).filter(n => n.subtype === 'IMAGE' || n.subtype === 'ANIMATION'));
            });
            texNodes.forEach(n => { if(n.vals.img) loadTexture(n.id, n.vals.img); });

            resizeCanvas(); updateState();
        }

        window.undo = () => { if(historyIndex > 0) { historyIndex--; isRestoring = true; applyState(JSON.parse(history[historyIndex])); isRestoring = false; } };
        window.redo = () => { if(historyIndex < history.length - 1) { historyIndex++; isRestoring = true; applyState(JSON.parse(history[historyIndex])); isRestoring = false; } };
        window.openDialog = (id) => { document.getElementById(id).style.display = 'flex'; };
        window.closeDialog = (id) => { document.getElementById(id).style.display = 'none'; };

        window.applyLayoutSettings = () => {
            layoutW = parseInt(document.getElementById('inp-ly-w').value); layoutH = parseInt(document.getElementById('inp-ly-h').value); gridSz = parseInt(document.getElementById('inp-ly-grid').value);
            resizeCanvas(); closeDialog('dlg-layout'); pushHistory();
        };

        window.addLayoutSize = () => {
            let add = parseInt(document.getElementById('inp-ly-add').value);
            if(isNaN(add) || add === 0) return;
            layoutW += add * 2; layoutH += add * 2;
            document.getElementById('inp-ly-w').value = layoutW; document.getElementById('inp-ly-h').value = layoutH;
            keyframes.forEach(kf => {
                kf.nodes.filter(n => !n.parent).forEach(root => { root.x += add; root.y += add; });
            });
            resizeCanvas(); pushHistory(); alert("拡張しました");
        };

        window.applyTimelineSettings = () => {
            tDuration = parseFloat(document.getElementById('inp-tl-len').value); tSpeed = parseFloat(document.getElementById('inp-tl-spd').value); tLoop = document.getElementById('inp-tl-loop').checked;
            timeSlider.max = tDuration; closeDialog('dlg-timeline'); renderKeyframes();
        };

        window.togglePlay = () => { isPlaying = !isPlaying; playBtn.innerText = isPlaying ? '⏸️' : '▶️'; };
        
        timeSlider.addEventListener('input', (e) => { 
            currentTime = parseFloat(e.target.value); 
            timeDisplay.innerText = currentTime.toFixed(2); 
            window.uniforms.u_time.value = currentTime; 
            isPreviewing = true;
        });
        timeSlider.addEventListener('change', () => { isPreviewing = false; });

        window.copyGLSL = () => { navigator.clipboard.writeText(glslContent.innerText).then(() => alert('コピーしました!')); };

        let templates = JSON.parse(localStorage.getItem('gemTemplates') || '[]');
        window.saveTemplate = () => {
            if(!globalSelectedNode) return alert("対象を選択してください。");
            let name = prompt("テンプレート名を入力:"); if(!name) return;
            let data = serializeNode(globalSelectedNode);
            templates.push({ name: name, data: data });
            localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates();
            
            if(confirm("このテンプレートを個別のライブラリファイル(.json)としてローカルに保存しますか?\n(ライブラリフォルダに追加して管理できます)")) {
                const blob = new Blob([JSON.stringify({name: name, isTemplate: true, data: data}, null, 2)], {type: "application/json"});
                const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = `${name}_template.json`; a.click();
            }
        };
        function renderTemplates() {
            const tc = document.getElementById('template-container'); tc.innerHTML = '';
            templates.forEach((tpl, i) => {
                let btn = document.createElement('button'); btn.className = 'tpl-btn'; btn.innerText = tpl.name;
                btn.onclick = () => { 
                    let n = deserializeNode(tpl.data, layoutW/2, layoutH/2, false, nodes); 
                    let texNodes = getAllNodes([n]).filter(x => x.subtype === 'IMAGE' || x.subtype === 'ANIMATION');
                    texNodes.forEach(x => { if(x.vals.img) loadTexture(x.id, x.vals.img); });
                    
                    if (viewMode === 'tree' && globalSelectedNode) {
                        if (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF') {
                            n.parent = globalSelectedNode; globalSelectedNode.children.push(n); globalSelectedNode.isOpen = true;
                        } else if (globalSelectedNode.parent) {
                            n.parent = globalSelectedNode.parent; globalSelectedNode.parent.children.push(n);
                        } else { nodes.push(n); }
                    } else { nodes.push(n); }

                    globalSelectedNode = n; updateState(); pushHistory();
                };
                btn.oncontextmenu = (e) => { e.preventDefault(); if(confirm(`削除しますか?`)){ templates.splice(i, 1); localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates(); } };
                tc.appendChild(btn);
            });
        }

        const fileOps = {
            currentHandle: null,
            new: () => { if(confirm("新規作成しますか?")) { clearNodes(); fileOps.currentHandle = null; document.getElementById('proj-name').innerText = "新規プロジェクト"; pushHistory(); } },
            open: async () => {
                const input = document.getElementById('file-loader');
                input.onchange = async (e) => {
                    const file = e.target.files[0];
                    let state = JSON.parse(await file.text());
                    if(state.isTemplate) {
                        let n = deserializeNode(state.data, layoutW/2, layoutH/2, false, nodes); 
                        let texNodes = getAllNodes([n]).filter(x => x.subtype === 'IMAGE' || x.subtype === 'ANIMATION');
                        texNodes.forEach(x => { if(x.vals.img) loadTexture(x.id, x.vals.img); });
                        
                        if (viewMode === 'tree' && globalSelectedNode) {
                            if (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF') {
                                n.parent = globalSelectedNode; globalSelectedNode.children.push(n); globalSelectedNode.isOpen = true;
                            } else if (globalSelectedNode.parent) {
                                n.parent = globalSelectedNode.parent; globalSelectedNode.parent.children.push(n);
                            } else { nodes.push(n); }
                        } else { nodes.push(n); }

                        globalSelectedNode = n; updateState(); pushHistory();
                    } else {
                        if(!state.keyframes) { 
                            state.keyframes = [{ id: 'kf_0', time: 0.0, nodes: state.nodes }];
                            state.currentKfId = 'kf_0';
                        }
                        applyState(state);
                        document.getElementById('proj-name').innerText = file.name;
                        pushHistory();
                    }
                    input.value = '';
                };
                input.click();
            },
            saveAs: async () => {
                updateAISettingsFromUI();
                let state = { 
                    keyframes: keyframes.map(kf => ({ id: kf.id, time: kf.time, nodes: kf.nodes.filter(n=>!n.parent).map(serializeNode) })), 
                    currentKfId: currentKfId,
                    layout: { w: layoutW, h: layoutH, g: gridSz }, 
                    aiSettings: aiSettings 
                };
                const blob = new Blob([JSON.stringify(state, null, 2)], {type: "application/json"});
                const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = "effect_project.json"; a.click();
            },
            save: () => fileOps.saveAs(),
            exportGLSL: () => window.copyGLSL(),
            exportSpriteSheet: () => {
                let durationStr = prompt("アニメーションの総時間(秒)を入力してください", "2.0"); if(!durationStr) return;
                let fpsStr = prompt("フレームレート(FPS)を入力してください\n※高いと画像が巨大になります", "30"); if(!fpsStr) return;

                let duration = parseFloat(durationStr); let fps = parseInt(fpsStr);
                let totalFrames = Math.ceil(duration * fps);
                let cols = Math.ceil(Math.sqrt(totalFrames)); let rows = Math.ceil(totalFrames / cols);
                let frameW = 256; let frameH = 256;
                
                let outCanvas = document.createElement('canvas'); outCanvas.width = cols * frameW; outCanvas.height = rows * frameH;
                let outCtx = outCanvas.getContext('2d');
                
                let oldTime = window.uniforms.u_time.value; 
                let oldW = previewContainer.clientWidth; let oldH = previewContainer.clientHeight;
                renderer.setSize(frameW, frameH); window.uniforms.u_aspect.value = frameW / frameH;
                
                for(let i=0; i<totalFrames; i++) {
                    window.uniforms.u_time.value = i / fps; renderer.render(scene, camera);
                    let col = i % cols; let row = Math.floor(i / cols);
                    outCtx.drawImage(renderer.domElement, 0, 0, frameW, frameH, col * frameW, row * frameH, frameW, frameH);
                }
                
                window.uniforms.u_time.value = oldTime; 
                renderer.setSize(oldW, oldH); window.uniforms.u_aspect.value = oldW / oldH;
                
                let a = document.createElement('a'); a.href = outCanvas.toDataURL('image/png'); a.download = 'spritesheet.png'; a.click();
            }
        };
        window.fileOps = fileOps;

        // ==========================================
        // ★ ヘルパー関数 (追加) ★
        // ==========================================
        function getAllNodes(nodeList, visited = new Set()) {
            let result = [];
            nodeList.forEach(n => { if(visited.has(n)) return; visited.add(n); result.push(n); if(n.children) result = result.concat(getAllNodes(n.children, visited)); });
            return result;
        }

        function getAllBases(nodeList, visited = new Set()) {
            let result = [];
            nodeList.forEach(n => {
                if(visited.has(n)) return; visited.add(n);
                if(n.type === 'BASE' || n.type === 'BASE_REF') result.push(n);
                if(n.children) result = result.concat(getAllBases(n.children, visited));
            });
            return result;
        }

        function getLabelFromSubtype(subtype) {
            const map = {
                'COLOR': '色(HSV)', 'SHAPE': '形(多角形)', 'IMAGE': '画像読込', 'ANIMATION': 'アニメ読込', 'CUSTOM': 'カスタムGLSL',
                'MOTION_LINEAR': '直線移動', 'MOTION_SINE': '反復移動', 'MOTION_BOUNCE': 'バウンド', 'MOTION_PARABOLA': '放物線',
                'MOTION_SPIRAL': '渦巻き', 'MOTION_ROTATE': '円軌道', 'MOTION_HOMING': '追尾', 'MOTION_EASING': 'イージング',
                'MOTION_SHAKE': '揺らぎ', 'MOTION_FALL': '落下と弾み',
                'TIME': '時間制御', 'TRAIL': '残像・軌跡', 'FILTER_DISTORT': '空間歪み', 'FILTER': 'フィルター', 'PARTICLE': 'パーティクル'
            };
            return map[subtype] || subtype;
        }

        // 大元リストのインデックス名を取得する関数
        function getBaseNameDisplay(node) {
            let baseId = node.type === 'BASE_REF' ? node.refId : node.id;
            let allKFRoots = [];
            keyframes.forEach(kf => {
                kf.nodes.filter(n => n.type === 'BASE' && !n.parent).forEach(b => {
                    if(!allKFRoots.find(x => x.id === b.id)) allKFRoots.push(b);
                });
            });
            let idx = allKFRoots.findIndex(b => b.id === baseId);
            if (idx !== -1) return `${allKFRoots[idx].emoji} Base ${idx + 1} (Base)`;
            return '❓ 不明なベース (Base)';
        }

        // 最初の登場キーフレームにジャンプしてフォーカス
        function jumpToFirstAppearanceAndSelect(baseId) {
            let sortedKFs = keyframes.slice().sort((a,b) => a.time - b.time);
            for (let kf of sortedKFs) {
                let found = getAllNodes(kf.nodes).find(n => n.id === baseId || n.refId === baseId);
                if (found) {
                    if (currentKfId !== kf.id) {
                        selectKeyframe(kf.id);
                    }
                    globalSelectedNode = getAllNodes(nodes).find(n => n.id === found.id);
                    break;
                }
            }
            
            if (globalSelectedNode) {
                if (viewMode === 'canvas') {
                    nodes.filter(n => !n.parent).forEach(root => calcPos(root, root.x, root.y));
                    if (globalSelectedNode.screenX !== undefined) {
                        container.scrollLeft = (globalSelectedNode.screenX * zoom) - container.clientWidth / 2;
                        container.scrollTop = (globalSelectedNode.screenY * zoom) - container.clientHeight / 2;
                    }
                } else {
                    setTimeout(() => {
                        let el = document.getElementById('tree-node-' + globalSelectedNode.id);
                        if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' });
                    }, 50);
                }
            }
            updateState();
        }

        // ==========================================
        // ★ タイムライン・キーフレーム管理 ★
        // ==========================================
        function renderKeyframes() {
            kfContainer.innerHTML = '';
            keyframes.forEach(kf => {
                let pct = (kf.time / tDuration) * 100;
                let marker = document.createElement('div');
                marker.className = 'kf-marker';
                if (kf.id === currentKfId) marker.classList.add('selected');
                marker.style.left = `${pct}%`;
                
                marker.addEventListener('dblclick', (e) => {
                    e.stopPropagation(); selectKeyframe(kf.id);
                });
                marker.addEventListener('contextmenu', (e) => {
                    e.stopPropagation(); e.preventDefault(); showTimelineCtxMenu(e, kf);
                });
                kfContainer.appendChild(marker);
            });
        }

        function selectKeyframe(id) {
            let kf = keyframes.find(k => k.id === id);
            if(kf) {
                currentKfId = id; nodes = kf.nodes;
                currentTime = kf.time; timeSlider.value = currentTime;
                globalSelectedNode = null; closeParamPanel();
                updateState(); renderKeyframes();
            }
        }

        document.getElementById('timeline-container').addEventListener('contextmenu', (e) => {
            e.preventDefault();
            let rect = e.currentTarget.getBoundingClientRect();
            let pct = (e.clientX - rect.left) / rect.width;
            tlCtxTime = Math.max(0, Math.min(tDuration, pct * tDuration));
            showTimelineCtxMenu(e, null);
        });

        function showTimelineCtxMenu(e, kf) {
            tlCtxMenu.style.display = 'block';
            tlCtxMenu.style.left = e.clientX + 'px';
            tlCtxMenu.style.top = (e.clientY - 160) + 'px';
            targetKf = kf;
            
            document.getElementById('ctx-tl-add').style.display = kf ? 'none' : 'block';
            document.getElementById('ctx-tl-sep').style.display = kf ? 'none' : 'block';
            document.getElementById('ctx-tl-cut').style.display = kf ? 'block' : 'none';
            document.getElementById('ctx-tl-copy').style.display = kf ? 'block' : 'none';
            document.getElementById('ctx-tl-paste').style.display = (kfClipboard && !kf) ? 'block' : 'none';
            document.getElementById('ctx-tl-delete').style.display = kf ? 'block' : 'none';
        }

        document.getElementById('ctx-tl-add').onclick = () => {
            let sorted = keyframes.slice().sort((a,b)=>a.time - b.time);
            let prevKf = sorted.slice().reverse().find(k => k.time <= tlCtxTime) || sorted[0];
            let serialized = prevKf.nodes.filter(n=>!n.parent).map(serializeNode);
            let newNodes = [];
            serialized.forEach(data => deserializeNode(data, data.x, data.y, true, newNodes));
            
            let newKf = { id: 'kf_' + Math.random().toString(36).substr(2,9), time: tlCtxTime, nodes: newNodes };
            keyframes.push(newKf); selectKeyframe(newKf.id); pushHistory();
            tlCtxMenu.style.display = 'none';
        };

        document.getElementById('ctx-tl-delete').onclick = () => {
            if (keyframes.length <= 1) return alert("最後のキーフレームは削除できません。");
            keyframes = keyframes.filter(k => k.id !== targetKf.id);
            if (currentKfId === targetKf.id) selectKeyframe(keyframes[0].id);
            else { renderKeyframes(); updateShader(); }
            pushHistory(); tlCtxMenu.style.display = 'none';
        };

        document.getElementById('ctx-tl-copy').onclick = () => {
            kfClipboard = targetKf.nodes.filter(n=>!n.parent).map(serializeNode); tlCtxMenu.style.display = 'none';
        };

        document.getElementById('ctx-tl-cut').onclick = () => {
            if (keyframes.length <= 1) return alert("最後のキーフレームはカットできません。");
            kfClipboard = targetKf.nodes.filter(n=>!n.parent).map(serializeNode);
            keyframes = keyframes.filter(k => k.id !== targetKf.id);
            if (currentKfId === targetKf.id) selectKeyframe(keyframes[0].id);
            else { renderKeyframes(); updateShader(); }
            pushHistory(); tlCtxMenu.style.display = 'none';
        };

        document.getElementById('ctx-tl-paste').onclick = () => {
            if(!kfClipboard) return;
            let newNodes = []; kfClipboard.forEach(data => deserializeNode(data, data.x, data.y, true, newNodes));
            let newKf = { id: 'kf_' + Math.random().toString(36).substr(2,9), time: tlCtxTime, nodes: newNodes };
            keyframes.push(newKf); selectKeyframe(newKf.id); pushHistory(); tlCtxMenu.style.display = 'none';
        };


        // ==========================================
        // ★ AI 生成連携ロジック ★
        // ==========================================
        function updateAISettingsFromUI() {
            aiSettings.apiKey = document.getElementById('ai-api-key').value;
            aiSettings.localExe = document.getElementById('ai-local-exe').value;
            aiSettings.localModel = document.getElementById('ai-local-model').value;
            aiSettings.localOpt = document.getElementById('ai-local-opt').value;
            aiSettings.localUrl = document.getElementById('ai-local-url').value;
            aiSettings.usePS = document.getElementById('ai-local-ps').checked;
        }

        function buildAIPrompt(userReq) {
            return `あなたはGLSLシェーダーの専門家です。以下のフォーマットとユーザーの要望に従って、エフェクトのGLSLコードを生成してください。

【仕様】
・座標は vec2 p (中心が0.0, 画面アスペクト比補正済み) です。
・時間は float t_tr です。
・最終的な形状(距離関数)を float local_d に代入してください。
・最終的な色(RGB)を vec3 local_col に代入してください。
・hsv2rgb(vec3(h,s,v)) 関数、noise3D(vec3) 関数、flame3D(vec3, float) 関数が利用可能です。
・ユーザーがスライダーで調整可能な変数は、コードの先頭で \`float param_名前 = 初期値;\` の形式で必ず宣言してください。(例: float param_speed = 1.0;)
・GLSLのソースコードだけを書いてください。説明などは絶対に書いてはいけない。

【ユーザーの要望】
${userReq}

【出力形式】
GLSLコードのみを出力してください。マークダウンや説明は不要です。`;
        }

        function applyAICode(rawText) {
            let code = rawText.trim();
            let match = code.match(/```(?:glsl)?\n([\s\S]*?)```/);
            if (match) code = match[1];

            let target = globalSelectedNode;
            if (!target || target.subtype !== 'CUSTOM') {
                 window.addNode('CUSTOM'); target = nodes[nodes.length - 1];
            }
            target.vals.code = code;
            if(target.vals.customParams) target.vals.customParams = {};
            updateState(); pushHistory(); openParamPanel(target);
            alert("AI生成コードを適用しました!");
        }

        window.openWizard = (id) => { document.getElementById(id).style.display = 'flex'; window.wizardNext(1); };
        window.wizardNext = (step) => {
            document.getElementById('wizard-step-1').style.display = step===1?'block':'none';
            document.getElementById('wizard-step-2').style.display = step===2?'block':'none';
            document.getElementById('wizard-step-3').style.display = step===3?'block':'none';
            if(step===2) document.getElementById('ai-manual-prompt').value = buildAIPrompt(document.getElementById('ai-manual-req').value);
        };
        window.copyManualPrompt = () => { navigator.clipboard.writeText(document.getElementById('ai-manual-prompt').value).then(() => alert('プロンプトをコピーしました。AIに送信してください。')); };
        window.execAIManual = () => { applyAICode(document.getElementById('ai-manual-res').value); closeDialog('dlg-ai-manual'); };

        let aiApiAbortController = null; let aiLocalAbortController = null;
        window.cancelAIApi = () => { if(aiApiAbortController) aiApiAbortController.abort(); closeDialog('dlg-ai-api'); };
        window.cancelAILocal = () => { if(aiLocalAbortController) aiLocalAbortController.abort(); closeDialog('dlg-ai-local'); };

        window.checkLocalServer = async () => {
            updateAISettingsFromUI();
            try {
                let url = new URL(aiSettings.localUrl); let healthUrl = `${url.protocol}//${url.host}/health`; 
                let res = await fetch(healthUrl, { method: 'GET' });
                if(res.ok) alert("✅ サーバーは正常に稼働しています!"); else alert("⚠️ サーバーには接続できましたが、正常な応答がありません。");
            } catch(e) { alert("❌ サーバーに接続できません。\nまだ起動していないか、URLが間違っている可能性があります。"); }
        };

        window.execAIApi = async () => {
            updateAISettingsFromUI(); let key = aiSettings.apiKey; let req = document.getElementById('ai-api-req').value;
            if(!key) return alert("APIキーを入力してください。"); if(!req) return alert("要望を入力してください。");
            aiApiAbortController = new AbortController(); let btn = document.getElementById('btn-ai-api-exec');
            btn.innerText = "生成中..."; btn.disabled = true;
            try {
                let res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${key}`, {
                    method: 'POST', headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({ contents: [{ parts: [{ text: buildAIPrompt(req) }] }] }), signal: aiApiAbortController.signal
                });
                let data = await res.json(); if(data.error) throw new Error(data.error.message);
                applyAICode(data.candidates[0].content.parts[0].text); closeDialog('dlg-ai-api');
            } catch(e) { if(e.name !== 'AbortError') alert("APIエラー: " + e.message); } 
            finally { btn.innerText = "✨ 自動生成"; btn.disabled = false; aiApiAbortController = null; }
        };

        window.copyLocalCommand = () => {
            updateAISettingsFromUI();
            let cmd = `"${aiSettings.localExe}" -m "${aiSettings.localModel}" ${aiSettings.localOpt}`;
            if(aiSettings.usePS) cmd = `& ` + cmd; 
            navigator.clipboard.writeText(cmd).then(() => alert(`起動コマンドをコピーしました。\nターミナル等で実行してください。\n\nコピー内容: ${cmd}`));
        };
        
        window.execAILocal = async () => {
            updateAISettingsFromUI(); let req = document.getElementById('ai-local-req').value;
            if(!req) return alert("要望を入力してください。");
            aiLocalAbortController = new AbortController(); let btn = document.getElementById('btn-ai-local-exec');
            btn.innerText = "生成中..."; btn.disabled = true;
            try {
                let res = await fetch(aiSettings.localUrl, {
                    method: 'POST', headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify({ messages: [{role: 'user', content: buildAIPrompt(req)}], temperature: 0.7 }), signal: aiLocalAbortController.signal
                });
                let data = await res.json(); applyAICode(data.choices[0].message.content); closeDialog('dlg-ai-local');
            } catch(e) { if(e.name !== 'AbortError') alert("ローカルサーバーに接続できません。\n詳細: " + e.message); } 
            finally { btn.innerText = "✨ 接続して生成"; btn.disabled = false; aiLocalAbortController = null; }
        };


        // ==========================================
        // ★ GLSL最適化ロジック ★
        // ==========================================

        // 最適化プロンプトを生成する
        function buildOptimizePrompt(glslSrc, userHint) {
            let hintSection = userHint && userHint.trim()
                ? '\n【追加の最適化指示】\n' + userHint.trim() + '\n'
                : '';
            return `あなたはGLSLシェーダーの最適化専門家です。以下のGLSLフラグメントシェーダーを、シェーダーの観点から最適化してください。

【最適化の方針】
・定数式は事前計算・折り畳みを行い、実行時演算を減らしてください。
・繰り返し計算される共通部分式を変数にまとめてください。
・不要な中間変数や冗長な計算を削除してください。
・条件分岐(if/else)はstep/clamp/mix等を使った分岐レスな演算に置き換えてください。
・ループは可能な限りアンロール(展開)または削減してください。
・uniform/varyingは型・名前を変更しないでください(外部から渡されます)。
・GLSLの構文や組み込み関数(vec2/vec3/mix/smoothstep等)は正しく使ってください。
・ユーザー定義関数(hsv2rgb, noise3D, flame3D等)のシグネチャは変更しないでください。
・最終的な視覚的出力(gl_FragColor)は元のコードと同等を保ってください。
・最適化済みGLSLのソースコードだけを出力してください。説明・コメント・マークダウンは一切不要です。
` + hintSection + `
【最適化対象のGLSLコード】
` + glslSrc + `

【出力】
最適化済みGLSLコードのみを出力してください。マークダウンや説明は不要です。`;
        }

        // 最適化済みコードをプレビューと表示領域に適用する
        window.applyOptimizedGLSL = (rawText) => {
            let code = rawText.trim();
            let match = code.match(/```(?:glsl)?\n([\s\S]*?)```/);
            if (match) code = match[1].trim();
            if (!code) return alert("最適化コードが空です。");

            glslContent.innerText = code;
            if (mesh) {
                const newMat = new THREE.ShaderMaterial({
                    vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
                    fragmentShader: code, uniforms: window.uniforms,
                    blending: THREE.AdditiveBlending, transparent: true
                });
                if (mesh.material) mesh.material.dispose();
                mesh.material = newMat;
            }
            alert("✅ 最適化済みGLSLを適用しました!\n元に戻すには Ctrl+Z (Undo) を使用してください。");
        };

        // 現在のGLSLをダイアログのtextareaに読み込む
        window.loadCurrentGLSLToOptimize = (mode) => {
            let src = glslContent.innerText.trim();
            if (!src || src.startsWith('//')) return alert("まず編集データを作成してGLSLを生成してください。");
            if (mode === 'api')    document.getElementById('opt-api-src').value = src;
            if (mode === 'local')  document.getElementById('opt-local-src').value = src;
            if (mode === 'manual') document.getElementById('opt-manual-src').value = src;
        };

        // 最適化ダイアログを開く(同時にGLSLを自動読み込み)
        window.openOptimizeDialog = (mode) => {
            updateAISettingsFromUI();
            if (mode === 'api') {
                document.getElementById('opt-api-key').value = aiSettings.apiKey || '';
                openDialog('dlg-opt-api');
                loadCurrentGLSLToOptimize('api');
            } else if (mode === 'local') {
                openDialog('dlg-opt-local');
                loadCurrentGLSLToOptimize('local');
            } else if (mode === 'manual') {
                document.getElementById('opt-wizard-step-1').style.display = 'block';
                document.getElementById('opt-wizard-step-2').style.display = 'none';
                document.getElementById('opt-wizard-step-3').style.display = 'none';
                openDialog('dlg-opt-manual');
                loadCurrentGLSLToOptimize('manual');
            }
        };

        // 手動ウィザードのステップ移動
        window.optManualNext = (step) => {
            document.getElementById('opt-wizard-step-1').style.display = step === 1 ? 'block' : 'none';
            document.getElementById('opt-wizard-step-2').style.display = step === 2 ? 'block' : 'none';
            document.getElementById('opt-wizard-step-3').style.display = step === 3 ? 'block' : 'none';
            if (step === 2) {
                let src = document.getElementById('opt-manual-src').value.trim();
                let hint = document.getElementById('opt-manual-hint').value.trim();
                if (!src) return alert("最適化対象のGLSLコードを入力してください。");
                document.getElementById('opt-manual-prompt').value = buildOptimizePrompt(src, hint);
            }
        };

        // 手動モードのプロンプトをコピー
        window.copyOptManualPrompt = () => {
            navigator.clipboard.writeText(document.getElementById('opt-manual-prompt').value)
                .then(() => alert('プロンプトをコピーしました。AIに送信してください。'));
        };

        // API(Gemini)で最適化実行
        let optApiAbortController = null;
        window.execOptimizeApi = async () => {
            let key = document.getElementById('opt-api-key').value.trim();
            let src = document.getElementById('opt-api-src').value.trim();
            let hint = document.getElementById('opt-api-hint').value.trim();
            if (!key) return alert("APIキーを入力してください。");
            if (!src) return alert("最適化対象のGLSLコードを入力してください。");
            aiSettings.apiKey = key;
            document.getElementById('ai-api-key').value = key;

            optApiAbortController = new AbortController();
            let btn = document.getElementById('btn-opt-api-exec');
            btn.innerText = "最適化中..."; btn.disabled = true;
            try {
                let res = await fetch(
                    `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=${key}`,
                    {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({ contents: [{ parts: [{ text: buildOptimizePrompt(src, hint) }] }] }),
                        signal: optApiAbortController.signal
                    }
                );
                let data = await res.json();
                if (data.error) throw new Error(data.error.message);
                let result = data.candidates[0].content.parts[0].text;
                applyOptimizedGLSL(result);
                closeDialog('dlg-opt-api');
            } catch (e) {
                if (e.name !== 'AbortError') alert("APIエラー: " + e.message);
            } finally {
                btn.innerText = "⚡ 最適化実行"; btn.disabled = false; optApiAbortController = null;
            }
        };

        // ローカルLLMで最適化実行
        let optLocalAbortController = null;
        window.execOptimizeLocal = async () => {
            updateAISettingsFromUI();
            let src = document.getElementById('opt-local-src').value.trim();
            let hint = document.getElementById('opt-local-hint').value.trim();
            if (!src) return alert("最適化対象のGLSLコードを入力してください。");

            optLocalAbortController = new AbortController();
            let btn = document.getElementById('btn-opt-local-exec');
            btn.innerText = "最適化中..."; btn.disabled = true;
            try {
                let res = await fetch(aiSettings.localUrl, {
                    method: 'POST',
                    headers: { 'Content-Type': 'application/json' },
                    body: JSON.stringify({
                        messages: [{ role: 'user', content: buildOptimizePrompt(src, hint) }],
                        temperature: 0.2
                    }),
                    signal: optLocalAbortController.signal
                });
                let data = await res.json();
                applyOptimizedGLSL(data.choices[0].message.content);
                closeDialog('dlg-opt-local');
            } catch (e) {
                if (e.name !== 'AbortError') alert("ローカルサーバーに接続できません。\n詳細: " + e.message);
            } finally {
                btn.innerText = "⚡ 最適化実行"; btn.disabled = false; optLocalAbortController = null;
            }
        };

        // ==========================================
        // 4. 左側リスト更新とドラッグ&ドロップ(BASE_REF)
        // ==========================================
        function updateObjectList() {
            objList.innerHTML = '';
            let allKFRoots = [];
            keyframes.forEach(kf => {
                kf.nodes.filter(n => n.type === 'BASE' && !n.parent).forEach(b => {
                    if(!allKFRoots.find(x => x.id === b.id)) allKFRoots.push(b);
                });
            });

            allKFRoots.forEach((root, idx) => {
                let div = document.createElement('div'); div.className = 'obj-list-item'; 
                div.innerText = `${root.emoji} Base ${idx + 1}`;
                if(globalSelectedNode && (globalSelectedNode.id === root.id || globalSelectedNode.refId === root.id)) {
                    div.style.borderColor = '#00ffcc';
                }
                div.draggable = true; 
                div.ondragstart = (e) => { e.dataTransfer.setData('text/plain', root.id); };
                div.onclick = () => { jumpToFirstAppearanceAndSelect(root.id); };
                objList.appendChild(div);
            });
        }

        canvas.addEventListener('dragover', e => { e.preventDefault(); });
        canvas.addEventListener('drop', e => {
            e.preventDefault(); const refId = e.dataTransfer.getData('text/plain');
            if(refId) {
                const rect = canvas.getBoundingClientRect();
                let nx = (e.clientX - rect.left) / zoom; let ny = (e.clientY - rect.top) / zoom;
                if(document.getElementById('snap-toggle').checked) { nx = Math.round(nx/gridSz)*gridSz; ny = Math.round(ny/gridSz)*gridSz; }
                let n = { id: Math.random().toString(36).substr(2, 9), type: 'BASE_REF', refId: refId, x: nx, y: ny, parent: null, children: [], isOpen: false };
                nodes.push(n); globalSelectedNode = n; updateState(); pushHistory();
            }
        });

        // ==========================================
        // ★ ツリービュー制御とD&D ★
        // ==========================================
        function updateState(skipTreeRender = false) { 
            updateObjectList(); 
            renderKeyframes(); 
            updateShader(); 

            if (viewMode === 'tree') {
                treeViewContainer.style.display = 'block';
                canvas.style.display = 'none';
                if (!skipTreeRender) renderTreeView();
            } else {
                treeViewContainer.style.display = 'none';
                canvas.style.display = 'block';
            }
            updateParamPanelPos();
        }

        window.toggleViewMode = () => {
            viewMode = viewMode === 'canvas' ? 'tree' : 'canvas';
            updateState();
        };

        function renderTreeView() {
            treeViewContent.innerHTML = '';
            let rootNodes = nodes.filter(n => !n.parent);
            rootNodes.forEach(node => { treeViewContent.appendChild(createTreeDOM(node)); });
        }

        function createTreeDOM(node) {
            let el = document.createElement('div');
            el.id = 'tree-node-' + node.id;
            el.className = 'tree-node ' + (node.type === 'BASE' || node.type === 'BASE_REF' ? 'base-node' : 'param-node');
            if (globalSelectedNode && node.id === globalSelectedNode.id) el.classList.add('selected');

            // --- ツリーノードのドラッグ&ドロップ設定 ---
            el.draggable = true;
            el.addEventListener('dragstart', (e) => {
                e.dataTransfer.setData('application/tree-node', node.id);
                e.stopPropagation();
            });
            el.addEventListener('dragover', (e) => {
                e.preventDefault(); e.stopPropagation();
                el.classList.add('drag-over');
            });
            el.addEventListener('dragleave', (e) => {
                el.classList.remove('drag-over');
            });
            el.addEventListener('drop', (e) => {
                e.preventDefault(); e.stopPropagation();
                el.classList.remove('drag-over');
                let dragId = e.dataTransfer.getData('application/tree-node');
                if (dragId) handleTreeDrop(dragId, node);
            });

            let header = document.createElement('div');
            header.className = 'node-header';
            let labelText = '';
            if(node.type === 'BASE' || node.type === 'BASE_REF') {
                labelText = getBaseNameDisplay(node);
            } else {
                labelText = `${node.emoji} ${getLabelFromSubtype(node.subtype)}`;
            }
            header.innerText = labelText;

            // ツリー項目選択 (クリックで他のパネルを閉じ、右クリックメニューなどを処理)
            header.addEventListener('pointerdown', (e) => {
                e.stopPropagation();
                let clickedNode = node;
                
                if (clickedNode.type === 'BASE' || clickedNode.type === 'BASE_REF') {
                    let baseId = clickedNode.type === 'BASE_REF' ? clickedNode.refId : clickedNode.id;
                    let sortedKFs = keyframes.slice().sort((a,b) => a.time - b.time);
                    for (let kf of sortedKFs) {
                        let found = getAllNodes(kf.nodes).find(n => n.id === baseId || n.refId === baseId);
                        if (found) {
                            if (currentKfId !== kf.id) {
                                selectKeyframe(kf.id);
                                clickedNode = getAllNodes(nodes).find(n => n.id === found.id) || clickedNode;
                            }
                            break;
                        }
                    }
                }

                let isSameNode = (globalSelectedNode && globalSelectedNode.id === clickedNode.id);
                globalSelectedNode = clickedNode;
                
                // DOM再構築を防ぐため手動でクラス変更
                document.querySelectorAll('.tree-node').forEach(nEl => nEl.classList.remove('selected'));
                el.classList.add('selected');
                
                updateState(true); 
                
                if(e.button === 2) {
                    ctxMenuNode.style.display = 'block';
                    ctxMenuNode.style.left = e.clientX + 'px';
                    ctxMenuNode.style.top = e.clientY + 'px';
                    document.getElementById('ctx-cut').style.display = 'block';
                    document.getElementById('ctx-copy').style.display = 'block';
                    document.getElementById('ctx-delete').style.display = 'block';
                    document.getElementById('ctx-paste').style.display = clipboardData ? 'block' : 'none';
                } else {
                    if (!isSameNode) closeParamPanel();
                }
            });

            // 展開・パネルトグル(ダブルクリック)
            header.addEventListener('dblclick', (e) => {
                e.stopPropagation();
                if (node.type === 'BASE' || node.type === 'BASE_REF') {
                    node.isOpen = !node.isOpen; 
                    updateState(); 
                } else if (node.type === 'PARAM') {
                    if (paramPanel.style.display === 'block') {
                        closeParamPanel();
                    } else {
                        openParamPanel(node);
                    }
                }
            });

            // 閉じる(何もないところダブルクリック)
            el.addEventListener('dblclick', (e) => {
                if(e.target === el || e.target.classList.contains('node-children')) {
                    if((node.type === 'BASE' || node.type === 'BASE_REF') && node.isOpen) {
                        node.isOpen = false; updateState();
                    }
                    e.stopPropagation();
                }
            });

            el.appendChild(header);

            if ((node.type === 'BASE' || node.type === 'BASE_REF') && node.isOpen && node.children && node.children.length > 0) {
                let childrenContainer = document.createElement('div');
                childrenContainer.className = 'node-children';
                node.children.forEach(child => {
                    childrenContainer.appendChild(createTreeDOM(child));
                });
                el.appendChild(childrenContainer);
            }
            return el;
        }

        // ツリー背景へドロップ(ルートへ移動)
        treeViewContainer.addEventListener('dragover', (e) => { e.preventDefault(); });
        treeViewContainer.addEventListener('drop', (e) => {
            e.preventDefault();
            let dragId = e.dataTransfer.getData('application/tree-node');
            if (dragId && (e.target === treeViewContainer || e.target === treeViewContent)) {
                handleTreeDrop(dragId, null);
            }
        });

        window.handleTreeDrop = function(dragId, targetNode) {
            if (dragId === targetNode?.id) return;
            let dragNode = getAllNodes(nodes).find(n => n.id === dragId);
            if (!dragNode) return;
            
            if (targetNode && isDescendant(dragNode, targetNode)) return; // 循環参照防止
            
            let parentNode = targetNode;
            if (targetNode && targetNode.type !== 'BASE' && targetNode.type !== 'BASE_REF') {
                parentNode = targetNode.parent;
            }
            
            if (dragNode.parent) {
                dragNode.parent.children = dragNode.parent.children.filter(c => c !== dragNode);
            } else {
                nodes = nodes.filter(n => n !== dragNode);
            }
            
            if (parentNode) {
                dragNode.parent = parentNode;
                parentNode.children.push(dragNode);
                parentNode.isOpen = true;
            } else {
                dragNode.parent = null;
                nodes.push(dragNode);
            }
            
            globalSelectedNode = dragNode;
            updateState(); pushHistory();
        };

        // ツリービュー全体ダブルクリックでレイアウトに戻る (F1と同じ)
        treeViewContainer.addEventListener('dblclick', (e) => {
            if (e.target === treeViewContainer || e.target === treeViewContent) {
                window.toggleViewMode();
            }
        });

        // ==========================================
        // 5. ツリー構造・UIロジック (スクロール/ズーム対応)
        // ==========================================
        function resizeCanvas() {
            canvas.width = layoutW * zoom; canvas.height = layoutH * zoom;
            canvas.style.width = `${layoutW * zoom}px`; canvas.style.height = `${layoutH * zoom}px`;
            zoomValDisplay.innerText = `${Math.round(zoom * 100)}%`;
            updateState();
        }

        function clearNodesCore() { 
            keyframes = [{ id: 'kf_' + Date.now(), time: 0.0, nodes: [] }];
            currentKfId = keyframes[0].id; nodes = keyframes[0].nodes;
            globalSelectedNode = null; closeParamPanel(); 
        }
        window.clearNodes = () => { clearNodesCore(); updateState(); pushHistory(); };

        window.addNode = (defKey) => {
            const def = NODE_DEFS[defKey]; const vals = {}; if(def.params) def.params.forEach(p => vals[p.key] = p.def);
            let cx = (container.scrollLeft + container.clientWidth/2) / zoom; let cy = (container.scrollTop + container.clientHeight/2) / zoom;
            let n = { id: Math.random().toString(36).substr(2, 9), ...def, vals: vals, x: cx, y: cy, parent: null, children: [], isOpen: true };
            
            if (viewMode === 'tree' && globalSelectedNode && defKey !== 'BASE') {
                if (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF') {
                    n.parent = globalSelectedNode; globalSelectedNode.children.push(n); globalSelectedNode.isOpen = true;
                } else if (globalSelectedNode.parent) {
                    n.parent = globalSelectedNode.parent; globalSelectedNode.parent.children.push(n);
                } else { nodes.push(n); }
            } else { nodes.push(n); }
            
            globalSelectedNode = n; updateState(); pushHistory();
        };

        function isBaseOpen(node) {
            if(node.type !== 'BASE' && node.type !== 'BASE_REF') return false;
            if(node.isOpen) return true;
            if(node.children && node.children.some(c => (c.type === 'BASE' || c.type === 'BASE_REF') && isBaseOpen(c))) return true;
            return false;
        }

        function getVisibleNodes(nodeList, visited = new Set()) {
            let result = [];
            nodeList.forEach(n => {
                if(visited.has(n)) return; visited.add(n); result.push(n);
                if(n.type === 'BASE' || n.type === 'BASE_REF') { if(n.isOpen && n.children) result = result.concat(getVisibleNodes(n.children, visited)); } 
                else if(n.children) { result = result.concat(getVisibleNodes(n.children, visited)); }
            });
            return result;
        }

        function getHitTarget(pos, ignoreNode = null) {
            let visibleAll = getVisibleNodes(nodes.filter(n => !n.parent)).filter(n => n !== ignoreNode);
            let target = null; let minR = 9999;
            visibleAll.forEach(n => {
                if(n.screenX === undefined) return;
                let r = (n.type === 'BASE' || n.type === 'BASE_REF') ? (n.isOpen ? n.radiusOpen : n.radiusClosed) : n.radius;
                if(Math.hypot(n.screenX - pos.x, n.screenY - pos.y) < r + 5) { if (r < minR) { minR = r; target = n; } }
            });
            return target;
        }

        function isDescendant(parent, child) {
            if(!parent.children) return false; if(parent.children.includes(child)) return true;
            return parent.children.some(c => isDescendant(c, child));
        }
        function getDepth(node) { let d = 0; let curr = node; while(curr.parent) { d++; curr = curr.parent; } return d; }

        function getDropTarget(dragNode) {
            let target = null; let maxDepth = -1;
            let visibleAll = getVisibleNodes(nodes.filter(n => !n.parent)).filter(n => n !== dragNode && !isDescendant(dragNode, n));
            visibleAll.forEach(n => {
                if(n.screenX === undefined || (n.type !== 'BASE' && n.type !== 'BASE_REF')) return;
                let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
                if(Math.hypot(n.screenX - dragNode.x, n.screenY - dragNode.y) < r + 15) {
                    let depth = getDepth(n); if(depth > maxDepth) { maxDepth = depth; target = n; }
                }
            });
            return target;
        }

        function openParamPanel(node) {
            if (node.type === 'BASE' || node.type === 'BASE_REF') return;
            paramPanel.style.display = 'block'; paramTitle.innerText = `${node.emoji} の調整`;
            paramContent.innerHTML = '';
            
            node.params.forEach(p => {
                let row = document.createElement('div'); row.className = 'param-row';
                let labelDiv = document.createElement('label'); let nameSpan = document.createElement('span'); nameSpan.innerText = p.label;
                let valSpan = document.createElement('span'); valSpan.innerText = (p.type === 'image' || p.type === 'textarea') ? '' : node.vals[p.key];
                labelDiv.appendChild(nameSpan); labelDiv.appendChild(valSpan);
                
                if(p.type === 'image') {
                    let input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
                    let thumb = document.createElement('img'); thumb.style.width = '100%'; thumb.style.height = '40px'; thumb.style.objectFit = 'contain'; 
                    thumb.style.display = node.vals[p.key] ? 'block' : 'none'; if(node.vals[p.key]) thumb.src = node.vals[p.key];
                    input.addEventListener('change', (e) => {
                        const file = e.target.files[0]; if(!file) return; const reader = new FileReader();
                        reader.onload = (re) => { node.vals[p.key] = re.target.result; thumb.src = re.target.result; thumb.style.display = 'block'; loadTexture(node.id, re.target.result); pushHistory();};
                        reader.readAsDataURL(file);
                    });
                    row.appendChild(labelDiv); row.appendChild(input); row.appendChild(thumb);
                } else if(p.type === 'textarea') {
                    let input = document.createElement('textarea'); 
                    input.style.width = '100%'; input.style.height = '80px'; 
                    input.style.background = '#050508'; input.style.color = '#00ffcc'; 
                    input.style.border = '1px solid #555'; input.style.fontFamily = 'monospace'; 
                    input.style.fontSize = '10px'; input.style.resize = 'vertical';
                    input.value = node.vals[p.key];
                    input.addEventListener('input', (e) => { node.vals[p.key] = e.target.value; updateState(); });
                    input.addEventListener('change', () => { pushHistory(); openParamPanel(node); }); 
                    row.appendChild(labelDiv); row.appendChild(input); 
                } else {
                    let input = document.createElement('input'); input.type = 'range'; input.min = p.min; input.max = p.max; input.step = p.step; input.value = node.vals[p.key];
                    input.addEventListener('input', (e) => { let val = parseFloat(e.target.value); node.vals[p.key] = val; valSpan.innerText = val.toFixed(2); updateState(); });
                    input.addEventListener('change', () => { pushHistory(); });
                    row.appendChild(labelDiv); row.appendChild(input); 
                }
                paramContent.appendChild(row);
            });

            if(node.subtype === 'CUSTOM') {
                let code = node.vals.code;
                let paramRegex = /float\s+(param_[a-zA-Z0-9_]+)\s*=\s*([0-9.-]+);/g;
                let match;
                if (!node.vals.customParams) node.vals.customParams = {};
                
                let customParamContainer = document.createElement('div');
                customParamContainer.style.marginTop = '10px'; customParamContainer.style.borderTop = '1px dashed #555'; customParamContainer.style.paddingTop = '8px';
                
                let hasParams = false;
                while ((match = paramRegex.exec(code)) !== null) {
                    hasParams = true; let pName = match[1]; let pDef = parseFloat(match[2]);
                    if (node.vals.customParams[pName] === undefined) node.vals.customParams[pName] = pDef;
                    
                    let row = document.createElement('div'); row.className = 'param-row';
                    let labelDiv = document.createElement('label'); 
                    let nameSpan = document.createElement('span'); nameSpan.innerText = pName.replace('param_', '⚙️ ');
                    nameSpan.style.color = '#00ffaa';
                    let valSpan = document.createElement('span'); valSpan.innerText = node.vals.customParams[pName].toFixed(2);
                    labelDiv.appendChild(nameSpan); labelDiv.appendChild(valSpan);
                    
                    let input = document.createElement('input'); input.type = 'range';
                    let range = Math.max(Math.abs(pDef) * 5, 10);
                    input.min = pDef < 0 ? -range : 0; input.max = range; input.step = range / 1000;
                    input.value = node.vals.customParams[pName];
                    
                    input.addEventListener('input', (e) => {
                        let val = parseFloat(e.target.value); node.vals.customParams[pName] = val; valSpan.innerText = val.toFixed(2); updateState();
                    });
                    input.addEventListener('change', () => { pushHistory(); });
                    row.appendChild(labelDiv); row.appendChild(input); customParamContainer.appendChild(row);
                }
                if(hasParams) paramContent.appendChild(customParamContainer);
            }
            updateParamPanelPos(node);
        }
        
        function closeParamPanel() { paramPanel.style.display = 'none'; }
        
        function updateParamPanelPos(node = globalSelectedNode) {
            if(!node || paramPanel.style.display === 'none') return;
            if (viewMode === 'tree') {
                paramPanel.style.left = 'auto';
                paramPanel.style.right = '20px';
                paramPanel.style.top = '100px';
                paramPanel.style.transform = 'none';
            } else {
                paramPanel.style.right = 'auto';
                paramPanel.style.transform = 'translate(-50%, 20px)';
                if(node.screenX !== undefined) { 
                    paramPanel.style.left = (node.screenX * zoom) + 'px'; 
                    paramPanel.style.top = ((node.screenY + 30) * zoom) + 'px'; 
                }
            }
        }

        const getPos = (e) => { const rect = canvas.getBoundingClientRect(); return { x: ((e.touches ? e.touches[0].clientX : e.clientX) - rect.left) / zoom, y: ((e.touches ? e.touches[0].clientY : e.clientY) - rect.top) / zoom }; };

        let panVelocity = {x:0, y:0};
        let lastPanPos = {x:0, y:0};
        let panMomentumRAF = null;

        function startPanMomentum() {
            if(panMomentumRAF) cancelAnimationFrame(panMomentumRAF);
            function step() {
                if(Math.abs(panVelocity.x) < 0.5 && Math.abs(panVelocity.y) < 0.5) return;
                container.scrollLeft += panVelocity.x;
                container.scrollTop  += panVelocity.y;
                panVelocity.x *= 0.88;
                panVelocity.y *= 0.88;
                panMomentumRAF = requestAnimationFrame(step);
            }
            panMomentumRAF = requestAnimationFrame(step);
        }

        canvas.addEventListener('pointerdown', (e) => {
            if(e.button === 2) return; canvas.setPointerCapture(e.pointerId); ctxMenuNode.style.display = 'none'; tlCtxMenu.style.display = 'none';
            const pos = getPos(e); dragStartPos = pos; isDragging = false; isPanning = false; hoverTarget = null; 
            if(panMomentumRAF) { cancelAnimationFrame(panMomentumRAF); panMomentumRAF = null; }
            panVelocity = {x:0, y:0};
            draggedNode = getHitTarget(pos);

            if(draggedNode) { 
                if (draggedNode.type === 'BASE' || draggedNode.type === 'BASE_REF') {
                    let baseId = draggedNode.type === 'BASE_REF' ? draggedNode.refId : draggedNode.id;
                    let sortedKFs = keyframes.slice().sort((a,b) => a.time - b.time);
                    for (let kf of sortedKFs) {
                        let found = getAllNodes(kf.nodes).find(n => n.id === baseId || n.refId === baseId);
                        if (found) {
                            if (currentKfId !== kf.id) {
                                selectKeyframe(kf.id);
                                draggedNode = getAllNodes(nodes).find(n => n.id === found.id) || draggedNode;
                            }
                            break;
                        }
                    }
                }
                
                let isSame = (globalSelectedNode && globalSelectedNode.id === draggedNode.id);
                globalSelectedNode = draggedNode; 
                if (!isSame) closeParamPanel();
                
                updateObjectList(); 
            } else { 
                globalSelectedNode = null; closeParamPanel(); updateObjectList(); 
                isPanning = true; panStart = {x: e.clientX, y: e.clientY}; lastPanPos = {x: e.clientX, y: e.clientY}; panVelocity = {x:0, y:0}; scrollStart = {x: container.scrollLeft, y: container.scrollTop};
            }
        });

        canvas.addEventListener('pointermove', (e) => {
            if(isPanning) {
                let dx = e.clientX - panStart.x;
                let dy = e.clientY - panStart.y;
                panVelocity.x = scrollStart.x - (e.clientX - panStart.x) - container.scrollLeft;
                panVelocity.y = scrollStart.y - (e.clientY - panStart.y) - container.scrollTop;
                container.scrollLeft = scrollStart.x - dx;
                container.scrollTop  = scrollStart.y - dy;
                lastPanPos = {x: e.clientX, y: e.clientY};
                return;
            }

            if(!draggedNode) return;
            const pos = getPos(e); 
            if(document.getElementById('snap-toggle').checked) { pos.x = Math.round(pos.x/gridSz)*gridSz; pos.y = Math.round(pos.y/gridSz)*gridSz; }
            
            if(!isDragging && Math.hypot(pos.x - dragStartPos.x, pos.y - dragStartPos.y) > 8) {
                isDragging = true;
                if(draggedNode.parent) { 
                    draggedNode.parent.children = draggedNode.parent.children.filter(c => c !== draggedNode); 
                    draggedNode.parent = null; 
                    draggedNode.x = pos.x; 
                    draggedNode.y = pos.y; 
                    updateState(true); 
                }
            }
            if(isDragging) { 
                draggedNode.x = pos.x; 
                draggedNode.y = pos.y; 
                hoverTarget = getDropTarget(draggedNode); 
                updateParamPanelPos(); 
                updateState(true); 
            }
        });

        canvas.addEventListener('pointerup', () => {
            if(isPanning) { isPanning = false; startPanMomentum(); return; }
            if(draggedNode) {
                if(!isDragging) {
                    let now = Date.now();
                    if(now - lastClickTime < 300 && lastClickNode === draggedNode) {
                        if(draggedNode.type === 'BASE' || draggedNode.type === 'BASE_REF') {
                            let opening = !draggedNode.isOpen;
                            if(opening) {
                                // 親BASEの子BASE一つだけ展開の排他制御
                                if(draggedNode.parent && (draggedNode.parent.type === 'BASE' || draggedNode.parent.type === 'BASE_REF')) {
                                    // 兄弟BASEを閉じる
                                    draggedNode.parent.children.forEach(sib => {
                                        if(sib !== draggedNode && (sib.type === 'BASE' || sib.type === 'BASE_REF')) {
                                            sib.isOpen = false;
                                        }
                                    });
                                    openChildBaseMap[draggedNode.parent.id] = draggedNode.id;
                                }
                                // スクロール位置を保存してから、開いたベースを中央へスクロール
                                savedScrollBeforeOpen = { left: container.scrollLeft, top: container.scrollTop, nodeId: draggedNode.id };
                                draggedNode.isOpen = true;
                                // calcPos で screenX/Y を更新してからスクロール
                                setTimeout(() => {
                                    nodes.filter(n => !n.parent).forEach(root => calcPos(root, root.x, root.y));
                                    if(draggedNode.screenX !== undefined) {
                                        let targetScrollX = draggedNode.screenX * zoom - container.clientWidth / 2;
                                        let targetScrollY = draggedNode.screenY * zoom - container.clientHeight / 2;
                                        container.scrollTo({ left: targetScrollX, top: targetScrollY, behavior: 'smooth' });
                                    }
                                }, 16);
                            } else {
                                // 閉じる: 保存したスクロール位置に戻す
                                draggedNode.isOpen = false;
                                if(draggedNode.parent && openChildBaseMap[draggedNode.parent.id] === draggedNode.id) {
                                    delete openChildBaseMap[draggedNode.parent.id];
                                }
                                if(savedScrollBeforeOpen && savedScrollBeforeOpen.nodeId === draggedNode.id) {
                                    container.scrollTo({ left: savedScrollBeforeOpen.left, top: savedScrollBeforeOpen.top, behavior: 'smooth' });
                                    savedScrollBeforeOpen = null;
                                } else if(draggedNode.screenX !== undefined) {
                                    // ルートベースが閉じた場合もノード中心へ
                                    let targetScrollX = draggedNode.screenX * zoom - container.clientWidth / 2;
                                    let targetScrollY = draggedNode.screenY * zoom - container.clientHeight / 2;
                                    container.scrollTo({ left: targetScrollX, top: targetScrollY, behavior: 'smooth' });
                                }
                            }
                            closeParamPanel();
                        } 
                        else if(draggedNode.type === 'PARAM') { 
                            if(paramPanel.style.display === 'block') closeParamPanel();
                            else openParamPanel(draggedNode); 
                        }
                        lastClickTime = 0; 
                    } else { lastClickTime = now; lastClickNode = draggedNode; }
                } else {
                    if(hoverTarget) {
                        if(hoverTarget.type === 'BASE_REF' && draggedNode.type === 'PARAM') {
                            let actualBase = nodes.find(n => n.id === hoverTarget.refId);
                            if(actualBase) { draggedNode.parent = actualBase; actualBase.children.push(draggedNode); actualBase.isOpen = true; }
                        } else { draggedNode.parent = hoverTarget; hoverTarget.children.push(draggedNode); hoverTarget.isOpen = true; }
                    }
                    pushHistory(); 
                }
            }
            draggedNode = null; isDragging = false; hoverTarget = null; updateState(); updateParamPanelPos();
        });

        window.addEventListener('keydown', (e) => {
            if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
            if(e.key === 'F1') { e.preventDefault(); window.toggleViewMode(); return; }
            if(e.key === 'Delete' || e.key === 'Backspace') {
                if(globalSelectedNode) { removeNode(globalSelectedNode); globalSelectedNode = null; updateState(); pushHistory(); }
            }
            if(e.key === 'PageUp') { zoom = Math.min(zoom + 0.1, 3.0); resizeCanvas(); e.preventDefault(); }
            if(e.key === 'PageDown') { zoom = Math.max(zoom - 0.1, 0.2); resizeCanvas(); e.preventDefault(); }
        });

        // ==========================================
        // 6. 右クリックコンテキストメニュー (Node)
        // ==========================================
        canvas.addEventListener('contextmenu', (e) => {
            e.preventDefault(); tlCtxMenu.style.display = 'none';
            const pos = getPos(e); ctxMousePos = { x: pos.x, y: pos.y };
            let target = getHitTarget(pos); if(target) globalSelectedNode = target;
            ctxMenuNode.style.display = 'block'; ctxMenuNode.style.left = e.clientX + 'px'; ctxMenuNode.style.top = e.clientY + 'px';
            document.getElementById('ctx-cut').style.display = globalSelectedNode ? 'block' : 'none';
            document.getElementById('ctx-copy').style.display = globalSelectedNode ? 'block' : 'none';
            document.getElementById('ctx-delete').style.display = globalSelectedNode ? 'block' : 'none';
            document.getElementById('ctx-paste').style.display = clipboardData ? 'block' : 'none';
            if(!globalSelectedNode && !clipboardData) ctxMenuNode.style.display = 'none';
        });
        window.addEventListener('click', (e) => { 
            if(!e.target.closest('#ctx-menu-node')) ctxMenuNode.style.display = 'none'; 
            if(!e.target.closest('#ctx-menu-timeline')) tlCtxMenu.style.display = 'none';
        });

        function removeNode(node) {
            if(node.parent) node.parent.children = node.parent.children.filter(c => c !== node);
            function getFlat(nList) { let res = []; nList.forEach(nx => { res.push(nx); if(nx.children) res = res.concat(getFlat(nx.children)); }); return res; }
            let toDelete = getFlat([node]); nodes = nodes.filter(n => !toDelete.includes(n));
            if(globalSelectedNode === node) { closeParamPanel(); globalSelectedNode = null; }
        }

        document.getElementById('ctx-delete').onclick = () => { if(!globalSelectedNode) return; removeNode(globalSelectedNode); ctxMenuNode.style.display = 'none'; updateState(); pushHistory(); };
        document.getElementById('ctx-copy').onclick = () => { if(globalSelectedNode) clipboardData = serializeNode(globalSelectedNode); ctxMenuNode.style.display = 'none'; };
        document.getElementById('ctx-cut').onclick = () => { if(globalSelectedNode) { clipboardData = serializeNode(globalSelectedNode); removeNode(globalSelectedNode); updateState(); pushHistory();} ctxMenuNode.style.display = 'none'; };
        document.getElementById('ctx-paste').onclick = () => {
            if(clipboardData) { 
                let newNodes=[]; deserializeNode(clipboardData, ctxMousePos.x + (Math.random()*20-10), ctxMousePos.y + (Math.random()*20-10), false, newNodes); let n = newNodes[0]; 
                if (viewMode === 'tree' && globalSelectedNode) {
                    if (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF') {
                        n.parent = globalSelectedNode; globalSelectedNode.children.push(n); globalSelectedNode.isOpen = true;
                    } else if (globalSelectedNode.parent) {
                        n.parent = globalSelectedNode.parent; globalSelectedNode.parent.children.push(n);
                    } else { nodes.push(n); }
                } else { nodes.push(n); }
                globalSelectedNode = n; updateState(); pushHistory(); 
            }
            ctxMenuNode.style.display = 'none';
        };

        // ==========================================
        // 7. GLSL生成エンジン
        // ==========================================
        function getInheritedModifier(node, subtype) {
            let path = []; let curr = node; while(curr) { path.unshift(curr); curr = curr.parent; }
            let result = null;
            path.forEach(n => {
                let actualN = n.type === 'BASE_REF' ? nodes.find(x => x.id === n.refId) : n;
                if(actualN && actualN.children) { let found = actualN.children.find(c => c.subtype === subtype); if(found) result = found; }
            });
            return result;
        }

        function updateShader() {
            let glsl = `
#define PI 3.14159265359
#define TWO_PI 6.28318530718

uniform float u_time;
uniform float u_aspect;
varying vec2 vUv;

vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

float sdPolygon(vec2 p, int n, float size) {
    float a = atan(p.x, p.y) + PI;
    float r = TWO_PI / float(n);
    return cos(floor(0.5 + a / r) * r - a) * length(p) - size;
}

vec2 hash2(float n) {
    float x = fract(sin(n) * 43758.5453123);
    float y = fract(sin(n + 1.0) * 43758.5453123);
    return vec2(x, y);
}

float noise3D(vec3 p) {
    vec3 i = floor(p);
    vec4 a = dot(i, vec3(1.0, 57.0, 21.0)) + vec4(0.0, 57.0, 21.0, 78.0);
    vec3 f = cos((p-i)*acos(-1.0))*(-0.5)+0.5;
    a = mix(sin(cos(a)*a),sin(cos(1.0+a)*(1.0+a)), f.x);
    a.xy = mix(a.xz, a.yw, f.y);
    return mix(a.x, a.y, f.z);
}

float flame3D(vec3 p, float time) {
    float d = length(p * vec3(1.0, 0.5, 1.0) - vec3(0.0, -1.0, 0.0)) - 1.0;
    return d + (noise3D(p + vec3(0.0, time * 2.0, 0.0)) + noise3D(p * 3.0) * 0.5) * 0.25 * (p.y);
}
`;
            let texNodes = [];
            keyframes.forEach(kf => { texNodes = texNodes.concat(getAllNodes(kf.nodes).filter(n => n.subtype === 'IMAGE' || n.subtype === 'ANIMATION')); });
            texNodes = [...new Set(texNodes)];

            texNodes.forEach((tn, i) => {
                glsl += `uniform sampler2D u_tex${i};\n`;
                if(tn.vals.img && !textures[tn.id]) loadTexture(tn.id, tn.vals.img);
                window.uniforms[`u_tex${i}`] = { value: textures[tn.id] || null };
            });

            let sortedKFs = keyframes.slice().sort((a, b) => a.time - b.time);
            let allBases = []; let baseFirstTime = {}; 

            sortedKFs.forEach(kf => {
                let bases = getAllBases(kf.nodes.filter(n => !n.parent));
                bases.forEach(b => { if (baseFirstTime[b.id] === undefined) baseFirstTime[b.id] = kf.time; });
            });
            sortedKFs.slice().reverse().forEach(kf => {
                let bases = getAllBases(kf.nodes.filter(n => !n.parent));
                bases.forEach(b => { if(!allBases.find(x => x.id === b.id)) allBases.push(b); });
            });
            
            allBases.forEach((base, idx) => {
                if(base.type === 'BASE_REF' && !allBases.find(x => x.id === base.refId)) return;
                
                glsl += `\nvec2 getPos_base${idx}(vec2 st, float t) {\n`;
                glsl += `    vec2 p = st;\n    p.x *= u_aspect;\n`;
                
                let path = []; let curr = base; while(curr) { path.unshift(curr); curr = curr.parent; }
                
                path.forEach((n, level) => {
                    let isRoot = (level === 0);
                    if (isRoot) {
                        let kfPoints = [];
                        sortedKFs.forEach(kf => {
                            let kfNode = kf.nodes.find(x => x.id === n.id && !x.parent);
                            if(kfNode) kfPoints.push({ time: kf.time, x: kfNode.x, y: kfNode.y });
                        });
                        
                        if(kfPoints.length > 0) {
                            glsl += `    vec2 base_pos = vec2(0.0);\n`;
                            glsl += `    float anim_t = mod(u_time, ${tDuration.toFixed(2)});\n`;
                            for(let i=0; i<kfPoints.length; i++) {
                                let pt = kfPoints[i];
                                let uvX = pt.x / layoutW; let uvY = 1.0 - (pt.y / layoutH);
                                let posStr = `vec2(${uvX.toFixed(4)} * u_aspect, ${uvY.toFixed(4)})`;
                                if(i === 0) glsl += `    if(anim_t <= ${pt.time.toFixed(3)}) base_pos = ${posStr};\n`;
                                if(i > 0) {
                                    let prev = kfPoints[i-1];
                                    let prevUvX = prev.x / layoutW; let prevUvY = 1.0 - (prev.y / layoutH);
                                    let prevPosStr = `vec2(${prevUvX.toFixed(4)} * u_aspect, ${prevUvY.toFixed(4)})`;
                                    let dt = pt.time - prev.time; if (dt <= 0.001) dt = 0.001; 
                                    glsl += `    else if(anim_t <= ${pt.time.toFixed(3)}) {\n`;
                                    glsl += `        float ratio = (anim_t - ${prev.time.toFixed(3)}) / ${dt.toFixed(3)};\n`;
                                    glsl += `        base_pos = mix(${prevPosStr}, ${posStr}, ratio);\n    }\n`;
                                }
                                if(i === kfPoints.length - 1) glsl += `    else base_pos = ${posStr};\n`;
                            }
                            glsl += `    p -= base_pos;\n`;
                        }
                    } else {
                        let r = n.parent.radiusOpen; let cidx = n.parent.children.findIndex(c=>c.id===n.id);
                        let angle = (cidx / n.parent.children.length) * Math.PI * 2;
                        let dx = Math.cos(angle) * (r * 0.65) / layoutW; let dy = -(Math.sin(angle) * (r * 0.65) / layoutH); 
                        glsl += `    p -= vec2(${dx.toFixed(4)} * u_aspect, ${dy.toFixed(4)});\n`;
                    }

                    let actualN = n.type === 'BASE_REF' ? allBases.find(x => x.id === n.refId) : n;
                    let motions = actualN && actualN.children ? actualN.children.filter(c => c.subtype && c.subtype.startsWith('MOTION_')) : [];
                    
                    let easing = motions.find(m => m.subtype === 'MOTION_EASING');
                    glsl += `    float t_local_${level} = t;\n`;
                    if(easing) {
                        let mode = easing.vals.mode;
                        if(mode === 1) glsl += `    t_local_${level} = pow(fract(t), 2.0) + floor(t);\n`;
                        else if(mode === 2) glsl += `    t_local_${level} = (1.0 - pow(1.0 - fract(t), 2.0)) + floor(t);\n`;
                        else glsl += `    t_local_${level} = smoothstep(0.0, 1.0, fract(t)) + floor(t);\n`;
                    }

                    glsl += `    vec2 offset_${level} = vec2(0.0);\n`;

                    let rotMotion = motions.find(m => m.subtype === 'MOTION_ROTATE');
                    if(rotMotion) {
                        let v = rotMotion.vals;
                        let r_rad = v.radius !== undefined ? v.radius : 0.0;
                        let r_spd = v.speed !== undefined ? v.speed : 2.0;
                        let r_acc = v.accel !== undefined ? v.accel : 0.0;
                        let r_ang = v.angle !== undefined ? v.angle : 0.0;
                        glsl += `    {\n`;
                        glsl += `        float r_angle = -(radians(${r_ang.toFixed(1)}) + t_local_${level} * ${r_spd.toFixed(2)} + 0.5 * ${r_acc.toFixed(2)} * t_local_${level} * t_local_${level});\n`;
                        glsl += `        float rs = sin(r_angle), rc = cos(r_angle);\n`;
                        if(r_rad > 0.0) glsl += `        offset_${level} += vec2(rc, rs) * ${r_rad.toFixed(2)};\n`;
                        glsl += `        p = mat2(rc, -rs, rs, rc) * p;\n`;
                        glsl += `    }\n`;
                    }

                    motions.forEach(m => {
                        let v = m.vals;
                        if(m.subtype === 'MOTION_LINEAR') {
                            let a = v.angle !== undefined ? v.angle : 0.0;
                            glsl += `    {\n`;
                            glsl += `        float lin_a = radians(${a.toFixed(1)});\n`;
                            glsl += `        vec2 lin_v = vec2(${v.vx.toFixed(2)}, ${-v.vy.toFixed(2)});\n`;
                            glsl += `        float lin_c = cos(lin_a), lin_s = sin(lin_a);\n`;
                            glsl += `        offset_${level} += vec2(lin_c * lin_v.x - lin_s * lin_v.y, lin_s * lin_v.x + lin_c * lin_v.y) * t_local_${level};\n`;
                            glsl += `    }\n`;
                        }
                        else if(m.subtype === 'MOTION_SINE') { 
                            let rad = (v.angle !== undefined ? v.angle : 0) * Math.PI / 180; 
                            glsl += `    offset_${level} += vec2(${Math.cos(rad).toFixed(3)}, ${-Math.sin(rad).toFixed(3)}) * sin(t_local_${level} * ${v.freq.toFixed(2)}) * ${v.amp.toFixed(2)};\n`; 
                        }
                        else if(m.subtype === 'MOTION_BOUNCE') glsl += `    offset_${level}.y += abs(sin(t_local_${level} * ${v.speed.toFixed(2)})) * ${v.height.toFixed(2)};\n`;
                        else if(m.subtype === 'MOTION_PARABOLA') {
                            let a = v.angle !== undefined ? v.angle : 0.0;
                            glsl += `    {\n`;
                            glsl += `        float pb_a = radians(${a.toFixed(1)});\n`;
                            glsl += `        vec2 pb_v = vec2(${v.vx.toFixed(2)} * t_local_${level}, ${v.vy.toFixed(2)} * t_local_${level} - 0.5 * ${v.g.toFixed(2)} * t_local_${level} * t_local_${level});\n`;
                            glsl += `        float pb_c = cos(pb_a), pb_s = sin(pb_a);\n`;
                            glsl += `        offset_${level} += vec2(pb_c * pb_v.x - pb_s * pb_v.y, pb_s * pb_v.x + pb_c * pb_v.y);\n`;
                            glsl += `    }\n`;
                        }
                        else if(m.subtype === 'MOTION_SPIRAL') {
                            glsl += `    float r_sp_${level} = min(${v.radius.toFixed(2)} * t_local_${level}, ${v.limit.toFixed(2)});\n`;
                            glsl += `    offset_${level} += vec2(cos(t_local_${level} * ${v.spin.toFixed(2)}), -sin(t_local_${level} * ${v.spin.toFixed(2)})) * r_sp_${level};\n`;
                        }
                        else if(m.subtype === 'MOTION_HOMING') glsl += `    offset_${level} += mix(vec2(0.0), vec2(${v.tx.toFixed(2)}, ${-v.ty.toFixed(2)}), clamp(t_local_${level} * ${v.speed.toFixed(2)}, 0.0, 1.0));\n`;
                        else if(m.subtype === 'MOTION_SHAKE') glsl += `    offset_${level} += vec2(sin(t_local_${level} * ${v.speed.toFixed(2)} + 1.0)*0.5 + sin(t_local_${level} * ${v.speed.toFixed(2)} * 1.7)*0.5, cos(t_local_${level} * ${v.speed.toFixed(2)} * 1.3)*0.5 + cos(t_local_${level} * ${v.speed.toFixed(2)} * 0.8)*0.5) * ${v.amp.toFixed(3)};\n`;
                        else if(m.subtype === 'MOTION_FALL') glsl += `    offset_${level}.y -= 0.5 * ${v.g.toFixed(2)} * t_local_${level} * t_local_${level};\n`;
                    });
                    glsl += `    p -= vec2(offset_${level}.x * u_aspect, offset_${level}.y);\n`;
                    
                    let distort = actualN && actualN.children ? actualN.children.find(c => c.subtype === 'FILTER_DISTORT') : null;
                    if(distort) {
                        let dv = distort.vals;
                        glsl += `    p += vec2(sin(p.y * ${dv.freq.toFixed(2)} + t * t_local_${level} * ${dv.speed.toFixed(2)}), cos(p.x * ${dv.freq.toFixed(2)} + t * t_local_${level} * ${dv.speed.toFixed(2)})) * ${dv.power.toFixed(3)};\n`;
                    }
                });
                glsl += `    return p;\n}\n`;
            });

            glsl += `
void main() {
    vec3 finalColor = vec3(0.0);
    vec2 st = vUv;
`;
            allBases.forEach((base, idx) => {
                if(base.type === 'BASE_REF' && !allBases.find(x => x.id === base.refId)) return;
                let spawnTime = baseFirstTime[base.id];
                glsl += `    // --- Base Object ${idx} ---\n    {\n`;
                
                let timeNode = getInheritedModifier(base, 'TIME');
                glsl += `        float t_base = u_time;\n        float alpha = 1.0;\n`;
                glsl += `        float global_t = mod(u_time, ${tDuration.toFixed(2)});\n`;
                glsl += `        float spawn_alpha = step(${spawnTime.toFixed(3)}, global_t);\n`;

                if(timeNode) {
                    let v = timeNode.vals;
                    glsl += `        t_base = max(0.0, u_time - (${v.delay.toFixed(2)} + ${spawnTime.toFixed(3)}));\n`;
                    glsl += `        t_base = mod(t_base, ${v.life.toFixed(2)} + 0.01);\n`;
                    if(v.fade > 0.5) glsl += `        alpha = clamp(1.0 - (t_base / ${v.life.toFixed(2)}), 0.0, 1.0);\n`;
                } else {
                    glsl += `        t_base = max(0.0, u_time - ${spawnTime.toFixed(3)});\n`;
                    glsl += `        t_base = mod(t_base, 3.0);\n`;
                }

                let filterNode = getInheritedModifier(base, 'FILTER');
                let st_used = 'st';
                if(filterNode && filterNode.vals.pixel > 0) {
                    let pScale = 200.0 / filterNode.vals.pixel;
                    glsl += `        vec2 st_mod = floor(st * ${pScale.toFixed(2)}) / ${pScale.toFixed(2)};\n`;
                    st_used = 'st_mod';
                }

                let trailNode = getInheritedModifier(base, 'TRAIL');
                let trailCount = trailNode ? trailNode.vals.count : 1;
                let trailInterval = trailNode ? trailNode.vals.interval : 0.05;
                let trailFade = trailNode ? trailNode.vals.fade : 1.0;

                let customNode = getInheritedModifier(base, 'CUSTOM');
                let shape = getInheritedModifier(base, 'SHAPE');
                let imageNode = getInheritedModifier(base, 'IMAGE');
                let animNode = getInheritedModifier(base, 'ANIMATION');
                let targetNode = animNode || imageNode || shape;
                let n_gon = shape ? shape.vals.n : 0;
                let shape_size = targetNode ? targetNode.vals.size : 0.05;
                
                let particleNode = getInheritedModifier(base, 'PARTICLE');
                let hasTex = (animNode || imageNode) ? true : false;
                let texIdx = hasTex ? texNodes.findIndex(t => t.id === (animNode || imageNode).id) : -1;
                
                let color = getInheritedModifier(base, 'COLOR');
                let colorCode = color ? `hsv2rgb(vec3(${color.vals.h.toFixed(3)}, ${color.vals.s.toFixed(2)}, ${color.vals.v.toFixed(2)}))` : `vec3(1.0)`;

                let fallNode = getInheritedModifier(base, 'MOTION_FALL');

                glsl += `        vec3 base_accum_col = vec3(0.0);\n`;
                glsl += `        for(int tr = 0; tr < 30; tr++) {\n`;
                glsl += `            if(tr >= ${Math.floor(trailCount)}) break;\n`;
                glsl += `            float t_tr = max(0.0, t_base - float(tr) * ${trailInterval.toFixed(3)});\n`;
                glsl += `            float currentFade = pow(${trailFade.toFixed(3)}, float(tr));\n`;
                
                glsl += `            vec2 p = getPos_base${idx}(${st_used}, t_tr);\n`;

                if(fallNode) {
                    glsl += `            {\n                float fa = t_tr * ${fallNode.vals.spin.toFixed(2)};\n                float fs = sin(fa), fc = cos(fa);\n                p = mat2(fc, -fs, fs, fc) * p;\n            }\n`;
                }

                if (customNode) {
                    let customGLSL = customNode.vals.code;
                    if (customNode.vals.customParams) {
                        customGLSL = customGLSL.replace(/float\s+(param_[a-zA-Z0-9_]+)\s*=\s*([^;]+);/g, (m, pName, defVal) => {
                            let val = customNode.vals.customParams[pName];
                            return `float ${pName} = ${val !== undefined ? val.toFixed(5) : defVal};`;
                        });
                    }

                    glsl += `            vec3 base_color = ${colorCode};\n`;
                    if (particleNode) {
                        let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
                        glsl += `            float out_d = 999.0;\n            vec3 out_col = vec3(0.0);\n`;
                        glsl += `            for(int p_idx=0; p_idx<30; p_idx++) {\n                if(float(p_idx) >= ${pv.count.toFixed(1)}) break;\n`;
                        glsl += `                vec2 poff = (hash2(float(p_idx)) * 2.0 - 1.0) * (${p_spread} + t_tr * ${pv.speed.toFixed(2)});\n                poff.x *= u_aspect;\n`;
                        glsl += `                vec2 orig_p = p;\n                p = orig_p - poff;\n`;
                        glsl += `                float local_d = 999.0;\n                vec3 local_col = vec3(1.0);\n`;
                        glsl += `                // --- Custom GLSL ---\n`;
                        glsl += `                ${customGLSL}\n`;
                        glsl += `                if(local_d < out_d) { out_d = local_d; out_col = local_col; }\n`;
                        glsl += `                p = orig_p;\n`;
                        glsl += `            }\n`;
                        glsl += `            float local_d = out_d;\n`;
                        glsl += `            vec3 local_col = out_col * base_color;\n`;
                        glsl += `            float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
                    } else {
                        glsl += `            float local_d = 999.0;\n            vec3 local_col = vec3(1.0);\n`;
                        glsl += `            // --- Custom GLSL ---\n`;
                        glsl += `            ${customGLSL}\n`;
                        glsl += `            local_col *= base_color;\n`;
                        glsl += `            float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
                    }
                } else {
                    glsl += `            float local_d = 999.0;\n            vec4 localTexCol = vec4(0.0);\n`;
                    let animCode = "";
                    if(animNode) {
                        animCode = `float fIdx = floor(mod(t_tr / ${animNode.vals.speed.toFixed(3)}, ${animNode.vals.frames.toFixed(1)})); texUv.x = (texUv.x + fIdx) / ${animNode.vals.frames.toFixed(1)};`;
                    }

                    if(particleNode) {
                        let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
                        glsl += `            for(int p_idx=0; p_idx<30; p_idx++) {\n                if(float(p_idx) >= ${pv.count.toFixed(1)}) break;\n`;
                        glsl += `                vec2 poff = (hash2(float(p_idx)) * 2.0 - 1.0) * (${p_spread} + t_tr * ${pv.speed.toFixed(2)});\n                poff.x *= u_aspect;\n                vec2 pp = p - poff;\n`;
                        if (hasTex) {
                            glsl += `                vec2 absp = abs(pp);\n                float pd = max(absp.x, absp.y) - ${shape_size.toFixed(3)};\n                local_d = min(local_d, pd);\n`;
                            glsl += `                vec2 texUv = clamp((pp / ${shape_size.toFixed(3)}) * 0.5 + 0.5, 0.0, 1.0);\n                ${animCode}\n`; 
                            glsl += `                vec4 tCol = texture2D(u_tex${texIdx}, texUv);\n                localTexCol += tCol * smoothstep(0.015, 0.0, pd);\n`;
                        } else {
                            if(n_gon < 3) glsl += `                local_d = min(local_d, length(pp) - ${shape_size.toFixed(3)});\n`;
                            else glsl += `                local_d = min(local_d, sdPolygon(pp, ${n_gon}, ${shape_size.toFixed(3)}));\n`;
                        }
                        glsl += `            }\n`;
                        if(hasTex) glsl += `            localTexCol = clamp(localTexCol, 0.0, 1.0);\n`;
                    } else {
                        if (hasTex) {
                            glsl += `            vec2 absp = abs(p);\n            local_d = max(absp.x, absp.y) - ${shape_size.toFixed(3)};\n`;
                            glsl += `            vec2 texUv = clamp((p / ${shape_size.toFixed(3)}) * 0.5 + 0.5, 0.0, 1.0);\n            ${animCode}\n`; 
                            glsl += `            localTexCol = texture2D(u_tex${texIdx}, texUv);\n`;
                        } else {
                            if(n_gon < 3) glsl += `            local_d = length(p) - ${shape_size.toFixed(3)};\n`;
                            else glsl += `            local_d = sdPolygon(p, ${n_gon}, ${shape_size.toFixed(3)});\n`;
                        }
                    }

                    glsl += `            float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
                    glsl += `            vec3 local_col = ${colorCode};\n`;
                    if (hasTex) { glsl += `            local_col *= localTexCol.rgb;\n            local_mask *= localTexCol.a;\n`; }
                }
                
                if(filterNode && filterNode.vals.glow > 0) {
                    glsl += `            local_mask += max(0.0, ${filterNode.vals.glow.toFixed(3)}) * (0.01 / (local_d*local_d + 0.001));\n`;
                }

                glsl += `            base_accum_col += local_col * local_mask * currentFade * alpha * spawn_alpha;\n`;
                glsl += `        }\n`; 
                glsl += `        finalColor += base_accum_col;\n    }\n`;
            });

            glsl += `\n    gl_FragColor = vec4(finalColor, 1.0);\n}`;
            glslContent.innerText = glsl.trim();

            if(mesh) {
                const newMat = new THREE.ShaderMaterial({
                    vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
                    fragmentShader: glsl, uniforms: window.uniforms, blending: THREE.AdditiveBlending, transparent: true
                });
                if(mesh.material) mesh.material.dispose(); mesh.material = newMat;
            }
        }

        // ==========================================
        // 8. キャンバス描画ループ (論理座標とズーム適用)
        // ==========================================
        // ベースが開いたときのスクロール保存・復元用
        let savedScrollBeforeOpen = null; // { left, top, nodeId }

        // 子BASEのうち現在展開中のIDを管理 (親BASE id -> 展開中子BASE id)
        let openChildBaseMap = {}; // { parentBaseId: childBaseId }

        // アイテムを同心円リングに配置するユーティリティ
        // items: 配置アイテム配列, cx/cy: 中心, minR: 最内リング半径
        // itemSlot: 1アイテムの必要弧幅, angleStart/angleSpan: 扇形制限(省略=全円)
        // 戻り値: { positions:[{node,x,y,angle,r}], outerEdgeR }
        function layoutInRings(items, cx, cy, minR, itemSlot, angleStart, angleSpan) {
            if(items.length === 0) return { positions: [], outerEdgeR: minR };
            let fullCircle = (angleStart === undefined);
            let span = fullCircle ? Math.PI * 2 : angleSpan;
            let positions = [];
            let r = minR;
            let remaining = items.slice();
            while(remaining.length > 0) {
                let capacity = fullCircle
                    ? Math.max(1, Math.floor((2 * Math.PI * r) / itemSlot))
                    : Math.max(1, Math.floor((r * span) / itemSlot));
                let batch = remaining.splice(0, capacity);
                batch.forEach((item, i) => {
                    let angle;
                    if(fullCircle) {
                        angle = (i / batch.length) * Math.PI * 2 - Math.PI / 2;
                    } else {
                        let t = batch.length === 1 ? 0.5 : i / (batch.length - 1);
                        angle = angleStart + t * span;
                    }
                    positions.push({ node: item, x: cx + Math.cos(angle) * r, y: cy + Math.sin(angle) * r, angle, r });
                });
                r += itemSlot * 1.15;
            }
            return { positions, outerEdgeR: r };
        }

        function calcPos(node, cx, cy) {
            node.screenX = cx; node.screenY = cy;
            if((node.type === 'BASE' || node.type === 'BASE_REF') && node.isOpen && node.children) {
                let coreR = node.radiusClosed || 16; // コア中心のサイズ

                let paramChildren = node.children.filter(c => c.type === 'PARAM');
                let baseChildren  = node.children.filter(c => c.type === 'BASE' || c.type === 'BASE_REF');

                // ── 内側リング: PARAM要素 ──
                let paramSlot = 32; // PARAMノードのradius(14)×2 + 余白
                let innerMinR = coreR + paramSlot * 0.7;
                let { positions: paramPos, outerEdgeR: innerEdge } = layoutInRings(
                    paramChildren, cx, cy, innerMinR, paramSlot
                );
                node._innerEdgeR = innerEdge;
                node._paramRingMinR = innerMinR;

                paramPos.forEach(({ node: child, x, y }) => {
                    let px = (draggedNode === child && isDragging) ? child.x : x;
                    let py = (draggedNode === child && isDragging) ? child.y : y;
                    calcPos(child, px, py);
                });

                // ── 外側リング: 子BASE (縮小表示) ──
                let openChildId = openChildBaseMap[node.id];
                // 排他展開チェック
                baseChildren.forEach(child => {
                    if(child.isOpen && child.id !== openChildId) child.isOpen = false;
                });

                let baseSlot = (coreR * 1.8) + 10;
                let outerMinR = innerEdge + baseSlot * 0.65;
                let { positions: basePos, outerEdgeR: outerEdge } = layoutInRings(
                    baseChildren, cx, cy, outerMinR, baseSlot
                );
                node._outerRingMinR = outerMinR;
                node._outerEdgeR = outerEdge;

                // 子BASEの位置を確定 (展開状態に関わらず外周上に置く)
                basePos.forEach(({ node: child, x, y, angle, r: childR }) => {
                    let bx = (draggedNode === child && isDragging) ? child.x : x;
                    let by = (draggedNode === child && isDragging) ? child.y : y;
                    child.screenX = bx; child.screenY = by;
                    child._outerAngle = angle; // 描画用に角度を保存
                    child._outerR = childR;
                });

                // ── 展開中の子BASEの要素: 同じ外周リング上の扇形エリアに配置 ──
                let openChild = baseChildren.find(c => c.isOpen);
                if(openChild) {
                    let openAngle = openChild._outerAngle !== undefined ? openChild._outerAngle : 0;

                    // 隣の子BASEとの間隔から扇形の広さを決定
                    let fanSpread;
                    if(baseChildren.length === 1) {
                        fanSpread = Math.PI * 1.4;
                    } else {
                        let slotAngle = (2 * Math.PI * (openChild._outerR || outerMinR)) / baseSlot;
                        fanSpread = Math.min(slotAngle * 0.85 * baseChildren.length / Math.max(baseChildren.length,1), Math.PI * 1.5);
                        // 実際の隣接スロット間の角度を使う
                        fanSpread = Math.max((Math.PI * 2 / baseChildren.length) * 0.78, Math.PI * 0.35);
                    }

                    // 扇形内にPARAM配置: 外周リングの外側から始める
                    let fanMinR = outerEdge + baseSlot * 0.1;
                    let fanSlot = paramSlot * 0.95;
                    let openParamChildren = openChild.children.filter(c => c.type === 'PARAM');
                    let openSubBaseChildren = openChild.children.filter(c => c.type === 'BASE' || c.type === 'BASE_REF');

                    // PARAMを扇形リングに配置
                    let { positions: fanParamPos, outerEdgeR: fanOuterEdge } = layoutInRings(
                        openParamChildren, cx, cy, fanMinR, fanSlot,
                        openAngle - fanSpread / 2, fanSpread
                    );
                    fanParamPos.forEach(({ node: child, x, y }) => {
                        let px = (draggedNode === child && isDragging) ? child.x : x;
                        let py = (draggedNode === child && isDragging) ? child.y : y;
                        calcPos(child, px, py);
                    });

                    // 孫BASEも扇形に配置
                    openSubBaseChildren.forEach((child, i) => {
                        let t = openSubBaseChildren.length === 1 ? 0.5 : i / (openSubBaseChildren.length - 1);
                        let angle = (openAngle - fanSpread * 0.28) + t * fanSpread * 0.56;
                        let r2 = fanOuterEdge + baseSlot * 0.5;
                        let bx = cx + Math.cos(angle) * r2;
                        let by = cy + Math.sin(angle) * r2;
                        if(draggedNode === child && isDragging) { bx = child.x; by = child.y; }
                        calcPos(child, bx, by);
                    });

                    // 扇形メタ情報を保存(描画用)
                    openChild._fanAngle  = openAngle;
                    openChild._fanSpread = fanSpread;
                    openChild._fanMinR   = fanMinR;
                    openChild._fanMaxR   = fanOuterEdge + fanSlot * 0.3;
                }
            }
        }

        function getInterpolatedPosition(baseId, t) {
            let kfs = keyframes.slice().sort((a,b)=>a.time - b.time);
            let points = [];
            kfs.forEach(kf => {
                let n = kf.nodes.find(x => x.id === baseId && !x.parent); 
                if (n) points.push({ time: kf.time, x: n.x, y: n.y });
            });
            if (points.length === 0) return null;
            if (points.length === 1) return { x: points[0].x, y: points[0].y };
            if (t <= points[0].time) return { x: points[0].x, y: points[0].y };
            if (t >= points[points.length-1].time) return { x: points[points.length-1].x, y: points[points.length-1].y };
            
            for (let i = 0; i < points.length - 1; i++) {
                if (t >= points[i].time && t <= points[i+1].time) {
                    let dt = points[i+1].time - points[i].time;
                    if(dt <= 0.001) dt = 0.001;
                    let ratio = (t - points[i].time) / dt;
                    return { x: points[i].x + (points[i+1].x - points[i].x) * ratio, y: points[i].y + (points[i+1].y - points[i].y) * ratio };
                }
            }
            return null;
        }

        function animateCanvas() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            
            ctx.save();
            ctx.scale(zoom, zoom);
            
            ctx.fillStyle = '#111118';
            ctx.fillRect(0, 0, layoutW, layoutH);
            
            ctx.strokeStyle = '#1a1a25';
            ctx.lineWidth = 1;
            ctx.beginPath();
            for(let x=0; x<=layoutW; x+=gridSz) { ctx.moveTo(x, 0); ctx.lineTo(x, layoutH); }
            for(let y=0; y<=layoutH; y+=gridSz) { ctx.moveTo(0, y); ctx.lineTo(layoutW, y); }
            ctx.stroke();

            ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
            
            let useInterp = isPlaying || isPreviewing;
            let renderNodesList = [];

            if (useInterp) {
                let sorted = keyframes.slice().sort((a,b)=>a.time - b.time);
                let activeKf = sorted.slice().reverse().find(k => k.time <= currentTime) || sorted[0];
                
                activeKf.nodes.filter(n=>!n.parent).forEach(root => {
                    let interp = getInterpolatedPosition(root.id, currentTime);
                    let rx = interp ? interp.x : root.x; let ry = interp ? interp.y : root.y;
                    if(draggedNode === root && isDragging) { rx = root.x; ry = root.y; }
                    calcPos(root, rx, ry);
                });
                renderNodesList = activeKf.nodes;
            } else {
                nodes.filter(n => !n.parent).forEach(root => {
                    let rx = root.x; let ry = root.y;
                    if(draggedNode === root && isDragging) { rx = root.x; ry = root.y; }
                    calcPos(root, rx, ry);
                });
                renderNodesList = nodes;
            }

            let visibleNodes = getVisibleNodes(renderNodesList.filter(n => !n.parent));
            let now = Date.now();
            let pulse  = Math.sin(now / 200);
            let pulse2 = Math.sin(now / 110);
            let blink  = Math.sin(now / 80); // 点滅用: 速め

            // ────────────────────────────────────────
            // 開いているベースの装飾を後ろから描画 (順序: デコレーション → ノード本体)
            // ────────────────────────────────────────
            function drawOpenBaseDecor(base) {
                if(!base.isOpen || base.screenX === undefined) return;
                let sx = base.screenX, sy = base.screenY;

                let paramChildren = base.children ? base.children.filter(c => c.type === 'PARAM') : [];
                let baseChildren  = base.children ? base.children.filter(c => c.type === 'BASE'  || c.type === 'BASE_REF') : [];

                let innerEdge = base._innerEdgeR || (base.radiusClosed + 32);
                let outerEdge = base._outerEdgeR || (innerEdge + 44);

                // ── 内側ゾーン背景 (PARAMリング) ──
                let igrd = ctx.createRadialGradient(sx, sy, base.radiusClosed||16, sx, sy, innerEdge);
                igrd.addColorStop(0, 'rgba(0,255,204,0.07)');
                igrd.addColorStop(1, 'rgba(0,255,204,0.01)');
                ctx.beginPath(); ctx.arc(sx, sy, innerEdge, 0, Math.PI*2);
                ctx.fillStyle = igrd; ctx.fill();

                // 内側リング境界線
                ctx.beginPath(); ctx.arc(sx, sy, innerEdge, 0, Math.PI*2);
                ctx.strokeStyle = `rgba(0,255,204,${0.22 + 0.1 * pulse})`;
                ctx.lineWidth = 1.5; ctx.setLineDash([4,4]); ctx.stroke(); ctx.setLineDash([]);

                // PARAM → 中心 接続ライン
                paramChildren.forEach(child => {
                    if(child.screenX === undefined) return;
                    ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(child.screenX, child.screenY);
                    ctx.strokeStyle = 'rgba(0,255,204,0.15)'; ctx.lineWidth = 1; ctx.stroke();
                });

                // ── 外側ゾーン背景 (子BASEリング) ──
                if(baseChildren.length > 0) {
                    let ogrd = ctx.createRadialGradient(sx, sy, innerEdge, sx, sy, outerEdge);
                    ogrd.addColorStop(0, 'rgba(80,160,255,0.05)');
                    ogrd.addColorStop(1, 'rgba(80,160,255,0.0)');
                    ctx.beginPath(); ctx.arc(sx, sy, outerEdge, 0, Math.PI*2);
                    ctx.fillStyle = ogrd; ctx.fill();

                    // 外側リング境界線
                    ctx.beginPath(); ctx.arc(sx, sy, outerEdge, 0, Math.PI*2);
                    ctx.strokeStyle = `rgba(80,160,255,${0.2 + 0.08 * pulse})`;
                    ctx.lineWidth = 1.5; ctx.setLineDash([5,5]); ctx.stroke(); ctx.setLineDash([]);

                    // 子BASE → 中心 接続ライン
                    baseChildren.forEach(child => {
                        if(child.screenX === undefined) return;
                        ctx.beginPath(); ctx.moveTo(sx, sy); ctx.lineTo(child.screenX, child.screenY);
                        ctx.strokeStyle = 'rgba(80,160,255,0.18)'; ctx.lineWidth = 1; ctx.stroke();
                    });
                }

                // ── 展開中の子BASEの扇形エリア ──
                let openChild = baseChildren.find(c => c.isOpen);
                if(openChild && openChild._fanAngle !== undefined) {
                    let fa  = openChild._fanAngle;
                    let fs  = openChild._fanSpread;
                    let fMin = openChild._fanMinR || outerEdge;
                    let fMax = openChild._fanMaxR || (fMin + 48);
                    let sa  = fa - fs / 2;
                    let ea  = fa + fs / 2;

                    // 扇形背景グロー
                    let fanGrd = ctx.createRadialGradient(sx, sy, fMin * 0.9, sx, sy, fMax * 1.1);
                    fanGrd.addColorStop(0, `rgba(255,220,40,${0.09 + 0.05 * pulse})`);
                    fanGrd.addColorStop(1, 'rgba(255,220,40,0.0)');
                    ctx.beginPath();
                    ctx.arc(sx, sy, fMax * 1.05, sa, ea);
                    ctx.arc(sx, sy, fMin * 0.95, ea, sa, true);
                    ctx.closePath();
                    ctx.fillStyle = fanGrd; ctx.fill();

                    // 扇形輪郭 (点滅するアウトライン)
                    let fanAlpha = 0.45 + 0.45 * blink; // 0〜0.9 で点滅
                    ctx.beginPath();
                    ctx.arc(sx, sy, fMax * 1.03, sa, ea);
                    ctx.arc(sx, sy, fMin * 0.97, ea, sa, true);
                    ctx.closePath();
                    ctx.strokeStyle = `rgba(255,220,40,${fanAlpha})`;
                    ctx.lineWidth = 2.5;
                    ctx.setLineDash([7, 4]);
                    ctx.stroke();
                    ctx.setLineDash([]);

                    // 扇形の両サイドライン
                    ctx.beginPath();
                    ctx.moveTo(sx + Math.cos(sa) * fMin * 0.97, sy + Math.sin(sa) * fMin * 0.97);
                    ctx.lineTo(sx + Math.cos(sa) * fMax * 1.03, sy + Math.sin(sa) * fMax * 1.03);
                    ctx.strokeStyle = `rgba(255,220,40,${fanAlpha * 0.8})`;
                    ctx.lineWidth = 1.5; ctx.setLineDash([4,3]); ctx.stroke();
                    ctx.beginPath();
                    ctx.moveTo(sx + Math.cos(ea) * fMin * 0.97, sy + Math.sin(ea) * fMin * 0.97);
                    ctx.lineTo(sx + Math.cos(ea) * fMax * 1.03, sy + Math.sin(ea) * fMax * 1.03);
                    ctx.stroke(); ctx.setLineDash([]);

                    // 展開中子BASE → 扇形内PARAM への接続ライン
                    let fanParamChildren = openChild.children ? openChild.children.filter(c => c.type === 'PARAM') : [];
                    fanParamChildren.forEach(child => {
                        if(child.screenX === undefined) return;
                        ctx.beginPath(); ctx.moveTo(openChild.screenX, openChild.screenY); ctx.lineTo(child.screenX, child.screenY);
                        ctx.strokeStyle = `rgba(255,200,40,0.22)`; ctx.lineWidth = 1; ctx.stroke();
                    });

                    // 扇形ラベル (展開中の子BASEのemoji)
                    let fanLabelR = (fMin + fMax) / 2;
                    ctx.font = '10px sans-serif';
                    ctx.fillStyle = `rgba(255,230,80,${0.6 + 0.3*pulse})`;
                    ctx.fillText(openChild.emoji || '▶', sx + Math.cos(fa) * fanLabelR, sy + Math.sin(fa) * fanLabelR);
                }

                // 再帰
                baseChildren.forEach(child => drawOpenBaseDecor(child));
            }

            // 全ルートBASEのデコレーションを描画
            renderNodesList.filter(n => !n.parent && (n.type === 'BASE' || n.type === 'BASE_REF')).forEach(base => drawOpenBaseDecor(base));

            // hoverTarget 強調
            if(hoverTarget && hoverTarget.screenX !== undefined) {
                let hr = (hoverTarget.radiusOpen || hoverTarget.radiusClosed || hoverTarget.radius || 16);
                ctx.beginPath(); ctx.arc(hoverTarget.screenX, hoverTarget.screenY, hr + 10, 0, Math.PI * 2);
                ctx.strokeStyle = `rgba(255,200,0,${0.4 + 0.5 * Math.sin(now / 90)})`; ctx.lineWidth = 3; ctx.stroke();
            }

            // ────────────────────────────────────────
            // ノード本体の描画
            // ────────────────────────────────────────
            visibleNodes.forEach(n => {
                let sx = n.screenX; let sy = n.screenY;
                if(sx === undefined) return;

                if(n.type === 'BASE' || n.type === 'BASE_REF') {
                    let isRef = n.type === 'BASE_REF';
                    let sortedKFs = keyframes.slice().sort((a,b)=>a.time - b.time);
                    let actual = null;
                    if(isRef) { sortedKFs.slice().reverse().forEach(kf => { if(!actual) actual = kf.nodes.find(x => x.id === n.refId); }); }
                    let emoji = isRef ? (actual ? actual.emoji : '❓') : n.emoji;
                    let nodeColor = isRef ? (actual ? actual.color : '#555') : n.color;
                    let r = n.isOpen ? (n.radiusClosed || 16) : (n.radiusClosed || 16);

                    if(n.isOpen) {
                        // ── 開いた状態: コアドット + リング ──
                        // 背景グロー
                        let grd = ctx.createRadialGradient(sx, sy, 0, sx, sy, r * 2.5);
                        grd.addColorStop(0, isRef ? nodeColor + '30' : 'rgba(0,255,204,0.22)');
                        grd.addColorStop(1, 'rgba(0,0,0,0)');
                        ctx.beginPath(); ctx.arc(sx, sy, r * 2.5, 0, Math.PI*2);
                        ctx.fillStyle = grd; ctx.fill();

                        // コア本体
                        let cGrd = ctx.createRadialGradient(sx-r*0.2, sy-r*0.2, 0, sx, sy, r);
                        cGrd.addColorStop(0, '#ffffff');
                        cGrd.addColorStop(0.35, isRef ? nodeColor : '#00ffcc');
                        cGrd.addColorStop(1, isRef ? nodeColor + '88' : '#006655');
                        ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI*2);
                        ctx.fillStyle = cGrd; ctx.fill();

                        // コアリング
                        ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI*2);
                        ctx.strokeStyle = isRef ? nodeColor : `rgba(0,255,204,${0.7 + 0.3*pulse})`;
                        ctx.lineWidth = 2; ctx.setLineDash([5,3]); ctx.stroke(); ctx.setLineDash([]);

                        // emoji ラベル (コアの上)
                        ctx.font = '11px sans-serif'; ctx.fillStyle = 'rgba(255,255,255,0.85)';
                        ctx.fillText(emoji, sx, sy + 1);
                    } else {
                        // ── 閉じた状態 ──
                        let grd = ctx.createRadialGradient(sx-r*0.2, sy-r*0.2, 0, sx, sy, r);
                        grd.addColorStop(0, '#33334a');
                        grd.addColorStop(1, '#111118');
                        ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI*2);
                        ctx.fillStyle = grd; ctx.fill();
                        ctx.strokeStyle = isRef ? nodeColor : '#556677'; ctx.lineWidth = 1.5;
                        if(isRef) ctx.setLineDash([2,3]);
                        ctx.stroke(); ctx.setLineDash([]);
                        ctx.font = '14px sans-serif'; ctx.fillStyle='#fff';
                        ctx.fillText(emoji, sx, sy + 1);
                    }
                }
                else if(n.type === 'PARAM') {
                    // PARAMノード: グロー + 本体
                    let glowR = n.radius * 1.6;
                    let gGrd = ctx.createRadialGradient(sx, sy, 0, sx, sy, glowR);
                    gGrd.addColorStop(0, n.color + '55');
                    gGrd.addColorStop(1, n.color + '00');
                    ctx.beginPath(); ctx.arc(sx, sy, glowR, 0, Math.PI*2);
                    ctx.fillStyle = gGrd; ctx.fill();

                    let iGrd = ctx.createRadialGradient(sx-n.radius*0.25, sy-n.radius*0.25, 0, sx, sy, n.radius);
                    iGrd.addColorStop(0, n.color + 'ff');
                    iGrd.addColorStop(1, n.color + '99');
                    ctx.beginPath(); ctx.arc(sx, sy, n.radius, 0, Math.PI*2);
                    ctx.fillStyle = iGrd; ctx.fill();
                    ctx.strokeStyle = '#000c'; ctx.lineWidth = 1.5; ctx.stroke();
                    ctx.font = '13px sans-serif'; ctx.fillStyle='#fff';
                    ctx.fillText(n.emoji, sx, sy + 1);
                }
            });

            // ── 選択中ノードのアニメ輪郭 ──
            if(!useInterp && globalSelectedNode && globalSelectedNode.screenX !== undefined && visibleNodes.includes(globalSelectedNode)) {
                let t2 = now / 150;
                let sx = globalSelectedNode.screenX; let sy = globalSelectedNode.screenY;
                let r = (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF')
                    ? (globalSelectedNode.radiusClosed || 16)
                    : globalSelectedNode.radius;
                for(let ri = 0; ri < 2; ri++) {
                    ctx.beginPath(); ctx.arc(sx, sy, r + 6 + ri * 5 + Math.sin(t2 + ri) * 2, 0, Math.PI*2);
                    ctx.strokeStyle = `rgba(0,255,204,${(0.6 - ri*0.25) + 0.3*Math.sin(t2)})`; ctx.lineWidth = 2 - ri*0.5; ctx.stroke();
                }
            }
            
            ctx.restore(); 
            requestAnimationFrame(animateCanvas);
        }
        
        setTimeout(() => { resizeCanvas(); renderKeyframes(); animateCanvas(); }, 100);

        // ==========================================
        // 9. アニメーションループ
        // ==========================================
        function animateThree() { 
            requestAnimationFrame(animateThree); 
            let delta = clock.getDelta();
            if(isPlaying) {
                currentTime += delta * tSpeed;
                if(currentTime >= tDuration) {
                    if(tLoop) currentTime = 0.0;
                    else { currentTime = tDuration; isPlaying = false; playBtn.innerText = '▶️'; }
                }
                timeSlider.value = currentTime;
                timeDisplay.innerText = currentTime.toFixed(2);
                window.uniforms.u_time.value = currentTime;
            }
            renderer.render(scene, camera); 
        }
        animateThree();
    </script>
</body>
</html>

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

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

便利なツール

  • 73本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
GLSLシェーダーで遊ぶ「Glsl Viewer」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word 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