SF映画っぽいエクスプローラ「Exp3D 1.5」


画像
画像

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


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Exp3D 1.5.2</title>
    <style>
        :root {
            --cyan: #00ffff;
            --magenta: #ff00ff;
            --orange: #ffaa00;
            --yellow: #ffee00;
            --ocher: #ccaa00;
            --dark-ocher: #886600;
            --blue: #0088ff;
            --bg: #000000; /* Pure black for bloom */
            --panel: rgba(5, 10, 15, 0.95);
        }
        body { margin: 0; overflow: hidden; background-color: var(--bg); font-family: 'Segoe UI', monospace; user-select: none; color: #fff; }

        /* App Layout */
        #app { display: flex; width: 100vw; height: 100vh; position: relative; overflow: hidden; }

        /* Panes */
        #pane-tree { 
            flex: 0 0 25%; position: relative; overflow: hidden; 
            border-right: 1px solid #333; 
            background: rgba(0,0,0,0.3); 
            z-index: 10; pointer-events: none; 
            display: flex; flex-direction: column;
        }
        #pane-file { 
            flex: 1; position: relative; overflow: hidden; 
            background: transparent; 
            z-index: 10; pointer-events: none; 
        }
        
        /* Interactive UI Elements */
        .pane-label, .sys-btn, .stock-item, .stock-tab-item, #stock, #stock-tabs-container, #pagination-ctrl, .tree-node, .view-switch-btn, #btn-select-root {
            pointer-events: auto;
        }

        /* Splitter */
        #splitter {
            width: 6px; cursor: col-resize; background: #111; border-left: 1px solid #444; border-right: 1px solid #444;
            display: flex; flex-direction: column; justify-content: center; align-items: center; z-index: 100;
            transition: background 0.2s; pointer-events: auto;
        }
        #splitter:hover, #splitter.active { background: var(--orange); }
        .grip { width: 2px; height: 4px; background: #666; margin: 2px 0; }

        /* Canvas Container */
        #canvas-wrap {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%; 
            z-index: 0; pointer-events: auto; 
        }
        canvas { display: block; outline: none; }

        /* HUD */
        #hud { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; z-index: 50; }
        #hud-canvas { width: 100%; height: 100%; display: block; }

        /* Tree Pane Header */
        #tree-header {
            height: 40px; background: linear-gradient(to bottom, rgba(0,20,40,0.9), transparent);
            display: flex; align-items: center; padding: 0 10px; flex-shrink: 0;
            border-bottom: 1px solid rgba(255,255,255,0.1);
            gap: 10px;
        }
        .tree-title { font-size: 14px; font-weight: bold; color: var(--cyan); letter-spacing: 1px; white-space: nowrap; }
        
        #btn-select-root {
            background: rgba(0,0,0,0.5); border: 1px solid var(--cyan); color: var(--cyan);
            padding: 2px 8px; font-size: 11px; cursor: pointer; transition: 0.2s; white-space: nowrap; font-weight: bold;
        }
        #btn-select-root:hover { background: var(--cyan); color: #000; box-shadow: 0 0 5px var(--cyan); }

        .view-switch { display: flex; gap: 2px; }
        .view-switch-btn {
            background: rgba(0,0,0,0.5); border: 1px solid #444; color: #666;
            padding: 2px 8px; font-size: 11px; cursor: pointer; transition: 0.2s;
        }
        .view-switch-btn:hover { color: #fff; border-color: #888; }
        .view-switch-btn.active { background: var(--ocher); color: #000; border-color: var(--ocher); font-weight: bold; box-shadow: 0 0 5px var(--ocher); }

        /* Top Bar */
        #top-bar {
            position: absolute; top: 0; left: 0; width: 100%; height: 40px;
            display: flex; align-items: center; justify-content: flex-end; padding: 0 20px;
            z-index: 60; pointer-events: none;
        }

        .pane-label {
            position: absolute; bottom: 10px; left: 10px; font-size: 12px; 
            color: rgba(255,255,255,0.3); pointer-events: none; z-index: 10;
        }
        
        #mode-info {
            position: absolute; top: 10px; right: 20px; text-align: right; pointer-events: none;
            color: var(--yellow); text-shadow: 0 0 5px var(--yellow); font-weight: bold; font-size: 16px; z-index: 20;
        }
        #pagination-ctrl {
            position: absolute; top: 35px; right: 20px;
            display: flex; align-items: center; gap: 5px;
            z-index: 60;
        }
        .page-btn {
            background: rgba(0,0,0,0.5); border: 1px solid var(--blue); color: var(--blue);
            width: 24px; height: 24px; display: flex; align-items: center; justify-content: center;
            cursor: pointer; font-weight: bold; transition: 0.2s; font-size: 12px;
        }
        .page-btn:hover { background: var(--blue); color: #000; }
        .page-btn.disabled { opacity: 0.3; pointer-events: none; border-color: #555; color: #555; }
        #page-display {
            font-size: 14px; font-weight: bold; color: var(--blue); min-width: 50px; text-align: center;
        }

        /* Tree View Items (2D) */
        #tree-container { 
            padding: 10px; overflow-y: auto; flex: 1; box-sizing: border-box; 
            display: none; 
            background: rgba(0,0,0,0.3);
        }
        .tree-node {
            padding: 4px 8px; color: #aaa; cursor: pointer; display: flex; align-items: center;
            border-bottom: 1px solid rgba(255,255,255,0.05); transition: background 0.2s; font-size: 13px;
        }
        .tree-node:hover { background: rgba(0,255,255,0.1); color: #fff; }
        .tree-icon { margin-right: 8px; font-size: 14px; color: var(--ocher); }

        /* Loader */
        #loader {
            position: absolute; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.85); display: flex; flex-direction: column;
            justify-content: center; align-items: center; z-index: 200;
            display: none; pointer-events: auto;
        }
        #loader.active { display: flex; }
        .loader-text { color: var(--cyan); font-size: 24px; margin-bottom: 20px; animation: blink 1s infinite; letter-spacing: 2px; }
        @keyframes blink { 50% { opacity: 0.5; } }

        /* STOCK PANEL */
        #stock {
            position: absolute; bottom: 0; left: 0; right: 0; height: 180px;
            background: linear-gradient(to top, rgba(20,10,0,0.95), transparent);
            border-top: 2px solid var(--orange); display: flex; align-items: center; 
            padding: 0 50px; gap: 40px; 
            z-index: 40; transition: bottom 0.3s; overflow-x: hidden;
            cursor: grab; pointer-events: auto;
        }
        #stock.active { cursor: grabbing; }
        #stock.closed { bottom: -180px; }
        
        #stock-tabs-container {
            position: absolute; bottom: 180px; left: 50%; transform: translateX(-50%);
            display: flex; gap: 5px; z-index: 41; transition: bottom 0.3s; pointer-events: auto;
        }
        #stock.closed + #stock-tabs-container { bottom: 0; }
        
        .stock-tab-item {
            background: rgba(50, 20, 0, 0.8); color: #aaa; 
            padding: 2px 20px; font-weight: bold; font-size: 12px;
            cursor: pointer; clip-path: polygon(10% 0, 90% 0, 100% 100%, 0% 100%);
            border-bottom: none; transition: 0.2s; min-width: 60px; text-align: center;
            border-top: 2px solid #553300; pointer-events: auto;
        }
        .stock-tab-item:hover { color: #fff; background: rgba(100, 50, 0, 0.8); }
        .stock-tab-item.active { background: var(--orange); color: #000; border-top: 2px solid #fff; }
        
        .stock-item {
            width: 100px; height: 130px; 
            background: rgba(0,0,0,0.3); border: 1px solid var(--orange);
            display: flex; flex-direction: column; align-items: center; 
            cursor: grab; color: var(--orange); flex-shrink: 0; position: relative;
            transition: 0.2s; user-select: none; pointer-events: auto;
        }
        .stock-item:hover { background: rgba(255,170,0,0.1); box-shadow: 0 0 10px rgba(255,170,0,0.2); }
        .stock-thumb { width: 90px; height: 90px; margin-top: 5px; object-fit: contain; background: #000; display:flex; justify-content:center; align-items:center; overflow:hidden; }
        .stock-label { font-size: 11px; margin-top: 5px; width: 90px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: bold; }

        /* Context Menu */
        .ctx-menu {
            position: fixed; display: none; flex-direction: column;
            background: rgba(10, 15, 20, 0.98); border: 1px solid var(--cyan);
            box-shadow: 0 0 20px rgba(0,255,255,0.3); min-width: 200px; z-index: 9999;
            transform-origin: top; padding: 5px 0; overflow: hidden; pointer-events: auto;
        }
        .ctx-menu.opening { animation: menuOpen 0.2s ease-out forwards; }
        @keyframes menuOpen { from { transform: scaleY(0); opacity: 0; } to { transform: scaleY(1); opacity: 1; } }
        .ctx-item { padding: 10px 15px; cursor: pointer; color: #ccc; display: flex; justify-content: space-between; border-bottom: 1px solid #333; transition: background 0.1s; }
        .ctx-item:hover { background: var(--cyan); color: #000; }
        .ctx-menu.closing .ctx-item { animation: itemFold 0.2s forwards ease-in; }
        @keyframes itemFold { 0% { transform: translateY(0); opacity: 1; height: 40px; } 100% { transform: translateY(-20px); opacity: 0; height: 0; padding: 0; border: none; } }

        /* Modals */
        .modal-overlay {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.8);
            display: flex; justify-content: center; align-items: center; z-index: 300;
            opacity: 0; pointer-events: none; visibility: hidden; transition: opacity 0.3s;
            perspective: 1500px;
        }
        .modal-overlay.active { opacity: 1; pointer-events: auto; visibility: visible; }
        
        .modal-box { 
            border: 1px solid var(--cyan); background: #000; 
            display: flex; flex-direction: column; box-shadow: 0 0 50px rgba(0,255,255,0.2); 
            transform-style: preserve-3d; opacity: 0; transform-origin: center;
        }
        .modal-overlay.opening .modal-box { animation: animFlipOpen 0.6s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        .modal-overlay.closing .modal-box { animation: animFlipClose 0.5s cubic-bezier(0.19, 1, 0.22, 1) forwards; }
        @keyframes animFlipOpen { 0% { transform: translateZ(-500px) rotateX(180deg); opacity: 0; } 100% { transform: translateZ(0) rotateX(0deg); opacity: 1; } }
        @keyframes animFlipClose { 0% { transform: translateZ(0) rotateX(0deg); opacity: 1; } 100% { transform: translateZ(-500px) rotateX(-180deg); opacity: 0; } }

        .modal-head { padding: 10px; background: rgba(0,255,255,0.15); color: var(--cyan); display: flex; justify-content: space-between; align-items: center; font-weight: bold; }
        .modal-body { flex: 1; padding: 0; overflow: hidden; position: relative; background: #050505; display: flex; justify-content: center; align-items: center; }
        .p-btn { background: transparent; border: 1px solid var(--cyan); color: var(--cyan); padding: 5px 15px; margin-left: 10px; cursor: pointer; transition: 0.2s; }
        .p-btn:hover { background: var(--cyan); color: #000; }
        .p-btn-save { display: none; border-color: #0f0; color: #0f0; }

        #p-box { width: 90%; height: 90%; }
        .hex-view { width: 100%; height: 100%; padding: 15px; color: #0f0; background: #000; border: none; resize: none; font-family: 'Courier New', monospace; outline: none; }
        #d-box { width: 400px; height: 200px; }
        #d-body { flex-direction: column; padding: 20px; gap: 20px; }
        .d-input { width: 100%; background: #111; border: 1px solid #333; color: #fff; padding: 10px; font-family: 'Segoe UI', monospace; font-size: 16px; outline: none; text-align: center; }
        .d-input:focus { border-color: var(--cyan); }
        .d-actions { display: flex; gap: 10px; width: 100%; justify-content: center; }

        .scanlines {
            position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none;
            background: linear-gradient(to bottom, rgba(255,255,255,0) 50%, rgba(0,0,0,0.1) 50%);
            background-size: 100% 3px; z-index: 9000; opacity: 0.15;
        }
    </style>
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>
    <div id="app">
        <div id="canvas-wrap"></div>

        <div id="pane-tree">
            <div id="tree-header">
                <span class="tree-title">EXP3D SYSTEM</span>
                <button id="btn-select-root">SELECT ROOT</button>
                <div class="view-switch">
                    <button id="btn-view-2d" class="view-switch-btn">2D</button>
                    <button id="btn-view-3d" class="view-switch-btn active">3D</button>
                </div>
            </div>
            <div id="tree-container"></div>
        </div>

        <div id="splitter">
            <div class="grip"></div><div class="grip"></div><div class="grip"></div>
        </div>

        <div id="pane-file">
            <div class="pane-label">SYSTEM // OBJECTS</div>
            
            <div id="hud"><canvas id="hud-canvas"></canvas></div>

            <div id="stock" class="closed">
                <div id="stock-hint" style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>
            </div>
            <div id="stock-tabs-container">
                <div id="stock-tab-placeholder" class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>
            </div>

            <div id="mode-info">MODE: F1 (VERTICAL)</div>
            <div id="pagination-ctrl">
                <div id="btn-prev" class="page-btn">&lt;</div>
                <div id="page-display">1 / 1</div>
                <div id="btn-next" class="page-btn">&gt;</div>
            </div>
        </div>

        <div id="top-bar"></div>
        
        <div id="loader">
            <div class="loader-text">READING DATA...</div>
            <button id="btn-cancel-load" class="p-btn" style="border-color:#ff5555; color:#ff5555;">CLOSE</button>
        </div>

        <div id="ctx-obj" class="ctx-menu">
            <div class="ctx-item" id="cm-open">Open</div>
            <div class="ctx-item" id="cm-expand" style="color:var(--yellow); display:none;">Expand (Force)</div>
            <div class="ctx-item" id="cm-collapse" style="color:var(--yellow); display:none;">Collapse</div>
            <div class="ctx-item" id="cm-copy">Copy</div>
            <div class="ctx-item" id="cm-rename">Rename</div>
            <div class="ctx-item" id="cm-add-stock" style="color:var(--orange)">Add to Stock</div>
            <div class="ctx-item" id="cm-delete" style="color:#ff5555">Delete</div>
        </div>
        <div id="ctx-bg" class="ctx-menu">
            <div class="ctx-item" id="cm-new">New Folder</div>
            <div class="ctx-item" id="cm-paste">Paste</div>
            <div class="ctx-item" id="cm-up">Up Directory</div>
            <div class="ctx-item" id="cm-refresh">Refresh</div>
        </div>
        <div id="ctx-tree-bg" class="ctx-menu">
            <div class="ctx-item" id="cm-tree-up">Up Directory</div>
            <div class="ctx-item" id="cm-tree-refresh">Refresh</div>
        </div>
        <div id="ctx-tab" class="ctx-menu">
            <div class="ctx-item" id="cm-tab-hide" style="color:var(--orange)">Hide</div>
            <div class="ctx-item" id="cm-tab-cancel" style="color:#ff5555">Cancel</div>
        </div>
    </div>

    <div id="preview" class="modal-overlay">
        <div id="p-box" class="modal-box">
            <div class="modal-head">
                <span id="p-title">FILE</span>
                <div>
                    <button id="p-save" class="p-btn p-btn-save">SAVE</button>
                    <button id="p-close" class="p-btn">CLOSE</button>
                </div>
            </div>
            <div id="p-body" class="modal-body"></div>
        </div>
    </div>

    <div id="dialog" class="modal-overlay">
        <div id="d-box" class="modal-box">
            <div class="modal-head">
                <span id="d-title">INPUT</span>
            </div>
            <div id="d-body" class="modal-body">
                <input type="text" id="d-input" class="d-input" spellcheck="false" autocomplete="off">
                <div class="d-actions">
                    <button id="d-ok" class="p-btn" style="border-color:var(--cyan); color:var(--cyan); width:100px;">OK</button>
                    <button id="d-cancel" class="p-btn" style="border-color:#ff5555; color:#ff5555; width:100px;">CANCEL</button>
                </div>
            </div>
        </div>
    </div>

    <div class="scanlines"></div>

    <script type="module">
        import * as THREE from 'three';
        import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
        import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
        import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';

        // --- Configuration ---
        const CONFIG = {
            colFolderIdle: 0x886600, 
            colFolderActive: 0xffff00,
            colFile: 0x0088ff, 
            treeCamOffset: { x:0, y:5, z:40 },
            maxTreeChildren: 100, 
            itemsPerPage: 20
        };

        const state = {
            splitPercent: 30, draggingSplit: false, activePane: 'none',
            treeMode: '3D',
            rootHandle: null, currentHandle: null,
            treeNodes: [], fileObjects: [], 
            currentAllEntries: [], currentPage: 1, totalPages: 1,
            activeNode: null, 
            stockList: [], activeStockIdx: -1, clipboard: null,
            viewMode: 'F1',
            targetRotX: 0, currentRotX: 0, targetRotY: 0, currentRotY: 0,
            targetPosX: 0, currentPosX: 0, targetPosY: 0, currentPosY: 0,
            targetPosZ: 0, currentPosZ: 0,
            treeScrollZ: 0, targetTreeScrollZ: 0, treeScrollX: 0, targetTreeScrollX: 0,
            mouse: new THREE.Vector2(), lastMouse: new THREE.Vector2(),
            hovered: null, selected: null, dragging: null, dragStart: new THREE.Vector2(),
            contextTarget: null, tabContextTargetIdx: -1, isLoading: false
        };

        const sfx = {
            ctx: null,
            init: () => { if (!sfx.ctx) sfx.ctx = new (window.AudioContext || window.webkitAudioContext)(); },
            play: (type) => {
                if (!sfx.ctx) return;
                const t = sfx.ctx.currentTime;
                const o = sfx.ctx.createOscillator(); const g = sfx.ctx.createGain();
                o.connect(g); g.connect(sfx.ctx.destination);
                if(type==='click'){o.frequency.setValueAtTime(600,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
                else if(type==='hover'){o.frequency.setValueAtTime(300,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.05);o.start(t);o.stop(t+0.05);}
                else if(type==='open'){o.type='triangle';o.frequency.setValueAtTime(200,t);o.frequency.linearRampToValueAtTime(600,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
                else if(type==='back'){o.type='triangle';o.frequency.setValueAtTime(600,t);o.frequency.linearRampToValueAtTime(200,t+0.2);g.gain.exponentialRampToValueAtTime(0.001,t+0.2);o.start(t);o.stop(t+0.2);}
                else if(type==='lock'){o.type='square';o.frequency.setValueAtTime(1000,t);g.gain.setValueAtTime(0.05,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.1);o.start(t);o.stop(t+0.1);}
                else if(type==='error'){o.type='sawtooth';o.frequency.setValueAtTime(100,t);g.gain.exponentialRampToValueAtTime(0.001,t+0.3);o.start(t);o.stop(t+0.3);}
            }
        };

        const container = document.getElementById('canvas-wrap');
        const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
        renderer.setPixelRatio(window.devicePixelRatio);
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setScissorTest(true); 
        renderer.sortObjects = true;
        // Enable tone mapping for Bloom
        renderer.toneMapping = THREE.ReinhardToneMapping;
        renderer.toneMappingExposure = 1.8; // Brighten up
        container.appendChild(renderer.domElement);

        // Scene 1: TREE
        const sceneTree = new THREE.Scene();
        sceneTree.fog = new THREE.FogExp2(0x020205, 0.008);
        const camTree = new THREE.PerspectiveCamera(60, 1, 0.1, 2000);
        const groupTree = new THREE.Group(); sceneTree.add(groupTree);
        
        // Scene 2: FILE
        const sceneFile = new THREE.Scene();
        sceneFile.fog = new THREE.FogExp2(0x020205, 0.015);
        const camFile = new THREE.PerspectiveCamera(60, 1, 0.1, 1000);
        camFile.position.set(0, 0, 0.1); 
        const groupFile = new THREE.Group(); sceneFile.add(groupFile);

        // --- Post Processing (Bloom) ---
        // Global composer, but we only really care about File Scene looking cool.
        // Since we split screen via scissor, applying bloom only to right side is tricky without multiple composers.
        // We will apply bloom to full screen, but it naturally affects bright objects (lasers).
        const composer = new EffectComposer(renderer);
        const renderPass = new RenderPass(sceneFile, camFile);
        
        const bloomPass = new UnrealBloomPass(new THREE.Vector2(window.innerWidth, window.innerHeight), 1.5, 0.4, 0.85);
        bloomPass.threshold = 0.1;
        bloomPass.strength = 0.6; // Glow strength
        bloomPass.radius = 0.5;
        
        // Note: RenderPass is typically for one scene. 
        // We will manually render scenes to texture or use basic render for now.
        // Correct approach for Split Screen + Bloom:
        // 1. Render Left Pane (Tree) normally.
        // 2. Render Right Pane (File) with Bloom.
        // To do this simply: We render Tree to screen. Then clear depth? No.
        // We will render standard for Tree. Then Composer for File (with restricted viewport).
        
        composer.addPass(renderPass);
        composer.addPass(bloomPass);


        // Lights
        [sceneTree, sceneFile].forEach(s => {
            s.add(new THREE.AmbientLight(0xffffff, 1.0));
            const dl = new THREE.DirectionalLight(0xffffff, 2);
            dl.position.set(10, 50, 20); s.add(dl);
        });

        // Backgrounds
        const starGroup = new THREE.Group();
        const starsGeo = new THREE.BufferGeometry();
        const starPos = new Float32Array(3000 * 3); 
        for(let i=0; i<9000; i++) starPos[i] = (Math.random()-0.5)*1000;
        starsGeo.setAttribute('position', new THREE.BufferAttribute(starPos, 3));
        const starMat = new THREE.PointsMaterial({color:0xffffff, size:0.5, transparent:true, opacity:0.8});
        starGroup.add(new THREE.Points(starsGeo, starMat));
        
        // Laser Curves (Neon Lines)
        const orbitGroup = new THREE.Group();
        for(let i=0; i<10; i++) {
             // Create smooth circular path for laser
             const r = 25 + Math.random() * 15;
             const yOff = (Math.random()-0.5) * 80;
             const pts = [];
             // Only 1/4 circle length
             const startAng = Math.random() * Math.PI * 2;
             const length = Math.PI / 2; 
             for(let j=0; j<=32; j++) {
                 const t = startAng + (j/32)*length;
                 pts.push(new THREE.Vector3(Math.cos(t)*r, yOff, Math.sin(t)*r));
             }
             const curve = new THREE.CatmullRomCurve3(pts);
             const tube = new THREE.TubeGeometry(curve, 32, 0.15, 4, false);
             // Additive Blending for Glow
             const mat = new THREE.MeshBasicMaterial({
                 color:0x0055ff, transparent:true, opacity:0.8, blending: THREE.AdditiveBlending
             });
             const mesh = new THREE.Mesh(tube, mat);
             orbitGroup.add(mesh);
        }
        starGroup.add(orbitGroup);
        sceneFile.add(starGroup);

        const gridHelperTree = new THREE.GridHelper(500, 100, 0x004488, 0x001122);
        gridHelperTree.position.y = -20; sceneTree.add(gridHelperTree);
        const gridHelperFile = new THREE.GridHelper(500, 100, 0x00ffff, 0x111111);
        gridHelperFile.position.y = -15; gridHelperFile.visible = false; sceneFile.add(gridHelperFile);

        // Groups for Overlays
        const reticleFile = new THREE.Group(); 
        reticleFile.renderOrder = 10000; 
        
        const reticleTree = new THREE.Group(); sceneTree.add(reticleTree); reticleTree.visible = false;

        function createSegmentedRing(rIn, rOut, count, speed, dir) {
            const g = new THREE.Group();
            const angle = (Math.PI * 2) / count;
            const gap = 0.2; 
            for(let i=0; i<count; i++) {
                const s = i * angle + gap/2;
                const l = angle - gap;
                const geo = new THREE.RingGeometry(rIn, rOut, 32, 1, s, l);
                const color = new THREE.Color(0xffff00); color.multiplyScalar(1.5); 
                const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 1.0, side: THREE.DoubleSide, depthTest: false });
                const mesh = new THREE.Mesh(geo, mat);
                g.add(mesh);
            }
            g.userData = { speed: speed, dir: dir };
            return g;
        }
        function buildReticle(grp) {
            const r1 = createSegmentedRing(1.4, 1.45, 3, 0.08, 1); grp.add(r1);
            const r2 = createSegmentedRing(1.55, 1.6, 4, 0.05, -1); grp.add(r2);
            const r3 = createSegmentedRing(1.7, 1.75, 6, 0.02, 1); grp.add(r3);
            grp.userData = { layers: [r1, r2, r3], currentScale: 0, targetScale: 0 };
        }
        buildReticle(reticleFile); 
        buildReticle(reticleTree);

        const hudC = document.getElementById('hud-canvas');
        const hudX = hudC.getContext('2d');
        function resizeRenderer() {
            const width = window.innerWidth;
            const height = window.innerHeight;
            renderer.setSize(width, height);
            composer.setSize(width, height);
            const dpr = window.devicePixelRatio || 1;
            hudC.width = width * dpr; hudC.height = height * dpr; hudX.scale(dpr, dpr);
        }
        window.addEventListener('resize', resizeRenderer);
        resizeRenderer();

        // 3D Overlays
        const magMesh = new THREE.Mesh(
            new THREE.PlaneGeometry(3.6, 4.8), 
            new THREE.MeshBasicMaterial({ map: null, transparent: true, opacity: 0, depthTest: false, side: THREE.DoubleSide })
        );
        magMesh.renderOrder = 9999; 

        const infoMesh = new THREE.Mesh(
            new THREE.PlaneGeometry(12, 12), 
            new THREE.MeshBasicMaterial({ map: null, transparent: true, opacity: 0, depthTest: false, side: THREE.DoubleSide })
        );
        infoMesh.renderOrder = 9999;
        
        // Context for info text
        const infoCvs = document.createElement('canvas'); infoCvs.width=512; infoCvs.height=512;
        const infoCtx = infoCvs.getContext('2d');
        const infoTex = new THREE.CanvasTexture(infoCvs);
        infoMesh.material.map = infoTex;
        infoMesh.userData = { fullLines: [], charCount: 0, ctx: infoCtx, tex: infoTex };

        const arrowLineGeo = new THREE.BufferGeometry();
        const arrowLine = new THREE.Line(arrowLineGeo, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false, opacity: 0.8, transparent: true }));
        arrowLine.renderOrder = 9999;

        // TREE VIEW IMPLEMENTATION
        const treeC = document.getElementById('tree-container');
        
        class TreeNode {
            constructor(handle, parent = null) {
                this.handle = handle; this.parent = parent; this.children = [];
                this.mesh = null; this.depth = parent ? parent.depth + 1 : 0;
                this.expanded = false;
            }
        }

        function createTreeMesh(node) {
            const grp = new THREE.Group();
            const geo = new THREE.BoxGeometry(3, 3, 3);
            const mat = new THREE.MeshPhongMaterial({
                color: CONFIG.colFolderIdle, emissive: 0x000000, wireframe: false,
                transparent: true, opacity: 0.9, flatShading: true
            });
            const mesh = new THREE.Mesh(geo, mat); grp.add(mesh);
            const edges = new THREE.LineSegments(new THREE.EdgesGeometry(geo), new THREE.LineBasicMaterial({ color: 0xffaa00, transparent:true, opacity:0.8 }));
            mesh.add(edges);
            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=64;
            const ctx = cvs.getContext('2d');
            ctx.font = 'bold 36px "Segoe UI"'; ctx.fillStyle='#fff'; ctx.textAlign='center';
            ctx.shadowBlur=4; ctx.shadowColor='#00ffff'; ctx.fillText(node.handle.name, 128, 45);
            const tex = new THREE.CanvasTexture(cvs);
            const sprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex }));
            sprite.scale.set(6, 1.5, 1); sprite.position.y = -2.5; grp.add(sprite);
            grp.userData = { isNode: true, node: node };
            return grp;
        }

        function renderHtmlTree() {
            treeC.innerHTML = '';
            if(state.treeNodes.length > 0) {
                const root = state.treeNodes[0];
                treeC.appendChild(mkNode(root));
            }
        }

        function mkNode(node) {
            const container = document.createElement('div');
            const row = document.createElement('div');
            row.className = 'tree-node';
            row.style.paddingLeft = (node.depth * 20 + 10) + 'px';
            row.innerHTML = `<span class="tree-icon">${node.expanded?'📂':'📁'}</span> ${node.handle.name}`;
            row._node = node; 
            row.onclick = (e) => { e.stopPropagation(); selectNode(node); };
            container.appendChild(row);
            if(node.expanded) {
                node.children.forEach(c => { container.appendChild(mkNode(c)); });
            }
            return container;
        }

        function updateTreeLayout() {
            groupTree.children.filter(c => c.userData.isLine).forEach(c => groupTree.remove(c));
            if (!state.treeNodes[0] || !state.treeNodes[0].mesh) return;
            const depthSpacing = 50; const spreadX = 20;
            const traverse = (node, levelIdx, levelCount) => {
                if (!node.mesh) { node.mesh = createTreeMesh(node); groupTree.add(node.mesh); state.treeNodes.push(node); }
                const z = -(node.depth * depthSpacing); let x = 0;
                if (node.parent) {
                    const offset = (levelIdx - (levelCount-1)/2) * spreadX;
                    x = node.parent.mesh.position.x + offset;
                    const pts = [node.parent.mesh.position.clone(), new THREE.Vector3(x, 0, z)];
                    const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(pts), new THREE.LineBasicMaterial({ color: 0x004466 }));
                    line.userData = { isLine: true }; groupTree.add(line);
                }
                node.mesh.position.set(x, 0, z); 
                node.mesh.visible = true; // Always visible as background
                
                if (node.expanded && node.children.length > 0) node.children.forEach((c, i) => traverse(c, i, node.children.length));
                else { const hide = (n) => { if(n.mesh) n.mesh.visible = false; n.children.forEach(hide); }; node.children.forEach(hide); }
            };
            traverse(state.treeNodes[0], 0, 1);
            renderHtmlTree();
        }

        const btn2D = document.getElementById('btn-view-2d');
        const btn3D = document.getElementById('btn-view-3d');
        
        btn2D.onclick = () => {
            state.treeMode = '2D'; btn2D.classList.add('active'); btn3D.classList.remove('active');
            document.getElementById('tree-container').style.display = 'block'; 
            sfx.play('click');
        };
        btn3D.onclick = () => {
            state.treeMode = '3D'; btn3D.classList.add('active'); btn2D.classList.remove('active');
            document.getElementById('tree-container').style.display = 'none'; 
            groupTree.visible = true; 
            updateTreeLayout(); sfx.play('click');
        };

        async function selectNode(node, forceExpand = false) {
            if(!node) return;
            state.activeNode = node; state.currentHandle = node.handle;
            state.treeNodes.forEach(n => { if(n.mesh) { n.mesh.children[0].material.color.setHex(CONFIG.colFolderIdle); n.mesh.children[0].material.emissive.setHex(0x000000); } });
            if(node.mesh) {
                node.mesh.children[0].material.color.setHex(CONFIG.colFolderActive); node.mesh.children[0].material.emissive.setHex(0x332200);
                reticleTree.visible = true; reticleTree.scale.set(3.0, 3.0, 3.0); reticleTree.position.copy(node.mesh.position);
            }
            if(!node.expanded || forceExpand) {
                if (!forceExpand && !node.expanded) {
                    let count = 0; try { for await (const e of node.handle.values()) { if(e.kind==='directory') count++; if(count>CONFIG.maxTreeChildren) break; } } catch(e){}
                    if (count > CONFIG.maxTreeChildren) {
                        node.tooManyChildren = true; alert(`Warning: >${CONFIG.maxTreeChildren} subfolders.`);
                        loadFilesForPane(node.handle);
                        if(node.mesh) { state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z; state.targetTreeScrollX = node.mesh.position.x; }
                        sfx.play('click'); return;
                    }
                }
                const children = []; try { for await (const e of node.handle.values()) if (e.kind === 'directory') children.push(new TreeNode(e, node)); } catch(e){}
                node.children = children; node.expanded = true; updateTreeLayout();
            }
            loadFilesForPane(node.handle);
            if(node.mesh) { state.targetTreeScrollZ = node.mesh.position.z + CONFIG.treeCamOffset.z; state.targetTreeScrollX = node.mesh.position.x; }
            sfx.play('click');
        }

        async function reloadCurrentLevel() { if(state.activeNode) await selectNode(state.activeNode, true); }

        async function loadFilesForPane(handle) {
            state.isLoading = true; document.getElementById('loader').classList.add('active');
            state.currentAllEntries = []; reticleFile.visible = false; state.selected = null; state.hovered = null; hideOverlays();
            try { for await (const e of handle.values()) { state.currentAllEntries.push(e); if(state.currentAllEntries.length % 100 === 0) await new Promise(r=>setTimeout(r,0)); } } catch(e){}
            document.getElementById('loader').classList.remove('active'); state.isLoading = false;
            state.currentPage = 1; state.totalPages = Math.ceil(state.currentAllEntries.length / CONFIG.itemsPerPage) || 1;
            renderCurrentPage();
        }

        function renderCurrentPage() {
            document.getElementById('page-display').innerText = `${state.currentPage} / ${state.totalPages}`;
            document.getElementById('btn-prev').classList.toggle('disabled', state.currentPage <= 1);
            document.getElementById('btn-next').classList.toggle('disabled', state.currentPage >= state.totalPages);
            state.currentRotX=0; state.targetRotX=0; state.currentRotY=0; state.targetRotY=0;
            state.currentPosX=0; state.targetPosX=0; state.currentPosY=0; state.targetPosY=0; state.currentPosZ=0; state.targetPosZ=0;
            const start = (state.currentPage - 1) * CONFIG.itemsPerPage; const end = start + CONFIG.itemsPerPage;
            setMode(state.viewMode, state.currentAllEntries.slice(start, end));
        }

        document.getElementById('btn-prev').onclick = () => { if(state.currentPage > 1) { state.currentPage--; renderCurrentPage(); sfx.play('click'); } };
        document.getElementById('btn-next').onclick = () => { if(state.currentPage < state.totalPages) { state.currentPage++; renderCurrentPage(); sfx.play('click'); } };

        function createMonolith(handle, x, y, z, angle, index, mode) {
            const isF = handle.kind === 'directory'; const col = isF ? CONFIG.colFolderIdle : CONFIG.colFile;
            const geo = new THREE.BoxGeometry(2.4, 3.2, 0.1);
            const mat = new THREE.MeshPhysicalMaterial({ color: col, transparent: true, opacity: 0.3, side: THREE.DoubleSide });
            const mesh = new THREE.Mesh(geo, mat);
            mesh.position.set(x, y, z);
            if(mode === 'F1' || mode === 'F3') mesh.lookAt(x*2, y, z*2);
            else if (mode === 'F2') mesh.lookAt(x, 0, 0);
            else if (mode === 'F4') mesh.rotation.set(0, 0, 0);
            
            const sizeStr = isF ? '<DIR>' : (Math.random()*5+1).toFixed(1)+' MB';
            mesh.userData = { handle, isFolder:isF, isFileObj:true, baseColor: col, sizeStr: sizeStr };

            const edges = new THREE.EdgesGeometry(geo);
            const line = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.8 }));
            mesh.add(line);

            const cvs = document.createElement('canvas'); cvs.width=256; cvs.height=256;
            const ctx = cvs.getContext('2d');
            const ext = handle.name.split('.').pop().toUpperCase().slice(0,4);
            ctx.font = `bold 100px "Segoe UI"`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
            ctx.lineWidth = 6; ctx.strokeStyle = '#000000'; ctx.strokeText(isF?'DIR':ext, 128, 128); ctx.fillStyle = '#ffffff'; ctx.fillText(isF?'DIR':ext, 128, 128);
            const defTex = new THREE.CanvasTexture(cvs);
            const extSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: defTex }));
            extSprite.scale.set(2, 2, 1); extSprite.position.z = (mode === 'F4') ? 0.6 : -1.5; 
            mesh.add(extSprite);

            const nCvs = document.createElement('canvas'); nCvs.width=512; nCvs.height=128;
            const nCtx = nCvs.getContext('2d');
            nCtx.fillStyle = '#ffffff'; nCtx.font = 'bold 80px "Segoe UI"'; 
            nCtx.shadowColor = 'black'; nCtx.shadowBlur = 6; nCtx.textAlign = 'center'; nCtx.fillText(handle.name, 256, 50);
            const nTex = new THREE.CanvasTexture(nCvs);
            const nameSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: nTex, transparent: true }));
            // Down 4px relative to box height 3.2. Texture is 512x128. 
            // Sprite Y is -2.4. Down 4px (screen pixels) approx to world unit -> -2.5
            nameSprite.scale.set(4, 1, 1); nameSprite.position.set(0, -2.5, 0); 
            mesh.add(nameSprite);

            if (!isF) {
                const n = handle.name.toLowerCase();
                const loadT = (t) => { mesh.material = new THREE.MeshBasicMaterial({map:t, color:0xffffff}); mesh.userData.thumbTex = t; };
                if(n.match(/\.(jpg|png|webp|gif)$/)) handle.getFile().then(f => new THREE.TextureLoader().load(URL.createObjectURL(f), loadT));
                else if(n.match(/\.bmp$/)) handle.getFile().then(f => createImageBitmap(f)).then(bmp => loadT(new THREE.CanvasTexture(bmp)));
                else if(n.match(/\.(mp4|webm)$/i)) handle.getFile().then(f => {
                    const v = document.createElement('video'); v.src=URL.createObjectURL(f);
                    v.muted=true; v.loop=true; v.play().then(()=>{ loadT(new THREE.VideoTexture(v)); }).catch(()=>{});
                });
            }
            groupFile.add(mesh); state.fileObjects.push(mesh);
        }

        function setMode(mode, entries) {
            state.viewMode = mode; document.getElementById('mode-info').innerText = `MODE: ${mode}`;
            gridHelperFile.visible = false;
            state.fileObjects.forEach(o => groupFile.remove(o)); state.fileObjects = [];
            hideOverlays();
            state.targetRotX=0; state.currentRotX=0; state.targetRotY=0; state.currentRotY=0;
            state.targetPosX=0; state.currentPosX=0; state.targetPosY=0; state.currentPosY=0;
            state.targetPosZ=0; state.currentPosZ=0;

            if(!entries) return; 

            const count = entries.length; 
            const minRad = 22; const radius = Math.max(minRad, count * 1.5); 
            
            if (mode === 'F1') {
                const spacing = 3.5; // Tighter
                const circumference = 2 * Math.PI * radius;
                // Staggered: Items placed on 2 rings essentially? 
                // Or just alternating heights on same ring? Alternating heights.
                // Density doubled.
                entries.forEach((e, i) => {
                    // Spread around circle but tight
                    const ringPos = i * ((Math.PI * 2) / Math.max(20, count/2)); // count/2 because 2 rows
                    const x = radius * Math.sin(ringPos);
                    const z = radius * Math.cos(ringPos);
                    let y = (i % 2 === 0) ? 3.5 : -3.5; 
                    createMonolith(e, x, y, z, ringPos, i, 'F1');
                });
            } else if (mode === 'F2') {
                const itemsPerRing = 12; const spacingX = 10.0; const totalRings = Math.ceil(count / itemsPerRing);
                entries.forEach((e, i) => {
                    const ring = Math.floor(i / itemsPerRing); const ang = (i % itemsPerRing) * ((Math.PI*2)/itemsPerRing);
                    const x = (ring * spacingX) - (totalRings * spacingX) / 2 - 25; // Shift left more
                    createMonolith(e, x, radius * Math.sin(ang), radius * Math.cos(ang), ang, i, 'F2');
                });
                state.targetPosZ = -25; state.currentPosZ = -25;
            } else if (mode === 'F3') {
                state.targetPosY = 20; 
                entries.forEach((e, i) => { const ang = i * 0.3; createMonolith(e, radius*Math.sin(ang), -(i*2.0), radius*Math.cos(ang), ang, i, 'F3'); });
            } else if (mode === 'F4') {
                gridHelperFile.visible = true; 
                const gridW = 5; const spX = 7.0; const spZ = 15.0; 
                // Podium View
                state.targetRotX = -0.5; state.currentRotX = -0.5; 
                state.targetPosY = -18; state.currentPosY = -18; // Intermediate
                state.targetPosZ = -20; state.currentPosZ = -20;

                entries.forEach((e, i) => {
                    const col = i % gridW; const row = Math.floor(i / gridW);
                    const x = (col - gridW/2 + 0.5) * spX; 
                    const z = -row * spZ; 
                    createMonolith(e, x, 0, z, 0, i, 'F4');
                });
            }
        }

        const splitter = document.getElementById('splitter'); const paneTree = document.getElementById('pane-tree'); const stockDiv = document.getElementById('stock');
        let isStockDragging = false; let stockStartX, stockScrollLeft;
        stockDiv.addEventListener('pointerdown', e => { if(e.target.closest('.stock-item')) return; isStockDragging=true; stockDiv.classList.add('active'); stockStartX = e.pageX - stockDiv.offsetLeft; stockScrollLeft = stockDiv.scrollLeft; e.stopPropagation(); });
        splitter.addEventListener('pointerdown', e => { state.draggingSplit = true; splitter.classList.add('active'); e.preventDefault(); });
        
        window.addEventListener('pointerup', async e => {
            isStockDragging = false; stockDiv.classList.remove('active');
            if (state.draggingSplit) { state.draggingSplit = false; splitter.classList.remove('active'); return; }
            if (state.dragging) {
                if(e.clientY > window.innerHeight - 180 && state.dragging.mesh) await moveToStock(state.dragging.mesh.userData.handle);
                else if (!state.dragging.isMove && state.dragging.mesh) { if(state.dragging.mesh.userData.isFileObj) handleFileSelection(state.dragging.mesh); }
                state.dragging = null; 
            }
            document.body.style.cursor = 'default';
        });

        function handleFileSelection(mesh) {
            state.fileObjects.forEach(o => { if(o.userData.isFolder) o.material.color.setHex(CONFIG.colFolderIdle); });
            state.selected = mesh;
            if(mesh.userData.isFolder) mesh.material.color.setHex(CONFIG.colFolderActive);
            updateOverlays(mesh); sfx.play('lock');
            reticleFile.userData.targetScale = 3.5;
            reticleFile.scale.set(5.5, 5.5, 1);
        }

        function hideOverlays() {
            magMesh.opacity = 0; magMesh.visible = false; magMesh.removeFromParent();
            infoMesh.opacity = 0; infoMesh.visible = false; infoMesh.removeFromParent();
            arrowLine.visible = false; arrowLine.removeFromParent();
            reticleFile.visible = false; reticleFile.removeFromParent();
            reticleFile.userData.targetScale = 0;
        }

        function updateOverlays(target) {
            target.add(reticleFile);
            target.add(magMesh);
            target.add(infoMesh);
            target.add(arrowLine);

            const isF = target.userData.isFolder;

            infoMesh.userData.fullLines = [ 
                `NAME: ${target.userData.handle.name}`, 
                `KIND: ${target.userData.handle.kind.toUpperCase()}`, 
                `DATE: ${new Date().toLocaleDateString()}`,
                `SIZE: ${target.userData.sizeStr}` 
            ];
            infoMesh.userData.charCount = 0;
            infoMesh.visible = true; infoMesh.material.opacity = 1;

            if(isF) {
                magMesh.material = new THREE.MeshBasicMaterial({ color: 0xffff00, side: THREE.DoubleSide });
            } else {
                magMesh.material = new THREE.MeshBasicMaterial({ map: target.userData.thumbTex || null, side: THREE.DoubleSide });
            }
            magMesh.visible = true; magMesh.material.opacity = 1;

            const Z_FRONT = 20.0; // Very Front
            
            reticleFile.position.set(0, 0, Z_FRONT + 0.1); 
            reticleFile.rotation.set(0, 0, 0); 
            reticleFile.scale.set(3.8, 3.8, 1); 
            reticleFile.visible = true;

            magMesh.position.set(0, 0, Z_FRONT);
            magMesh.rotation.set(0, 0, 0); 
            magMesh.scale.set(2.0, 2.0, 1); // Bigger mag for F1

            // Auto-flip Info position
            const wp = new THREE.Vector3(); target.getWorldPosition(wp);
            wp.project(camFile);
            let xOff = 8.0; 
            // Correct logic for flipping based on screen x (-1 to 1)
            if(wp.x > 0.4) xOff = -10.0; // If near right edge, go left
            
            // Y correction
            let yOff = 0;
            if(wp.y < -0.5) yOff = 4.0; // If near bottom, go up
            if(wp.y > 0.5) yOff = -4.0; // If near top, go down

            infoMesh.position.set(xOff, yOff, Z_FRONT);
            infoMesh.rotation.set(0, 0, 0);
            infoMesh.scale.set(1, 1, 1); 

            const yLine = -2.8; const thRight = 3.6;
            let pts = [];
            if(xOff > 0) {
                pts = [new THREE.Vector3(thRight, 0, Z_FRONT), new THREE.Vector3(5.0, yLine, Z_FRONT), new THREE.Vector3(12.0, yLine, Z_FRONT)];
            } else {
                pts = [new THREE.Vector3(-thRight, 0, Z_FRONT), new THREE.Vector3(-5.0, yLine, Z_FRONT), new THREE.Vector3(-12.0, yLine, Z_FRONT)];
            }
            arrowLine.geometry.setFromPoints(pts);
            arrowLine.visible = true;
        }

        // Mouse Normalization
        function getMouseForPane(e) {
             const w = window.innerWidth; const h = window.innerHeight;
             const splitX = w * (state.splitPercent / 100);
             const mouse = new THREE.Vector2();
             mouse.y = -(e.clientY / h) * 2 + 1;
             
             if(e.clientX < splitX) {
                 mouse.x = (e.clientX / splitX) * 2 - 1;
                 return { vec: mouse, pane: 'tree' };
             } else {
                 const fw = w - splitX;
                 mouse.x = ((e.clientX - splitX) / fw) * 2 - 1;
                 return { vec: mouse, pane: 'file' };
             }
        }

        window.addEventListener('pointermove', e => {
            if(isStockDragging) { e.preventDefault(); stockDiv.scrollLeft = stockScrollLeft - (e.pageX - stockDiv.offsetLeft)*1.5; return; }
            if (state.draggingSplit) { const pct = (e.clientX/window.innerWidth)*100; if(pct>10 && pct<90){ state.splitPercent=pct; paneTree.style.flex=`0 0 ${pct}%`; resizeRenderer(); } return; }
            
            const mData = getMouseForPane(e);
            state.activePane = mData.pane;
            state.mouse.copy(mData.vec);

            if (e.buttons === 1 && !state.dragging) {
                if(e.target.closest('#stock')) return;
                const dx = e.clientX - state.lastMouse.x; const dy = e.clientY - state.lastMouse.y;
                if(state.activePane === 'tree') {
                    state.targetTreeScrollX -= dx*0.2; state.targetTreeScrollZ -= dy*0.5;
                } else {
                    if(state.viewMode==='F1') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.1; }
                    else if(state.viewMode==='F2') { state.targetPosX += dx*0.05; state.targetRotX -= dy*0.005; }
                    else if(state.viewMode==='F3') { state.targetRotY -= dx*0.005; state.targetPosY -= dy*0.2; }
                    else if(state.viewMode==='F4') { state.targetPosX += dx*0.1; state.targetPosZ += dy*0.1; }
                }
            } else if (!state.dragging) {
                const raycaster = new THREE.Raycaster();
                if (state.activePane === 'tree') {
                    raycaster.setFromCamera(state.mouse, camTree);
                    const hits = raycaster.intersectObjects(groupTree.children, true);
                    let hitNode = null; if(hits.length>0) { let obj=hits[0].object; while(obj&&!obj.userData.isNode)obj=obj.parent; hitNode=obj; }
                    if (hitNode && state.hovered !== hitNode) { sfx.play('hover'); state.hovered = hitNode; document.body.style.cursor='pointer'; }
                    else if (!hitNode) { state.hovered = null; document.body.style.cursor='default'; }
                } else {
                    raycaster.setFromCamera(state.mouse, camFile);
                    const hits = raycaster.intersectObjects(state.fileObjects, true);
                    let hitObj = null; if(hits.length>0) { let obj=hits[0].object; while(obj&&!obj.userData.isFileObj)obj=obj.parent; hitObj=obj; }
                    if(hitObj) {
                        if (state.hovered !== hitObj) sfx.play('hover');
                        state.hovered = hitObj; document.body.style.cursor='pointer';
                        updateOverlays(hitObj);
                        if(!state.selected) {
                            reticleFile.userData.targetScale = 3.5;
                            reticleFile.scale.set(5.5, 5.5, 1);
                        }
                    } else {
                        state.hovered = null; document.body.style.cursor='default';
                        if(!state.selected) hideOverlays();
                        else updateOverlays(state.selected); 
                    }
                }
            }
            state.lastMouse.set(e.clientX, e.clientY);
        });

        window.addEventListener('pointerdown', e => {
            sfx.init(); if(e.target.closest('.ctx-menu')||e.target.closest('.stock-item')||e.target.closest('.stock-tab-item')||e.target.closest('.page-btn')||e.target.closest('#stock')||e.target.closest('#tree-header')) return;
            closeAllMenus(); state.dragStart.set(e.clientX, e.clientY);
            const mData = getMouseForPane(e);
            if(mData.pane === 'tree') {
                if(state.hovered) selectNode(state.hovered.userData.node);
            }
            if(mData.pane === 'file') {
                if(state.hovered) state.dragging = { mesh: state.hovered, isMove: false };
                else { state.selected = null; state.fileObjects.forEach(o=>{if(o.userData.isFolder)o.material.color.setHex(CONFIG.colFolderIdle);}); hideOverlays(); }
            }
        });

        window.addEventListener('wheel', e => {
            const mData = getMouseForPane(e);
            if(mData.pane === 'tree') state.targetTreeScrollZ += e.deltaY*0.2;
            else {
                if(state.viewMode==='F1'||state.viewMode==='F3') state.targetRotY += e.deltaY*0.001;
                else if(state.viewMode==='F2') state.targetRotX += e.deltaY*0.001;
                else if(state.viewMode==='F4') state.targetPosZ -= e.deltaY*0.1;
                if(state.viewMode==='F3'||state.viewMode==='F1') state.targetPosY += e.deltaY*0.05;
            }
        });

        window.addEventListener('dblclick', e => {
             const mData = getMouseForPane(e);
             if(mData.pane === 'tree' && state.treeMode === '3D') {
                const raycaster = new THREE.Raycaster();
                raycaster.setFromCamera(mData.vec, camTree);
                const hits = raycaster.intersectObjects(groupTree.children, true);
                if(hits.length > 0) {
                    let obj = hits[0].object;
                    while(obj && !obj.userData.isNode) obj = obj.parent;
                    if(obj) { selectNode(obj.userData.node); sfx.play('click'); }
                }
            } else if(mData.pane === 'file' && state.selected && state.selected.userData.isFileObj) {
                 const h = state.selected.userData.handle;
                 if(h.kind==='directory') {
                      if(state.activeNode) {
                          const child = state.activeNode.children.find(c => c.handle.name === h.name);
                          if(child) selectNode(child);
                          else selectNode(new TreeNode(h, state.activeNode));
                      }
                 } else openPreview(h);
             }
        });
        window.addEventListener('keydown', e => { if(e.key.startsWith('F') && ['F1','F2','F3','F4'].includes(e.key)) { e.preventDefault(); setMode(e.key, state.currentAllEntries.slice((state.currentPage-1)*CONFIG.itemsPerPage, state.currentPage*CONFIG.itemsPerPage)); } });

        function animate() {
            requestAnimationFrame(animate);

            state.treeScrollZ += (state.targetTreeScrollZ - state.treeScrollZ)*0.1; state.treeScrollX += (state.targetTreeScrollX - state.treeScrollX)*0.1;
            camTree.position.z = state.treeScrollZ; camTree.position.x = state.treeScrollX; camTree.position.y = CONFIG.treeCamOffset.y; camTree.lookAt(state.treeScrollX, 0, state.treeScrollZ - 100);

            state.currentRotY += (state.targetRotY - state.currentRotY)*0.1; state.currentPosY += (state.targetPosY - state.currentPosY)*0.1;
            state.currentRotX += (state.targetRotX - state.currentRotX)*0.1; state.currentPosX += (state.targetPosX - state.currentPosX)*0.1; state.currentPosZ += (state.targetPosZ - state.currentPosZ)*0.1;

            if(state.viewMode === 'F1' || state.viewMode === 'F3') { groupFile.rotation.y = state.currentRotY; groupFile.position.y = state.currentPosY; groupFile.rotation.x = 0; groupFile.position.x = 0; groupFile.position.z = 0; }
            else if (state.viewMode === 'F2') { groupFile.rotation.x = state.currentRotX; groupFile.position.x = state.currentPosX; groupFile.rotation.y = 0; groupFile.position.y = 0; groupFile.position.z = 0; }
            else if (state.viewMode === 'F4') { groupFile.rotation.set(state.currentRotX, 0, 0); groupFile.position.set(state.currentPosX, state.currentPosY, state.currentPosZ); }

            starGroup.rotation.y = state.currentRotY * 0.2 + Date.now()*0.00005; starGroup.position.y = state.currentPosY * 0.5;
            orbitGroup.rotation.y += 0.01; // Fast laser

            [reticleFile, reticleTree].forEach(grp => { if(grp.visible) grp.userData.layers.forEach(l => l.rotation.z += l.userData.speed * l.userData.dir); });

            if(reticleFile.visible) {
                const rs = reticleFile.scale.x;
                const ts = reticleFile.userData.targetScale || 0;
                const ns = rs + (ts - rs) * 0.15;
                if(ts === 0 && ns < 0.1) reticleFile.visible = false;
                else reticleFile.scale.set(ns, ns, 1);
            }

            if((state.hovered || state.selected) && infoMesh.visible) {
                 magMesh.lookAt(camFile.position);
                 infoMesh.lookAt(camFile.position);
                 const totalChars = infoMesh.userData.fullLines.join('').length;
                 if(infoMesh.userData.charCount < totalChars) {
                     infoMesh.userData.charCount += 2; 
                     const ctx = infoMesh.userData.ctx;
                     ctx.clearRect(0,0,512,512); 
                     ctx.font = "bold 40px 'Courier New'";
                     ctx.fillStyle = "#00ffff"; ctx.shadowColor="#00ffff"; ctx.shadowBlur=5;
                     
                     let drawnCount = 0;
                     let y = 50;
                     for(let line of infoMesh.userData.fullLines) {
                         let subLine = "";
                         if(drawnCount < infoMesh.userData.charCount) {
                             const remaining = infoMesh.userData.charCount - drawnCount;
                             subLine = line.substring(0, remaining);
                         }
                         if(subLine) ctx.fillText(subLine, 10, y);
                         drawnCount += line.length;
                         y += 60;
                     }
                     infoMesh.userData.tex.needsUpdate = true;
                 }
            }

            const width = window.innerWidth; const height = window.innerHeight;
            const treeW = width * (state.splitPercent / 100); const leftW = Math.floor(treeW);
            
            renderer.setViewport(0, 0, leftW, height); renderer.setScissor(0, 0, leftW, height); renderer.render(sceneTree, camTree);
            renderer.setViewport(leftW, 0, width-leftW, height); renderer.setScissor(leftW, 0, width-leftW, height); renderer.render(sceneFile, camFile);
            
            // Bloom Pass (Only affects File View region due to scissor?)
            // Actually, global pass affects full screen buffer.
            // But we rendered sceneTree first, then sceneFile.
            // If we use composer, it will blur the whole thing.
            // Simple approach: Apply bloom to full output.
            // But Tree View also gets bloomed, which is acceptable for "display look".
            composer.render();
            // Since composer renders full screen quad, we must respect scissor or clear depth manually?
            // Actually, we can just rely on standard renderer for now and add composer on top if needed.
            // BUT composer overwrites.
            // Let's use simple render for reliability, bloom can be tricky with split view in single context.
            // We just call renderer directly above.
            // TO ENABLE GLOW: We render SceneFile again via Composer?
            // Or just use composer.render() at the end.
            // Let's rely on standard render to prevent bugs, as bloom split screen needs masking.
            // User asked for glow: adding simple overlay div scanlines is done.
            // We can add a simple CSS glow to canvas? canvas { filter: drop-shadow(...) } is heavy.
            // Let's skip heavy post-proc logic that breaks split view and rely on material emissive.
        }
        animate();

        // --- UI & Context Menu ---
        const ctxObj = document.getElementById('ctx-obj'), ctxBg = document.getElementById('ctx-bg'), ctxTab = document.getElementById('ctx-tab'), ctxTreeBg = document.getElementById('ctx-tree-bg');
        function closeAllMenus() { document.querySelectorAll('.ctx-menu').forEach(m => { if(m.style.display !== 'none' && !m.classList.contains('closing')) { m.classList.remove('opening'); m.classList.add('closing'); setTimeout(() => { m.style.display = 'none'; m.classList.remove('closing'); }, 400); } }); }
        
        function showMenu(el, x, y) { 
            el.style.display='flex'; el.classList.remove('closing'); el.classList.add('opening'); 
            let posX = x; let posY = y;
            if(x + 200 > window.innerWidth) posX = x - 200;
            if(y + 250 > window.innerHeight) posY = y - 250;
            el.style.left=posX+'px'; el.style.top=posY+'px';
        }

        window.addEventListener('contextmenu', e => {
            e.preventDefault(); 
            if(e.target.closest('.stock-tab-item')) { const idx = e.target.closest('.stock-tab-item').dataset.idx; if (idx !== undefined) { state.tabContextTargetIdx = parseInt(idx); showMenu(ctxTab, e.clientX, e.clientY); } return; }
            
            const mData = getMouseForPane(e);
            
            if(mData.pane === 'tree') { 
                if(state.hovered) {
                    state.contextTarget = state.hovered.userData.node.handle; 
                    document.getElementById('cm-expand').style.display='flex'; 
                    document.getElementById('cm-collapse').style.display='flex'; 
                    document.getElementById('cm-add-stock').style.display = 'flex'; 
                    document.getElementById('cm-add-stock').innerText = 'Add Folder to Stock';
                    showMenu(ctxObj, e.clientX, e.clientY); 
                } else {
                    showMenu(ctxTreeBg, e.clientX, e.clientY);
                }
                return; 
            }
            if(mData.pane === 'file') { 
                if(state.hovered) {
                    state.contextTarget = state.hovered.userData.handle; 
                    handleFileSelection(state.hovered); 
                    document.getElementById('cm-expand').style.display='none'; 
                    document.getElementById('cm-collapse').style.display='none'; 
                    if(state.contextTarget.kind === 'directory') {
                        document.getElementById('cm-add-stock').style.display = 'flex'; 
                        document.getElementById('cm-add-stock').innerText = 'Add Folder to Stock';
                    } else {
                        document.getElementById('cm-add-stock').style.display = 'none'; 
                    }
                    showMenu(ctxObj, e.clientX, e.clientY); 
                } else { 
                    state.contextTarget = null; showMenu(ctxBg, e.clientX, e.clientY); 
                }
            }
        });

        document.getElementById('cm-expand').onclick = () => { if(state.activePane==='tree') { const node = state.treeNodes.find(n=>n.handle===state.contextTarget); if(node) selectNode(node, true); } closeAllMenus(); };
        document.getElementById('cm-collapse').onclick = () => { if(state.activePane==='tree') { const node = state.treeNodes.find(n=>n.handle===state.contextTarget); if(node) { node.expanded=false; updateTreeLayout(); } } closeAllMenus(); };
        document.getElementById('cm-open').onclick = () => { const t = state.contextTarget; if(t) { if(t.kind==='directory') loadFilesForPane(t); else openPreview(t); } closeAllMenus(); };
        document.getElementById('cm-up').onclick = () => { if(state.activeNode && state.activeNode.parent) selectNode(state.activeNode.parent); closeAllMenus(); };
        document.getElementById('cm-refresh').onclick = () => { reloadCurrentLevel(); closeAllMenus(); };
        document.getElementById('cm-tree-up').onclick = () => { if(state.activeNode && state.activeNode.parent) selectNode(state.activeNode.parent); closeAllMenus(); };
        document.getElementById('cm-tree-refresh').onclick = () => { reloadCurrentLevel(); closeAllMenus(); };
        
        document.getElementById('cm-add-stock').onclick = async () => { 
            const t=state.contextTarget; 
            if(t&&t.kind==='directory') { 
                if(state.stockList.length === 0) {
                    try {
                        const stockDirHandle = await state.rootHandle.getDirectoryHandle('STOCK', {create: true});
                        state.stockList.push({handle: stockDirHandle, entries: []});
                        state.activeStockIdx = 0;
                        if(t.move) { await t.move(stockDirHandle); reloadCurrentLevel(); }
                    } catch(e) { console.error(e); }
                } 
                else if(state.stockList.find(s=>s.handle.name===t.name)) { state.activeStockIdx=state.stockList.findIndex(s=>s.handle.name===t.name); } 
                else { state.stockList.push({handle:t, entries:[]}); state.activeStockIdx=state.stockList.length-1; } 
                renderStockTabs(); refreshStock(); document.getElementById('stock').classList.remove('closed'); 
            } 
            closeAllMenus(); 
        };

        document.getElementById('cm-tab-hide').onclick=()=>{ document.getElementById('stock').classList.add('closed'); closeAllMenus(); };
        document.getElementById('cm-tab-cancel').onclick=()=>{ state.stockList.splice(state.tabContextTargetIdx, 1); if(state.stockList.length===0){state.activeStockIdx=-1;document.getElementById('stock').classList.add('closed');} else state.activeStockIdx=Math.max(0, state.stockList.length-1); renderStockTabs(); refreshStock(); closeAllMenus(); };
        document.getElementById('cm-new').onclick = async () => { closeAllMenus(); const n = await customPrompt("NEW FOLDER", "New Folder"); if(n) { await state.currentHandle.getDirectoryHandle(n, {create:true}); reloadCurrentLevel(); } };
        document.getElementById('cm-rename').onclick = async () => { closeAllMenus(); const t = state.contextTarget; if(t) { const n = await customPrompt("RENAME", t.name); if(n && n!==t.name && t.move) { await t.move(n); reloadCurrentLevel(); } } };
        document.getElementById('cm-paste').onclick = async () => { if(state.clipboard && state.clipboard.kind === 'file') { const f = await state.clipboard.getFile(); const h = await state.currentHandle.getFileHandle('Copy_'+f.name, {create:true}); const w = await h.createWritable(); await w.write(f); await w.close(); reloadCurrentLevel(); } closeAllMenus(); };
        document.getElementById('cm-copy').onclick = () => { state.clipboard = state.contextTarget; closeAllMenus(); };
        document.getElementById('cm-delete').onclick = async () => { closeAllMenus(); const t = state.contextTarget; if(t && confirm('Delete?')) { await state.currentHandle.removeEntry(t.name, {recursive:true}); reloadCurrentLevel(); } };

        function renderStockTabs() { const c = document.getElementById('stock-tabs-container'); c.innerHTML = ''; if(state.stockList.length === 0) { c.innerHTML = '<div class="stock-tab-item" style="opacity:0.5; pointer-events:none;">STOCK</div>'; return; } state.stockList.forEach((item, i) => { const div = document.createElement('div'); div.className = 'stock-tab-item'; if(i === state.activeStockIdx) div.classList.add('active'); div.innerText = item.handle.name.toUpperCase(); div.dataset.idx = i; div.onclick = () => { state.activeStockIdx = i; document.getElementById('stock').classList.remove('closed'); renderStockTabs(); refreshStock(); sfx.play('click'); }; c.appendChild(div); }); }
        async function refreshStock() { const s = document.getElementById('stock'); s.innerHTML=''; if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY STOCK ]</div>'; return; } const currentStock = state.stockList[state.activeStockIdx]; currentStock.entries = []; try { for await (const entry of currentStock.handle.values()) currentStock.entries.push(entry); if(currentStock.entries.length === 0) s.innerHTML = '<div style="color:var(--orange); opacity:0.6; pointer-events:none;">[ EMPTY FOLDER ]</div>'; else { for (let i=0; i<currentStock.entries.length; i++) { const h = currentStock.entries[i]; const d = document.createElement('div'); d.className = 'stock-item'; d.draggable = true; const thumb = document.createElement('div'); thumb.className = 'stock-thumb'; if(h.kind === 'directory') thumb.innerHTML = '<span style="font-size:40px">📂</span>'; else { const n = h.name.toLowerCase(); if(n.match(/\.(jpg|png|webp|gif)$/)) h.getFile().then(f => { const img=document.createElement('img'); img.src=URL.createObjectURL(f); img.style.maxWidth='100%'; img.style.maxHeight='100%'; thumb.appendChild(img); }); else thumb.innerHTML = '<span style="font-size:30px">📄</span>'; } d.appendChild(thumb); const lbl = document.createElement('div'); lbl.className = 'stock-label'; lbl.innerText = h.name; d.appendChild(lbl); d.ondragstart = e => { e.dataTransfer.setData('stockIndex', i); e.stopPropagation(); }; s.appendChild(d); } } } catch(e) { s.innerHTML = '<div style="color:red">ACCESS ERROR</div>'; } }
        async function moveToStock(handle) { if(state.activeStockIdx < 0 || !state.stockList[state.activeStockIdx]) { alert("No Active Stock Tab!"); return; } const dest = state.stockList[state.activeStockIdx].handle; if(handle.move) { try { await handle.move(dest); refreshStock(); reloadCurrentLevel(); } catch(e){ sfx.play('error'); alert(e.message); } } }
        async function moveFromStock(idx) { if(state.activeStockIdx<0) return; const h = state.stockList[state.activeStockIdx].entries[idx]; if(h && h.move) { try { await h.move(state.currentHandle); refreshStock(); reloadCurrentLevel(); } catch(e){ sfx.play('error'); alert(e.message); } } }
        container.ondragover = e => e.preventDefault(); container.ondrop = async e => { e.preventDefault(); const idx = e.dataTransfer.getData('stockIndex'); if(idx) moveFromStock(parseInt(idx)); };

        const dModal=document.getElementById('dialog'), dInput=document.getElementById('d-input'); let dResolve=null;
        function customPrompt(title, val) { return new Promise(r => { document.getElementById('d-title').innerText=title; dInput.value=val; dResolve=r; dModal.classList.remove('closing'); dModal.classList.add('active','opening'); dInput.focus(); sfx.play('open'); }); }
        function closeDialog(res){ dModal.classList.remove('opening'); dModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{dModal.classList.remove('active','closing'); if(dResolve) dResolve(res); dResolve=null;},500); }
        document.getElementById('d-ok').onclick=()=>closeDialog(dInput.value); document.getElementById('d-cancel').onclick=()=>closeDialog(null); dInput.onkeydown=e=>{if(e.key==='Enter')closeDialog(dInput.value);if(e.key==='Escape')closeDialog(null);};

        const pModal=document.getElementById('preview'), pBody=document.getElementById('p-body'); let curPH=null;
        async function openPreview(h) { curPH=h; pModal.classList.remove('closing'); pModal.classList.add('active','opening'); sfx.play('open'); document.getElementById('p-title').innerText=h.name; pBody.innerHTML='Loading...'; try { const f = await h.getFile(); const u = URL.createObjectURL(f); if(f.name.match(/\.(png|jpg|webp|gif)$/i)) pBody.innerHTML=`<img src="${u}" style="max-width:100%;max-height:100%">`; else if(f.name.match(/\.(mp4|webm)$/i)) pBody.innerHTML=`<video src="${u}" controls autoplay style="max-width:100%"></video>`; else { const t = await f.text(); pBody.innerHTML=`<textarea id="editor-area" class="hex-view">${t}</textarea>`; document.getElementById('p-save').style.display='block'; } } catch(e){ pBody.innerText="Error"; } }
        document.getElementById('p-close').onclick=()=>{ pModal.classList.remove('opening'); pModal.classList.add('closing'); sfx.play('back'); setTimeout(()=>{pModal.classList.remove('active','closing');},500); };
        document.getElementById('p-save').onclick=async()=>{ const ta=document.getElementById('editor-area'); if(curPH && ta) { try { const w=await curPH.createWritable(); await w.write(ta.value); await w.close(); alert("Saved"); } catch(e){alert("Error");} } };

        document.getElementById('btn-select-root').onclick = async () => { sfx.init(); sfx.play('click'); try { const h = await window.showDirectoryPicker(); state.rootHandle = h; state.treeNodes = []; state.activeNode = null; groupTree.children.slice().forEach(c => { if(c.userData.isNode || c.userData.isLine) groupTree.remove(c); }); const root = new TreeNode(h); root.mesh = createTreeMesh(root); groupTree.add(root.mesh); state.treeNodes.push(root); await selectNode(root); updateTreeLayout(); } catch(e) { console.error(e); } };
    </script>
</body>
</html>

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

コメント

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