3Dマップエディタ



モデルエディタの編集画面

[ 操作説明 ]

・上キーを押すと、上の段に移動する。
・下キーを押すと、下の段に移動する。

・x,y,zキーのいずれかを押しながらマウスドラッグで操作をすると、
 移動方向がx,y,zのいずれかに制約できる。

・cキーを押すと、選択中のボクセルをコピーする。
・vキーを押すと、ポイント・カーソル(白色)の位置に
 コピーしたボクセルをペーストする。
・deleteキーを押すと、選択中のボクセルを削除する。

・pageupキーを押すと、ズームイン。
・pagedownキーを押すと、ズームアウト。

コンテナ・エディタの画面

[ 操作説明 ]

・モデルエディタと同じ。

マップエディタの編集画面


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

・モデル・エディタのソースコードはこちら。↓

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Voxel Editor Extended</title>
    <style>
        body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
        
        /* Menu Bar */
        #menubar {
            height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e;
        }
        .menu-item {
            padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc;
        }
        .menu-item:hover { background: #3e3e3e; color: #fff; }
        .dropdown {
            display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);
        }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item {
            padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px;
        }
        .dropdown-item:hover { background: #094771; color: #fff; }
        .separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }

        /* Layout */
        #container { display: flex; height: calc(100vh - 30px); }
        #canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #1e1e1e; }
        
        /* Right Panel */
        #properties-panel {
            width: 260px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto;
        }
        .prop-group { margin-bottom: 20px; }
        .prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
        input[type="text"], input[type="number"], input[type="color"] {
            width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 8px; font-size: 12px; border-radius: 2px;
            text-align: right; 
        }
        input:focus { border-color: #007fd4; outline: none; }
        
        /* Status / UI Overlays */
        #status-bar {
            position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
        }
        #info-overlay {
            position: absolute; top: 10px; left: 10px; pointer-events: none;
        }
        #mode-display {
            background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block;
        }
        #height-display {
            background: rgba(0,0,0,0.5); 
            color: #4fc1ff; /* Changed to Light Blue (Cyan) */
            padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table; 
        }
        
        /* Arrow Labels & Axis Labels */
        .arrow-label {
            position: absolute; color: #aaa; font-family: sans-serif; font-size: 12px; pointer-events: none; font-weight: bold; background: rgba(0,0,0,0.5); padding: 2px 4px; border-radius: 3px;
        }
        .axis-label {
            position: absolute; font-family: 'Consolas', monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff;
        }

        /* Context Menu */
        #context-menu {
            display: none; position: absolute; background: #252526; border: 1px solid #454545; z-index: 2000; box-shadow: 2px 4px 10px rgba(0,0,0,0.5); min-width: 120px;
        }
        #context-menu div { padding: 8px 15px; cursor: pointer; color: #eee; font-size: 13px; }
        #context-menu div:hover { background: #094771; }

        /* Dialog */
        #modal-overlay {
            display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 3000; justify-content: center; align-items: center;
        }
        #settings-dialog { background: #252526; padding: 20px; border: 1px solid #454545; width: 300px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
        #settings-dialog h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; }
        .dialog-btn { margin-top: 15px; padding: 6px 20px; cursor: pointer; background: #0e639c; color: white; border: none; font-size: 13px; border-radius: 2px; }
        .dialog-btn:hover { background: #1177bb; }
        .dialog-btn.cancel { background: #3e3e3e; margin-left: 10px; }
        .dialog-btn.cancel:hover { background: #4e4e4e; }

    </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="menubar">
        <div class="menu-item">File
            <div class="dropdown">
                <div class="dropdown-item" id="menu-new">New</div>
                <div class="dropdown-item" id="menu-open">Open</div>
                <div class="dropdown-item" id="menu-save">Save</div>
                <div class="dropdown-item" id="menu-saveas">Save As</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="menu-export-obj">Export OBJ</div>
                <div class="dropdown-item" id="menu-export-glb">Export GLB</div>
            </div>
        </div>
        <div class="menu-item">Edit
            <div class="dropdown">
                <div class="dropdown-item" id="tool-select">Select</div>
                <div class="dropdown-item" id="tool-add">Add</div>
                <div class="dropdown-item" id="tool-paint">Paint</div>
                <div class="dropdown-item" id="tool-erase">Erase</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-move">Move</div>
                <div class="dropdown-item" id="tool-rotate">Rotate</div>
                <div class="dropdown-item" id="tool-scale">Scaling</div>
                <div class="separator"></div>
                <div class="dropdown-item" id="tool-cam-move">Camera Move</div>
                <div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
                <div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
                <div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
            </div>
        </div>
        <div class="menu-item">View
            <div class="dropdown">
                <div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
                <div class="separator"></div>
                <div class="dropdown-item" onclick="viewCam('top')">Top</div>
                <div class="dropdown-item" onclick="viewCam('bottom')">Bottom</div>
                <div class="separator"></div>
                <div class="dropdown-item" onclick="viewCam('front')">Front</div>
                <div class="dropdown-item" onclick="viewCam('back')">Back</div>
                <div class="dropdown-item" onclick="viewCam('left')">Left</div>
                <div class="dropdown-item" onclick="viewCam('right')">Right</div>
            </div>
        </div>
        <div class="menu-item" id="menu-setting">Setting</div>
    </div>

    <div id="container">
        <div id="canvas-container" tabindex="0">
            <div id="info-overlay">
                <div id="mode-display">Select</div>
                <div id="height-display">Height: 0 Y</div>
            </div>
            <div id="status-bar">Ready</div>
            <div id="labels-container"></div>
        </div>
        
        <div id="properties-panel">
            <h3 id="prop-header">Properties</h3>
            <div id="prop-content" style="display:none;">
                <div class="prop-group">
                    <span class="prop-label">Name</span>
                    <input type="text" id="prop-name" style="text-align: left;">
                </div>
                <div id="prop-transforms">
                    <div class="prop-group">
                        <span class="prop-label">Position (X, Y, Z)</span>
                        <div style="display:flex; gap:5px;">
                            <input type="number" id="prop-x" step="1">
                            <input type="number" id="prop-y" step="1">
                            <input type="number" id="prop-z" step="1">
                        </div>
                    </div>
                    <div class="prop-group">
                        <span class="prop-label">Rotation (Deg)</span>
                        <div style="display:flex; gap:5px;">
                            <input type="number" id="prop-rx" step="45">
                            <input type="number" id="prop-ry" step="45">
                            <input type="number" id="prop-rz" step="45">
                        </div>
                    </div>
                    <div class="prop-group">
                        <span class="prop-label">Scale</span>
                        <div style="display:flex; gap:5px;">
                            <input type="number" id="prop-sx" step="1">
                            <input type="number" id="prop-sy" step="1">
                            <input type="number" id="prop-sz" step="1">
                        </div>
                    </div>
                </div>

                <div class="prop-group">
                    <span class="prop-label">Color</span>
                    <input type="color" id="prop-color">
                </div>
                <div class="prop-group">
                    <span class="prop-label">Texture</span>
                    <input type="file" id="prop-tex-file" accept="image/*" style="width:100%; font-size:11px; color:#aaa;">
                    <button id="btn-clear-tex" style="width:100%; margin-top:5px; padding:4px; background:#333; color:#eee; border:1px solid #444; cursor:pointer;">Clear Texture</button>
                    <img id="prop-tex-preview" style="width: 100%; margin-top: 5px; border: 1px solid #444; display: none;">
                </div>
            </div>
            <div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">
                No voxel selected.
            </div>
        </div>
    </div>

    <div id="context-menu">
        <div id="ctx-copy">Copy</div>
        <div id="ctx-paste">Paste</div>
        <div id="ctx-delete">Delete</div>
    </div>

    <div id="modal-overlay">
        <div id="settings-dialog">
            <h3>Settings</h3>
            <div class="prop-group">
                <span class="prop-label">Voxel Size</span>
                <input type="number" id="set-voxel-size" value="1" step="0.1">
            </div>
            <div class="prop-group">
                <span class="prop-label">Grid Size (X, Y, Z)</span>
                <div style="display:flex; gap:5px;">
                    <input type="number" id="set-grid-x" value="20">
                    <input type="number" id="set-grid-y" value="20">
                    <input type="number" id="set-grid-z" value="20">
                </div>
            </div>
            <div style="text-align:right;">
                <button class="dialog-btn cancel" id="btn-setting-cancel">Cancel</button>
                <button class="dialog-btn" id="btn-setting-ok">Apply</button>
            </div>
        </div>
    </div>

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

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

        // --- State Variables ---
        let scene, camera, renderer, controls;
        let voxels = [];
        let raycaster, mouse;
        
        let currentTool = 'select'; 
        
        // Brush State
        let currentBrush = {
            color: '#4fc1ff',
            textureSrc: null
        };

        // Laps (Quaternion Slerp Variables)
        let isLapping = false;
        let lapsQ = new THREE.Quaternion();       // Current Rotation on Sphere
        let lapsTargetQ = new THREE.Quaternion(); // Target Rotation
        let lapsRadius = 30;

        let currentSideViewIndex = 0; 
        let isTopView = true; 

        // Grid & Dimensions
        let gridSize = { x: 20, y: 20, z: 20 };
        let voxelSize = 1;
        
        // Visual Helpers
        let gridGroup = new THREE.Group(); 
        let guideLineGroup = new THREE.Group(); 
        let axisGuideGroup = new THREE.Group(); 
        let labelsContainer;

        // Cursors
        let cursorTarget; // Red
        let cursorPoint;  // White
        let cursorSelect; // Yellow
        let shadowPool = [];

        let targetPos = new THREE.Vector3(0, 0, 0); 
        let pointPos = new THREE.Vector3(0, 0, 0);  
        let selectedVoxel = null;
        let clipboard = null;

        // Interaction State
        let isDragging = false;
        let dragStartMouse = new THREE.Vector2();
        let dragStartVal = new THREE.Vector3(); 
        let startCursorPos = new THREE.Vector3(); 
        
        // Camera Constraint State (OrbitControls)
        let dragStartCamPos = new THREE.Vector3();
        let dragStartCamTarget = new THREE.Vector3();
        let dragStartPolar = 0;
        let dragStartAzimuth = 0;

        let planeGround; 

        // Axis Lock State
        let axisLock = { x: false, y: false, z: false };
        let axisArrows = { x: null, y: null, z: null };

        let walls = {}; 

        // --- Init ---
        function init() {
            const container = document.getElementById('canvas-container');
            labelsContainer = document.getElementById('labels-container');

            scene = new THREE.Scene();
            scene.background = new THREE.Color(0x1e1e1e);

            camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000);
            camera.position.set(25, 25, 25);
            camera.lookAt(0, 0, 0);

            renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
            renderer.setSize(container.clientWidth, container.clientHeight);
            renderer.setPixelRatio(window.devicePixelRatio);
            container.appendChild(renderer.domElement);

            const ambient = new THREE.AmbientLight(0xffffff, 0.6);
            scene.add(ambient);
            const sun = new THREE.DirectionalLight(0xffffff, 0.8);
            sun.position.set(10, 30, 20);
            scene.add(sun);

            scene.add(gridGroup);
            scene.add(guideLineGroup);
            scene.add(axisGuideGroup);

            initCursors();
            initShadowPool();
            initAxisGuides();
            updateEnvironment(); 

            const pgGeo = new THREE.PlaneGeometry(2000, 2000);
            const pgMat = new THREE.MeshBasicMaterial({ visible: false });
            planeGround = new THREE.Mesh(pgGeo, pgMat);
            planeGround.rotation.x = -Math.PI / 2;
            scene.add(planeGround);

            controls = new OrbitControls(camera, renderer.domElement);
            controls.enableDamping = true;
            controls.dampingFactor = 0.1;
            controls.enabled = true; 

            raycaster = new THREE.Raycaster();
            mouse = new THREE.Vector2();

            window.addEventListener('resize', onResize);
            window.addEventListener('keydown', onKeyDown);
            window.addEventListener('keyup', onKeyUp);
            
            const cvs = renderer.domElement;
            cvs.addEventListener('pointermove', onPointerMove);
            cvs.addEventListener('pointerdown', onPointerDown);
            cvs.addEventListener('pointerup', onPointerUp);
            cvs.addEventListener('contextmenu', (e) => { e.preventDefault(); });

            setupUI();
            setTool('select'); 
            animate();
        }

        function initCursors() {
            const tGeo = new THREE.PlaneGeometry(1, 1);
            const tMat = new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 });
            cursorTarget = new THREE.Mesh(tGeo, tMat);
            cursorTarget.rotation.x = -Math.PI / 2;
            scene.add(cursorTarget);

            const pGeo = new THREE.BoxGeometry(1, 1, 1);
            const pEdges = new THREE.EdgesGeometry(pGeo);
            cursorPoint = new THREE.LineSegments(pEdges, new THREE.LineBasicMaterial({ color: 0xffffff, opacity: 0.8, transparent: true }));
            scene.add(cursorPoint);

            const sGeo = new THREE.BoxGeometry(1, 1, 1);
            const sEdges = new THREE.EdgesGeometry(sGeo);
            cursorSelect = new THREE.LineSegments(sEdges, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false }));
            cursorSelect.visible = false;
            scene.add(cursorSelect);
        }

        function initAxisGuides() {
            const origin = new THREE.Vector3(0,0,0);
            const len = 5;
            axisArrows.x = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), origin, len, 0xff0000, 1, 0.5);
            axisArrows.y = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), origin, len, 0x00ff00, 1, 0.5);
            axisArrows.z = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), origin, len, 0x0088ff, 1, 0.5);
            axisGuideGroup.add(axisArrows.x);
            axisGuideGroup.add(axisArrows.y);
            axisGuideGroup.add(axisArrows.z);
            axisGuideGroup.visible = false;
        }

        function initShadowPool() {
            const makeShadow = (color) => {
                const geo = new THREE.PlaneGeometry(1, 1);
                const edges = new THREE.EdgesGeometry(geo);
                return new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: color }));
            };
            for(let i=0; i<6; i++) shadowPool.push(makeShadow(0xffffff)); 
            shadowPool.forEach(m => guideLineGroup.add(m));
        }

        function updateEnvironment() {
            while(gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]);
            labelsContainer.innerHTML = ''; 

            const sizeX = gridSize.x * voxelSize;
            const sizeZ = gridSize.z * voxelSize;
            const halfX = sizeX / 2;
            const halfZ = sizeZ / 2;

            const floorGrid = new THREE.GridHelper(Math.max(sizeX, sizeZ), Math.max(gridSize.x, gridSize.z), 0x555555, 0x2a2a2a);
            gridGroup.add(floorGrid);

            walls.zNeg = new THREE.GridHelper(sizeX, gridSize.x, 0x444444, 0x222222);
            walls.zNeg.rotation.x = -Math.PI / 2; 
            walls.zNeg.position.set(0, sizeX/2, -halfZ);
            
            walls.zPos = new THREE.GridHelper(sizeX, gridSize.x, 0x444444, 0x222222);
            walls.zPos.rotation.x = -Math.PI / 2;
            walls.zPos.position.set(0, sizeX/2, halfZ);

            walls.xNeg = new THREE.GridHelper(sizeZ, gridSize.z, 0x444444, 0x222222);
            walls.xNeg.rotation.z = -Math.PI / 2; 
            walls.xNeg.position.set(-halfX, sizeZ/2, 0);

            walls.xPos = new THREE.GridHelper(sizeZ, gridSize.z, 0x444444, 0x222222);
            walls.xPos.rotation.z = -Math.PI / 2; 
            walls.xPos.position.set(halfX, sizeZ/2, 0);

            gridGroup.add(walls.zNeg);
            gridGroup.add(walls.zPos);
            gridGroup.add(walls.xNeg);
            gridGroup.add(walls.xPos);

            addArrowLabel(new THREE.Vector3(0, 0, halfZ + 2), "Z+", "#0088ff");
            addArrowLabel(new THREE.Vector3(0, 0, -halfZ - 2), "Z-", "#0088ff");
            addArrowLabel(new THREE.Vector3(halfX + 2, 0, 0), "X+", "#ff4444");
            addArrowLabel(new THREE.Vector3(-halfX - 2, 0, 0), "X-", "#ff4444");
            addArrowLabel(new THREE.Vector3(0, sizeX + 1, -halfZ), "Y+", "#44ff44");

            if(cursorTarget) cursorTarget.scale.set(voxelSize, voxelSize, voxelSize);
            if(cursorPoint) cursorPoint.scale.set(voxelSize, voxelSize, voxelSize);
        }

        function addArrowLabel(pos, text, color) {
            const div = document.createElement('div');
            div.className = 'arrow-label';
            div.textContent = text;
            div.style.color = color;
            div.dataset.pos = JSON.stringify(pos); 
            div.dataset.type = 'static';
            labelsContainer.appendChild(div);
        }

        function addAxisLabel(pos, text, color) {
            const div = document.createElement('div');
            div.className = 'axis-label';
            div.textContent = text;
            div.style.backgroundColor = color;
            div.dataset.pos = JSON.stringify(pos);
            div.dataset.type = 'dynamic'; 
            labelsContainer.appendChild(div);
        }

        function animate() {
            requestAnimationFrame(animate);
            
            // Camera Laps (Slerp Logic)
            if(isLapping) {
                // Slerp towards target
                lapsQ.slerp(lapsTargetQ, 0.1);
                
                // Apply rotation to radius vector (0,0,radius)
                const v = new THREE.Vector3(0, 0, lapsRadius);
                v.applyQuaternion(lapsQ);
                
                camera.position.copy(v);
                camera.lookAt(0,0,0);
                controls.update(); 
            } else {
                
                // Enforce Camera Constraints for OrbitControls
                if(currentTool === 'cam-rotate') {
                    controls.minPolarAngle = 0; 
                    controls.maxPolarAngle = Math.PI;
                    controls.minAzimuthAngle = -Infinity; 
                    controls.maxAzimuthAngle = Infinity;

                    if(isDragging) {
                        if(axisLock.x) { 
                            // X Key -> Horizontal Move -> Lock Polar (Vertical Angle)
                            controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar;
                        }
                        else if(axisLock.y || axisLock.z) { 
                            // Y Key -> Vertical Move -> Lock Azimuth (Horizontal Angle)
                            controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth;
                        }
                    }
                }
                else if(currentTool === 'cam-move' && isDragging) {
                    if(axisLock.x) {
                        camera.position.y = dragStartCamPos.y;
                        camera.position.z = dragStartCamPos.z;
                        controls.target.y = dragStartCamTarget.y;
                        controls.target.z = dragStartCamTarget.z;
                    }
                    if(axisLock.y) {
                        camera.position.x = dragStartCamPos.x;
                        camera.position.z = dragStartCamPos.z;
                        controls.target.x = dragStartCamTarget.x;
                        controls.target.z = dragStartCamTarget.z;
                    }
                    if(axisLock.z) {
                        camera.position.x = dragStartCamPos.x;
                        camera.position.y = dragStartCamPos.y;
                        controls.target.x = dragStartCamTarget.x;
                        controls.target.y = dragStartCamTarget.y;
                    }
                }
                
                controls.update();
            }

            if(cursorTarget) {
                const time = Date.now() * 0.005;
                cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(time * 8);
            }
            
            updateWallVisibility();
            updateGuides();
            updateLabels();
            updateAxisGuides();
            renderer.render(scene, camera);
        }

        function updateWallVisibility() {
            if (!walls.xNeg) return;
            walls.xNeg.visible = (camera.position.x > 0);
            walls.xPos.visible = (camera.position.x < 0);
            walls.zNeg.visible = (camera.position.z > 0);
            walls.zPos.visible = (camera.position.z < 0);
        }

        function updateGuides() {
            shadowPool.forEach(s => s.visible = false);

            const limX = (camera.position.x > 0) ? -(gridSize.x * voxelSize)/2 : (gridSize.x * voxelSize)/2;
            const limZ = (camera.position.z > 0) ? -(gridSize.z * voxelSize)/2 : (gridSize.z * voxelSize)/2;

            const placeShadow = (idxStart, pos3d, scale) => {
                const s1 = shadowPool[idxStart]; 
                const s2 = shadowPool[idxStart+1];
                s1.visible = true;
                s1.position.set(pos3d.x, pos3d.y, limZ);
                s1.scale.set(scale.x, scale.y, 1);
                s2.visible = true;
                s2.position.set(limX, pos3d.y, pos3d.z);
                s2.scale.set(1, scale.y, scale.z);
                s2.rotation.y = Math.PI / 2;
            };

            if(currentTool === 'add' && cursorTarget.visible) {
                placeShadow(0, cursorTarget.position, cursorTarget.scale);
            }
            if(cursorPoint.visible) {
                placeShadow(2, cursorPoint.position, cursorPoint.scale);
            }
            if(selectedVoxel && selectedVoxel.visible && cursorSelect.visible) {
                placeShadow(4, selectedVoxel.position, selectedVoxel.scale);
            }
        }

        function updateLabels() {
            const width = renderer.domElement.clientWidth;
            const height = renderer.domElement.clientHeight;

            const dynamics = Array.from(labelsContainer.querySelectorAll('[data-type="dynamic"]'));
            dynamics.forEach(el => el.remove());

            if(axisLock.x || axisLock.y || axisLock.z) {
                const active = getActiveCursorPos();
                if(active) {
                    if(axisLock.x) {
                        addAxisLabel(active.clone().add(new THREE.Vector3(3,0,0)), "+X", "#aa0000");
                        addAxisLabel(active.clone().add(new THREE.Vector3(-3,0,0)), "-X", "#aa0000");
                    }
                    if(axisLock.y) {
                        addAxisLabel(active.clone().add(new THREE.Vector3(0,3,0)), "+Y", "#00aa00");
                        addAxisLabel(active.clone().add(new THREE.Vector3(0,-3,0)), "-Y", "#00aa00");
                    }
                    if(axisLock.z) {
                        addAxisLabel(active.clone().add(new THREE.Vector3(0,0,3)), "+Z", "#0044aa");
                        addAxisLabel(active.clone().add(new THREE.Vector3(0,0,-3)), "-Z", "#0044aa");
                    }
                }
            }

            Array.from(labelsContainer.children).forEach(div => {
                if(!div.dataset.pos) return;
                const pos = JSON.parse(div.dataset.pos);
                const vec = new THREE.Vector3(pos.x, pos.y, pos.z);
                vec.project(camera);

                if (vec.z > 1) { 
                    div.style.display = 'none';
                } else {
                    div.style.display = 'block';
                    const x = (vec.x * 0.5 + 0.5) * width;
                    const y = -(vec.y * 0.5 - 0.5) * height;
                    div.style.left = x + 'px';
                    div.style.top = y + 'px';
                }
            });
        }

        function getActiveCursorPos() {
            if(currentTool === 'cam-rotate' || currentTool === 'cam-move' || currentTool === 'cam-zoom') return controls.target; 
            if(selectedVoxel && currentTool !== 'add' && currentTool !== 'paint') return selectedVoxel.position;
            if(currentTool === 'add') return cursorTarget.position;
            if(cursorPoint.visible) return cursorPoint.position;
            return null;
        }

        function updateAxisGuides() {
            const pos = getActiveCursorPos();
            if(!pos) {
                axisGuideGroup.visible = false;
                return;
            }
            axisGuideGroup.visible = true;
            axisGuideGroup.position.copy(pos);
            axisArrows.x.visible = axisLock.x;
            axisArrows.y.visible = axisLock.y;
            axisArrows.z.visible = axisLock.z;
        }

        function snapToGrid(val) {
            return Math.round(val / voxelSize) * voxelSize;
        }

        // --- Interaction Logic ---

        function onPointerMove(e) {
            const rect = renderer.domElement.getBoundingClientRect();
            mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
            mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;

            if (isDragging) {
                const deltaX = e.clientX - dragStartMouse.x;
                const deltaY = e.clientY - dragStartMouse.y;
                const isLocked = axisLock.x || axisLock.y || axisLock.z;
                
                // Camera Laps Drag Logic (Quaternion Slerp based)
                if(isLapping) {
                    const rotSpeed = 0.005;
                    const deltaQ = new THREE.Quaternion();
                    
                    if (!axisLock.y && !axisLock.z) { 
                        if (axisLock.x || (!axisLock.y && !axisLock.z)) {
                             // Horizontal rotation (Around World Y)
                             const qy = new THREE.Quaternion();
                             qy.setFromAxisAngle(new THREE.Vector3(0,1,0), -deltaX * rotSpeed);
                             deltaQ.multiply(qy);
                        }
                    }

                    if (axisLock.y || axisLock.z || (!axisLock.x)) {
                        let dy = deltaY;
                        if (axisLock.x) dy = 0;
                        // Vertical rotation (Around Camera Right)
                        const camRight = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
                        const qx = new THREE.Quaternion();
                        qx.setFromAxisAngle(camRight, -deltaY * rotSpeed);
                        deltaQ.premultiply(qx);
                    }

                    lapsTargetQ.premultiply(deltaQ); 
                    dragStartMouse.set(e.clientX, e.clientY);
                    return;
                }
                
                if (currentTool.startsWith('cam-')) return;

                if (currentTool === 'move' && selectedVoxel) {
                    if(isLocked) {
                        const mag = (deltaX - deltaY) * 0.05; 
                        if(axisLock.x) selectedVoxel.position.x = snapToGrid(dragStartVal.x + mag);
                        if(axisLock.y) selectedVoxel.position.y = snapToGrid(dragStartVal.y + mag);
                        if(axisLock.z) selectedVoxel.position.z = snapToGrid(dragStartVal.z + mag);
                    } else {
                        raycaster.setFromCamera(mouse, camera);
                        const intersects = raycaster.intersectObject(planeGround);
                        if(intersects.length > 0) {
                            const p = intersects[0].point;
                            selectedVoxel.position.x = snapToGrid(p.x);
                            selectedVoxel.position.z = snapToGrid(p.z);
                        }
                    }
                } 
                else if (currentTool === 'rotate' && selectedVoxel) {
                    const mag = deltaX * 0.02;
                    const snap = Math.PI / 4;
                    if(isLocked) {
                        if(axisLock.x) selectedVoxel.rotation.x = Math.round((dragStartVal.x + mag) / snap) * snap;
                        if(axisLock.y) selectedVoxel.rotation.y = Math.round((dragStartVal.y + mag) / snap) * snap;
                        if(axisLock.z) selectedVoxel.rotation.z = Math.round((dragStartVal.z + mag) / snap) * snap;
                    } else {
                        selectedVoxel.rotation.y = Math.round((dragStartVal.y + mag) / snap) * snap; 
                    }
                } 
                else if (currentTool === 'scale' && selectedVoxel) {
                    const mag = -deltaY * 0.02; 
                    let sVec = dragStartVal.clone();
                    
                    if(isLocked) {
                        if(axisLock.x) sVec.x = Math.max(1, Math.round(dragStartVal.x + mag));
                        if(axisLock.y) sVec.y = Math.max(1, Math.round(dragStartVal.y + mag));
                        if(axisLock.z) sVec.z = Math.max(1, Math.round(dragStartVal.z + mag));
                    } else {
                        const s = Math.max(1, Math.round(dragStartVal.x + mag));
                        sVec.set(s,s,s);
                    }
                    
                    selectedVoxel.scale.copy(sVec);

                    const fixPos = (pos, scale) => {
                        const halfS = (scale * voxelSize) / 2;
                        const base = Math.round((pos - halfS) / voxelSize) * voxelSize;
                        return base + halfS;
                    };
                    selectedVoxel.position.x = fixPos(selectedVoxel.position.x, sVec.x);
                    selectedVoxel.position.y = fixPos(selectedVoxel.position.y, sVec.y);
                    selectedVoxel.position.z = fixPos(selectedVoxel.position.z, sVec.z);
                }
                updateSelectionBox();
                updateUIFromSelection();
                return;
            }

            const isLocked = axisLock.x || axisLock.y || axisLock.z;

            // Cursor Drag (Key held + Drag)
            if (isDragging && !selectedVoxel && (currentTool === 'select' || currentTool === 'add' || currentTool === 'erase' || currentTool === 'paint')) {
                 if(isLocked) {
                    const deltaX = e.clientX - dragStartMouse.x;
                    const deltaY = e.clientY - dragStartMouse.y;
                    const mag = (deltaX - deltaY) * 0.05;
                    
                    let targetCursor = (currentTool === 'add') ? cursorTarget : cursorPoint;
                    
                    if(axisLock.x) targetCursor.position.x = snapToGrid(startCursorPos.x + mag);
                    if(axisLock.y) targetCursor.position.y = snapToGrid(startCursorPos.y + mag);
                    if(axisLock.z) targetCursor.position.z = snapToGrid(startCursorPos.z + mag);

                    const gy = Math.round((targetCursor.position.y - voxelSize/2) / voxelSize);
                    document.getElementById('height-display').textContent = `Height: ${gy} Y`;
                 }
                 return;
            }

            // Normal Hover
            if(!isDragging) {
                raycaster.setFromCamera(mouse, camera);
                const intersectsPlane = raycaster.intersectObject(planeGround);
                
                if (intersectsPlane.length > 0) {
                    const p = intersectsPlane[0].point;
                    const ix = Math.round(p.x / voxelSize);
                    const iz = Math.round(p.z / voxelSize);
                    
                    // Find highest Y
                    let maxY = 0;
                    for(let v of voxels) {
                        const vMinX = Math.round((v.position.x - v.scale.x*voxelSize/2)/voxelSize);
                        const vMaxX = Math.round((v.position.x + v.scale.x*voxelSize/2)/voxelSize);
                        const vMinZ = Math.round((v.position.z - v.scale.z*voxelSize/2)/voxelSize);
                        const vMaxZ = Math.round((v.position.z + v.scale.z*voxelSize/2)/voxelSize);
                        
                        if (ix >= vMinX && ix < vMaxX && iz >= vMinZ && iz < vMaxZ) {
                             const top = v.position.y + (v.scale.y * voxelSize)/2;
                             if(top > maxY) maxY = top;
                        }
                    }
                    
                    if (currentTool === 'add') {
                        targetPos.set(ix, 0, iz);
                        const nextCenterY = (maxY === 0) ? (voxelSize/2) : (maxY + voxelSize/2);
                        cursorTarget.position.set(ix * voxelSize, nextCenterY, iz * voxelSize);
                        const gridY = Math.round((nextCenterY - voxelSize/2)/voxelSize);
                        document.getElementById('height-display').textContent = `Height: ${gridY} Y`;
                        
                    } else if (currentTool === 'erase') {
                        if (maxY > 0) {
                            cursorPoint.visible = true;
                            cursorPoint.position.set(ix * voxelSize, maxY - voxelSize/2, iz * voxelSize);
                        } else {
                            cursorPoint.visible = false;
                        }
                        document.getElementById('height-display').textContent = `Height: -`;
                        
                    } else {
                        // Select / Paint
                        const intersectVoxels = raycaster.intersectObjects(voxels);
                        if (intersectVoxels.length > 0) {
                            const hit = intersectVoxels[0];
                            const vPos = hit.object.position;
                            cursorPoint.position.copy(vPos);
                            cursorPoint.scale.copy(hit.object.scale);
                            cursorPoint.visible = true;
                            const gy = Math.round((vPos.y - voxelSize/2) / voxelSize);
                            document.getElementById('height-display').textContent = `Height: ${gy} Y`;
                        } else {
                            cursorPoint.scale.set(1,1,1);
                            cursorPoint.position.set(ix*voxelSize, voxelSize/2, iz*voxelSize);
                            cursorPoint.visible = true;
                            document.getElementById('height-display').textContent = `Height: 0 Y`;
                        }
                    }
                }
            }
        }

        function onPointerDown(e) {
            if (e.button !== 0) return; 
            
            const isLocked = axisLock.x || axisLock.y || axisLock.z;

            // Cam Laps
            if(isLapping) {
                isDragging = true;
                dragStartMouse.set(e.clientX, e.clientY);
                controls.enabled = false;
                return;
            }

            // Capture Camera Start State (Always capture for constraints)
            dragStartCamPos.copy(camera.position);
            dragStartCamTarget.copy(controls.target);
            dragStartPolar = controls.getPolarAngle();
            dragStartAzimuth = controls.getAzimuthalAngle();

            if (currentTool.startsWith('cam-')) {
                isDragging = true;
                dragStartMouse.set(e.clientX, e.clientY);
                controls.enabled = true; // Ensure OrbitControls is ENABLED for camera tools
                return;
            }

            if (isLocked) {
                isDragging = true;
                dragStartMouse.set(e.clientX, e.clientY);
                controls.enabled = false; 
                
                if(selectedVoxel && ['move', 'rotate', 'scale'].includes(currentTool)) {
                    if (currentTool === 'move') dragStartVal.copy(selectedVoxel.position);
                    if (currentTool === 'rotate') dragStartVal.set(selectedVoxel.rotation.x, selectedVoxel.rotation.y, selectedVoxel.rotation.z);
                    if (currentTool === 'scale') dragStartVal.set(selectedVoxel.scale.x, selectedVoxel.scale.y, selectedVoxel.scale.z);
                } else {
                    let targetCursor = (currentTool === 'add') ? cursorTarget : cursorPoint;
                    startCursorPos.copy(targetCursor.position);
                }
                return; 
            }

            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects(voxels);
            const hitVoxel = intersects.length > 0 ? intersects[0].object : null;

            if (currentTool === 'select') {
                selectVoxel(hitVoxel);
            }
            else if (currentTool === 'add') {
                const pos = cursorTarget.position;
                createVoxel(null, {
                    x: pos.x, y: pos.y, z: pos.z,
                    rx: 0, ry: 0, rz: 0,
                    sx: 1, sy: 1, sz: 1,
                    color: new THREE.Color(currentBrush.color).getHex(),
                    texture: currentBrush.textureSrc,
                    name: "Voxel_" + voxels.length
                });
            }
            else if (currentTool === 'paint') {
                if(hitVoxel) floodFill(hitVoxel);
            }
            else if (currentTool === 'erase') {
                if(hitVoxel) deleteVoxel(hitVoxel);
            }
            else if (['move', 'rotate', 'scale'].includes(currentTool)) {
                if (hitVoxel) {
                    selectVoxel(hitVoxel);
                    isDragging = true;
                    controls.enabled = false; 
                    dragStartMouse.set(e.clientX, e.clientY);
                    
                    if (currentTool === 'move') dragStartVal.copy(hitVoxel.position);
                    if (currentTool === 'rotate') dragStartVal.set(hitVoxel.rotation.x, hitVoxel.rotation.y, hitVoxel.rotation.z);
                    if (currentTool === 'scale') dragStartVal.set(hitVoxel.scale.x, hitVoxel.scale.y, hitVoxel.scale.z);
                } else {
                    selectVoxel(null);
                }
            }
        }

        function onPointerUp() {
            isDragging = false;
            controls.enabled = true;
        }

        function floodFill(startVoxel) {
            const targetColor = startVoxel.material.color.getHex();
            const targetTex = startVoxel.material.userData.textureSrc;
            const brushColorInt = new THREE.Color(currentBrush.color).getHex();
            
            if (targetColor === brushColorInt && targetTex === currentBrush.textureSrc) return;

            const queue = [startVoxel];
            const processed = new Set();
            processed.add(startVoxel.uuid);

            const getCoord = (v) => {
                return {
                    x: Math.round(v.position.x/voxelSize),
                    y: Math.round(v.position.y/voxelSize),
                    z: Math.round(v.position.z/voxelSize)
                };
            };

            while(queue.length > 0) {
                const v = queue.shift();
                
                v.material.color.setHex(brushColorInt);
                if(currentBrush.textureSrc) {
                    loadTexture(v.material, currentBrush.textureSrc);
                } else {
                    v.material.map = null;
                    v.material.userData.textureSrc = null;
                    v.material.needsUpdate = true;
                }

                const c = getCoord(v);
                const neighbors = [
                    {x:c.x+1, y:c.y, z:c.z}, {x:c.x-1, y:c.y, z:c.z},
                    {x:c.x, y:c.y+1, z:c.z}, {x:c.x, y:c.y-1, z:c.z},
                    {x:c.x, y:c.y, z:c.z+1}, {x:c.x, y:c.y, z:c.z-1}
                ];

                for(let nPos of neighbors) {
                    const neighbor = voxels.find(nv => {
                        const nc = getCoord(nv);
                        return nc.x === nPos.x && nc.y === nPos.y && nc.z === nPos.z;
                    });

                    if(neighbor && !processed.has(neighbor.uuid)) {
                        const nColor = neighbor.material.color.getHex();
                        const nTex = neighbor.material.userData.textureSrc;
                        if(nColor === targetColor && nTex === targetTex) {
                            processed.add(neighbor.uuid);
                            queue.push(neighbor);
                        }
                    }
                }
            }
        }

        function onKeyDown(e) {
            if (e.target.tagName === 'INPUT') return;

            // PageUp/PageDown Zoom Logic
            if (e.key === 'PageUp') {
                e.preventDefault();
                camera.zoom = Math.min(camera.zoom + 0.1, 5); 
                camera.updateProjectionMatrix();
            }
            if (e.key === 'PageDown') {
                e.preventDefault();
                camera.zoom = Math.max(camera.zoom - 0.1, 0.1); 
                camera.updateProjectionMatrix();
            }

            if(e.key.toLowerCase() === 'x') axisLock.x = true;
            if(e.key.toLowerCase() === 'y') axisLock.y = true;
            if(e.key.toLowerCase() === 'z') axisLock.z = true;

            if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
            if (e.key === 'F2') { e.preventDefault(); viewCam(isTopView ? 'top' : 'bottom'); isTopView = !isTopView; }
            if (e.key === 'F3') { e.preventDefault(); const sides = ['front', 'right', 'back', 'left']; viewCam(sides[currentSideViewIndex]); currentSideViewIndex = (currentSideViewIndex + 1) % 4; }

            if (e.key.toLowerCase() === 'c') { if (selectedVoxel) copyVoxel(selectedVoxel); }
            if (e.key.toLowerCase() === 'v') { if (clipboard) pasteVoxel(); }

            if (e.key === 'ArrowUp') { e.preventDefault(); moveCursorY(1); }
            if (e.key === 'ArrowDown') { e.preventDefault(); moveCursorY(-1); }
            if (e.key === 'Delete') { if (selectedVoxel) deleteVoxel(selectedVoxel); }
        }

        function onKeyUp(e) {
            if(e.key.toLowerCase() === 'x') axisLock.x = false;
            if(e.key.toLowerCase() === 'y') axisLock.y = false;
            if(e.key.toLowerCase() === 'z') axisLock.z = false;
        }

        function moveCursorY(dir) {
            const target = (currentTool === 'add') ? cursorTarget : cursorPoint;
            target.position.y += dir * voxelSize;
            const gy = Math.round((target.position.y - voxelSize/2) / voxelSize);
            document.getElementById('height-display').textContent = `Height: ${gy} Y`;
        }

        function createVoxel(gridPos, data=null) {
            const geo = new THREE.BoxGeometry(voxelSize, voxelSize, voxelSize);
            let colorHex = data ? data.color : Math.random() * 0xffffff;
            if (data && data.texture) colorHex = 0xffffff;

            // Use Standard Material for GLTF/FBX Compatibility
            const mat = new THREE.MeshStandardMaterial({ color: colorHex });
            if (data && data.texture) loadTexture(mat, data.texture);

            const mesh = new THREE.Mesh(geo, mat);
            if (data) {
                mesh.position.set(data.x, data.y, data.z);
                mesh.rotation.set(data.rx, data.ry, data.rz);
                mesh.scale.set(data.sx, data.sy, data.sz);
                mesh.userData.name = data.name;
            } else {
                mesh.position.set(gridPos.x * voxelSize, gridPos.y * voxelSize + voxelSize/2, gridPos.z * voxelSize);
                mesh.userData.name = "Voxel_" + voxels.length;
            }
            scene.add(mesh);
            voxels.push(mesh);
            return mesh;
        }

        function deleteVoxel(mesh) {
            scene.remove(mesh);
            voxels = voxels.filter(v => v !== mesh);
            if (selectedVoxel === mesh) selectVoxel(null);
        }

        function selectVoxel(mesh) {
            selectedVoxel = mesh;
            if (mesh) {
                if(currentTool !== 'add' && currentTool !== 'paint') cursorSelect.visible = true;
                updateSelectionBox();
                updateUIFromSelection();
            } else {
                cursorSelect.visible = false;
                if (currentTool !== 'add' && currentTool !== 'paint') {
                    document.getElementById('prop-content').style.display = 'none';
                    document.getElementById('prop-empty').style.display = 'block';
                }
            }
        }

        function updateSelectionBox() {
            if(!selectedVoxel) return;
            cursorSelect.position.copy(selectedVoxel.position);
            cursorSelect.rotation.copy(selectedVoxel.rotation);
            cursorSelect.scale.copy(selectedVoxel.scale).multiplyScalar(1.05);
        }

        function copyVoxel(mesh) {
            clipboard = {
                color: mesh.material.color.getHex(),
                texture: mesh.material.userData.textureSrc,
                rx: mesh.rotation.x, ry: mesh.rotation.y, rz: mesh.rotation.z,
                sx: mesh.scale.x, sy: mesh.scale.y, sz: mesh.scale.z,
                name: mesh.userData.name + "_copy"
            };
        }

        function pasteVoxel() {
            if(!clipboard) return;
            if(!cursorPoint.visible) return;
            const data = { 
                ...clipboard, 
                x: cursorPoint.position.x, 
                y: cursorPoint.position.y, 
                z: cursorPoint.position.z 
            };
            createVoxel(null, data);
        }

        function loadTexture(mat, src) {
            new THREE.TextureLoader().load(src, (tex) => {
                mat.map = tex;
                mat.color.setHex(0xffffff); 
                mat.needsUpdate = true;
            });
            mat.userData.textureSrc = src;
        }

        // --- UI Binding ---
        window.setTool = (tool) => {
            currentTool = tool;
            
            const names = {
                'select': 'Select', 'add': 'Add', 'erase': 'Erase', 'paint': 'Paint',
                'move': 'Move', 'rotate': 'Rotate', 'scale': 'Scale',
                'cam-move': 'Camera Pan', 'cam-rotate': 'Camera Orbit', 'cam-zoom': 'Camera Zoom', 'cam-laps': 'Camera Laps'
            };
            document.getElementById('mode-display').innerText = (names[tool] || tool);

            // Visibility
            if(tool === 'add' || tool === 'paint') {
                cursorTarget.visible = (tool === 'add');
                cursorSelect.visible = false;
                cursorPoint.visible = false; 
                
                document.getElementById('prop-header').innerText = "Brush Settings";
                document.getElementById('prop-content').style.display = 'block';
                document.getElementById('prop-empty').style.display = 'none';
                document.getElementById('prop-transforms').style.display = 'none';
                document.getElementById('prop-name').parentElement.style.display = 'none';
                
                document.getElementById('prop-color').value = currentBrush.color;
                if(currentBrush.textureSrc) {
                     document.getElementById('prop-tex-preview').src = currentBrush.textureSrc;
                     document.getElementById('prop-tex-preview').style.display = 'block';
                } else {
                    document.getElementById('prop-tex-preview').style.display = 'none';
                }
            } else {
                cursorTarget.visible = false;
                cursorPoint.visible = true;
                if(selectedVoxel) cursorSelect.visible = true;
                
                document.getElementById('prop-header').innerText = "Properties";
                document.getElementById('prop-transforms').style.display = 'block';
                document.getElementById('prop-name').parentElement.style.display = 'block';
                
                if(selectedVoxel) updateUIFromSelection();
                else {
                    document.getElementById('prop-content').style.display = 'none';
                    document.getElementById('prop-empty').style.display = 'block';
                }
            }

            isLapping = (tool === 'cam-laps');
            
            if(isLapping) {
                // Initialize Quaternion Laps State
                // Store current camera position relative to 0,0,0
                const relPos = camera.position.clone();
                lapsRadius = relPos.length();
                relPos.normalize();
                
                // Set current Quats
                const dummy = new THREE.Object3D();
                dummy.position.copy(relPos.multiplyScalar(lapsRadius));
                dummy.lookAt(0,0,0);
                
                const vBase = new THREE.Vector3(0,0,1);
                lapsQ.setFromUnitVectors(vBase, relPos.normalize()); 
                lapsTargetQ.copy(lapsQ);
                
                controls.enabled = false;
            } else {
                if(controls) {
                    controls.enabled = true;
                    controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
                    if (tool === 'cam-move') controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
                    else if (tool === 'cam-rotate') controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
                    else if (tool === 'cam-zoom') {
                        controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
                        controls.zoomSpeed = 3.0; // Faster Zoom
                    } else {
                        controls.zoomSpeed = 1.0; // Default
                    }
                }
            }
        };

        // Events
        document.getElementById('tool-select').onclick = () => setTool('select');
        document.getElementById('tool-add').onclick = () => setTool('add');
        document.getElementById('tool-paint').onclick = () => setTool('paint');
        document.getElementById('tool-erase').onclick = () => setTool('erase');
        document.getElementById('tool-move').onclick = () => setTool('move');
        document.getElementById('tool-rotate').onclick = () => setTool('rotate');
        document.getElementById('tool-scale').onclick = () => setTool('scale');
        
        document.getElementById('tool-cam-move').onclick = () => setTool('cam-move');
        document.getElementById('tool-cam-rotate').onclick = () => setTool('cam-rotate');
        document.getElementById('tool-cam-zoom').onclick = () => setTool('cam-zoom');
        document.getElementById('tool-cam-laps').onclick = () => setTool('cam-laps');

        window.viewCam = (dir) => {
            const dist = 30 * voxelSize;
            const p = camera.position;
            if(isLapping) { isLapping = false; setTool('select'); }

            switch(dir) {
                case 'default': p.set(dist, dist, dist); break;
                case 'front': p.set(0, 0, dist); break;
                case 'back': p.set(0, 0, -dist); break;
                case 'left': p.set(-dist, 0, 0); break;
                case 'right': p.set(dist, 0, 0); break;
                case 'top': p.set(0, dist, 0); break;
                case 'bottom': p.set(0, -dist, 0); break;
            }
            camera.lookAt(0,0,0);
            controls.target.set(0,0,0);
            controls.update();
        };

        function setupUI() {
            const bind = (id, prop, axis, isRot=false) => {
                const el = document.getElementById(id);
                el.addEventListener('input', () => {
                    if(currentTool === 'add' || currentTool === 'paint') return;
                    if(!selectedVoxel) return;
                    let val = parseFloat(el.value);
                    if(isRot) val = THREE.MathUtils.degToRad(val);
                    if(axis) selectedVoxel[prop][axis] = val;
                    else selectedVoxel[prop] = val;
                    
                    if(prop === 'scale') {
                         const fixPos = (pos, scale) => {
                            const halfS = (scale * voxelSize) / 2;
                            const base = Math.round((pos - halfS) / voxelSize) * voxelSize;
                            return base + halfS;
                         };
                         selectedVoxel.position.x = fixPos(selectedVoxel.position.x, selectedVoxel.scale.x);
                         selectedVoxel.position.y = fixPos(selectedVoxel.position.y, selectedVoxel.scale.y);
                         selectedVoxel.position.z = fixPos(selectedVoxel.position.z, selectedVoxel.scale.z);
                         updateUIFromSelection();
                    }
                    updateSelectionBox();
                });
            };
            
            bind('prop-x', 'position', 'x'); bind('prop-y', 'position', 'y'); bind('prop-z', 'position', 'z');
            bind('prop-rx', 'rotation', 'x', true); bind('prop-ry', 'rotation', 'y', true); bind('prop-rz', 'rotation', 'z', true);
            bind('prop-sx', 'scale', 'x'); bind('prop-sy', 'scale', 'y'); bind('prop-sz', 'scale', 'z');
            
            document.getElementById('prop-name').addEventListener('input', (e) => {
                if(selectedVoxel) selectedVoxel.userData.name = e.target.value;
            });
            
            document.getElementById('prop-color').addEventListener('input', (e) => {
                if(currentTool === 'add' || currentTool === 'paint') {
                    currentBrush.color = e.target.value;
                } else if(selectedVoxel) {
                    selectedVoxel.material.color.set(e.target.value);
                    if(!selectedVoxel.material.map) selectedVoxel.material.color.set(e.target.value);
                }
            });

            document.getElementById('prop-tex-file').addEventListener('change', (e) => {
                const f = e.target.files[0];
                if(!f) return;
                const reader = new FileReader();
                reader.onload = (ev) => {
                    const src = ev.target.result;
                    if(currentTool === 'add' || currentTool === 'paint') {
                        currentBrush.textureSrc = src;
                        document.getElementById('prop-tex-preview').src = src;
                        document.getElementById('prop-tex-preview').style.display = 'block';
                    } else if(selectedVoxel) {
                        loadTexture(selectedVoxel.material, src);
                    }
                };
                reader.readAsDataURL(f);
            });

            document.getElementById('btn-clear-tex').onclick = () => {
                if(currentTool === 'add' || currentTool === 'paint') {
                    currentBrush.textureSrc = null;
                    document.getElementById('prop-tex-preview').style.display = 'none';
                    document.getElementById('prop-tex-file').value = "";
                } else if(selectedVoxel) {
                    selectedVoxel.material.map = null;
                    selectedVoxel.material.userData.textureSrc = null;
                    selectedVoxel.material.color.set(document.getElementById('prop-color').value); 
                    selectedVoxel.material.needsUpdate = true;
                    document.getElementById('prop-tex-file').value = "";
                }
            };
            
            document.getElementById('menu-setting').onclick = () => document.getElementById('modal-overlay').style.display = 'flex';
            document.getElementById('btn-setting-cancel').onclick = () => document.getElementById('modal-overlay').style.display = 'none';
            document.getElementById('btn-setting-ok').onclick = () => {
                gridSize.x = parseInt(document.getElementById('set-grid-x').value);
                gridSize.y = parseInt(document.getElementById('set-grid-y').value);
                gridSize.z = parseInt(document.getElementById('set-grid-z').value);
                voxelSize = parseFloat(document.getElementById('set-voxel-size').value);
                updateEnvironment();
                document.getElementById('modal-overlay').style.display = 'none';
            };

            const downloadFile = (blob, defaultName) => {
                const link = document.createElement('a');
                link.href = URL.createObjectURL(blob);
                link.download = defaultName;
                link.click();
            };

            document.getElementById('menu-new').onclick = () => {
                if(confirm("Create new scene? All unsaved voxels will be lost.")) {
                    voxels.forEach(v => scene.remove(v));
                    voxels = [];
                    selectVoxel(null);
                    setTool('select');
                }
            };

            document.getElementById('menu-save').onclick = () => {
                const data = serializeScene();
                const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
                downloadFile(blob, 'scene.json');
            };
            document.getElementById('menu-saveas').onclick = () => {
                const name = prompt("Save As (filename):", "scene.json");
                if(name) {
                    const data = serializeScene();
                    const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
                    downloadFile(blob, name);
                }
            };
            document.getElementById('menu-open').onclick = () => document.getElementById('file-input').click();
            document.getElementById('file-input').onchange = (e) => {
                const f = e.target.files[0];
                if(!f) return;
                const r = new FileReader();
                r.onload = (ev) => {
                    const d = JSON.parse(ev.target.result);
                    voxels.forEach(v => scene.remove(v));
                    voxels = [];
                    d.forEach(i => createVoxel(null, i));
                };
                r.readAsText(f);
            };
            
            const doExport = async (fmt) => {
                // Prepare a clean export scene
                const exportScene = new THREE.Scene();
                
                voxels.forEach(v => {
                    // Convert to MeshStandardMaterial for best compatibility
                    let mat = v.material;
                    // Check if conversion needed (e.g. from Lambert)
                    if (mat.type !== 'MeshStandardMaterial') {
                        mat = new THREE.MeshStandardMaterial({
                            color: v.material.color,
                            map: v.material.map,
                            transparent: v.material.transparent,
                            opacity: v.material.opacity
                        });
                    }
                    
                    const mesh = new THREE.Mesh(v.geometry.clone(), mat);
                    mesh.position.copy(v.position);
                    mesh.rotation.copy(v.rotation);
                    mesh.scale.copy(v.scale);
                    // Ensure matrix is up to date
                    mesh.updateMatrix();
                    exportScene.add(mesh);
                });
                
                // Important: Update world matrix of the scene/roots
                exportScene.updateMatrixWorld(true);

                if(fmt === 'obj') {
                    const { OBJExporter } = await import('three/addons/exporters/OBJExporter.js');
                    const res = new OBJExporter().parse(exportScene);
                    downloadFile(new Blob([res], {type:'text/plain'}), 'model.obj');
                }
                if(fmt === 'glb') {
                    const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
                    new GLTFExporter().parse(exportScene, (res) => {
                        downloadFile(new Blob([res], {type:'application/octet-stream'}), 'model.glb');
                    }, (e)=>console.error(e), { binary: true, embedImages: true });
                }
            };
            document.getElementById('menu-export-obj').onclick = () => doExport('obj');
            document.getElementById('menu-export-glb').onclick = () => doExport('glb');
        }

        function serializeScene() {
            return voxels.map(v => ({
                x: v.position.x, y: v.position.y, z: v.position.z,
                rx: v.rotation.x, ry: v.rotation.y, rz: v.rotation.z,
                sx: v.scale.x, sy: v.scale.y, sz: v.scale.z,
                color: v.material.color.getHex(),
                texture: v.material.userData.textureSrc,
                name: v.userData.name
            }));
        }

        function updateUIFromSelection() {
            if(!selectedVoxel) return;
            if(currentTool === 'add' || currentTool === 'paint') return;

            document.getElementById('prop-content').style.display = 'block';
            document.getElementById('prop-empty').style.display = 'none';
            
            const v = selectedVoxel;
            document.getElementById('prop-name').value = v.userData.name || "";
            document.getElementById('prop-color').value = '#' + v.material.color.getHexString();
            
            document.getElementById('prop-x').value = v.position.x;
            document.getElementById('prop-y').value = v.position.y;
            document.getElementById('prop-z').value = v.position.z;
            
            document.getElementById('prop-rx').value = Math.round(THREE.MathUtils.radToDeg(v.rotation.x));
            document.getElementById('prop-ry').value = Math.round(THREE.MathUtils.radToDeg(v.rotation.y));
            document.getElementById('prop-rz').value = Math.round(THREE.MathUtils.radToDeg(v.rotation.z));
            
            document.getElementById('prop-sx').value = v.scale.x;
            document.getElementById('prop-sy').value = v.scale.y;
            document.getElementById('prop-sz').value = v.scale.z;

            if(v.material.userData.textureSrc) {
                document.getElementById('prop-tex-preview').src = v.material.userData.textureSrc;
                document.getElementById('prop-tex-preview').style.display = 'block';
            } else {
                document.getElementById('prop-tex-preview').style.display = 'none';
            }
        }

        function onResize() {
            const c = document.getElementById('canvas-container');
            camera.aspect = c.clientWidth / c.clientHeight;
            camera.updateProjectionMatrix();
            renderer.setSize(c.clientWidth, c.clientHeight);
        }

        init();
    </script>
</body>
</html>

・コンテナ・エディタのソースコードはこちら。↓

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WFC Container Editor Ultimate v1.8</title>
    <style>
        /* --- Base Styles --- */
        body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
        
        /* Menu Bar */
        #menubar {
            height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e;
            justify-content: space-between;
        }
        .menu-group { display: flex; height: 100%; }
        .menu-item {
            padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc;
        }
        .menu-item:hover { background: #3e3e3e; color: #fff; }
        .dropdown {
            display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);
        }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item {
            padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px;
        }
        .dropdown-item:hover { background: #094771; color: #fff; }
        .separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }

        /* Layout */
        #container { display: flex; height: calc(100vh - 30px); }
        
        /* Left Sidebar (Asset List) */
        #sidebar-left {
            width: 220px; background: #252526; border-right: 1px solid #3e3e3e; display: flex; flex-direction: column;
        }
        .panel-header { background: #2d2d2d; padding: 8px 10px; font-weight: bold; font-size: 12px; text-transform: uppercase; color: #aaa; border-bottom: 1px solid #3e3e3e; }
        #asset-list { flex: 1; overflow-y: auto; padding: 5px; outline: none; }
        .asset-item { 
            padding: 6px 10px; margin-bottom: 2px; background: #333; border-radius: 2px; cursor: grab; font-size: 12px; 
            display: flex; align-items: center; border: 1px solid transparent; color: #ccc;
        }
        .asset-item:hover { background: #3e3e3e; border-color: #555; color: #fff; }
        .asset-item.active { background: #094771; border-color: #007fd4; color: #fff; }
        .asset-item:focus { outline: 1px solid #007fd4; background: #3e3e3e; } 
        .asset-icon { width: 10px; height: 10px; background: #569cd6; margin-right: 8px; border-radius: 2px; }

        /* Canvas Area */
        #canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #1e1e1e; }
        
        /* Right Panel (Properties) */
        #properties-panel {
            width: 280px; background: #252526; border-left: 1px solid #3e3e3e; display: flex; flex-direction: column; box-sizing: border-box;
        }
        .prop-scroll { flex: 1; overflow-y: auto; padding: 15px; }
        .prop-section { margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 10px; }
        .prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
        
        /* Inputs */
        input[type="text"], input[type="number"], select {
            width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 8px; font-size: 12px; border-radius: 2px;
            text-align: right;
        }
        input[type="text"] { text-align: left; }
        input:focus, select:focus { border-color: #007fd4; outline: none; }
        .row { display: flex; gap: 4px; }
        
        /* Status / UI Overlays */
        #status-bar {
            position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
        }
        #info-overlay {
            position: absolute; top: 10px; left: 10px; pointer-events: none;
        }
        #mode-display {
            background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block;
        }
        #height-display {
            background: rgba(0,0,0,0.5); color: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table; 
        }
        
        /* Helpers */
        .arrow-label {
            position: absolute; color: #aaa; font-family: sans-serif; font-size: 12px; pointer-events: none; font-weight: bold; background: rgba(0,0,0,0.5); padding: 2px 4px; border-radius: 3px; display: none;
        }
        .axis-label {
            position: absolute; font-family: 'Consolas', monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff; display: none;
        }
        .hidden { display: none !important; }
        .face-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; font-size: 11px; }
        .face-tag { width: 70px; font-weight: bold; padding: 2px 5px; border-radius: 2px; background: rgba(0,0,0,0.2); }
        .btn { width: 100%; padding: 6px; background: #0e639c; color: white; border: none; cursor: pointer; margin-top: 5px; font-size: 12px; border-radius: 2px;}
        .btn:hover { background: #1177bb; }
        .btn-group { display: flex; gap: 4px; margin-top: 4px; }
        .btn-sm { flex: 1; padding: 4px; background: #333; border: 1px solid #444; color: #ddd; cursor: pointer; font-size: 11px; text-align: center; }
        .btn-sm:hover { background: #444; }

        /* Dialogs */
        #modal-overlay {
            display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 3000; justify-content: center; align-items: center;
        }
        #settings-dialog { background: #252526; padding: 20px; border: 1px solid #454545; width: 300px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
        #settings-dialog h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; }
        .dialog-btn { margin-top: 15px; padding: 6px 20px; cursor: pointer; background: #0e639c; color: white; border: none; font-size: 13px; border-radius: 2px; }
        .dialog-btn.cancel { background: #3e3e3e; margin-left: 10px; }

        #drop-zone-overlay { 
            position: absolute; top:0; left:0; width:100%; height:100%; 
            background: rgba(50, 150, 255, 0.2); border: 4px dashed #3296ff; 
            display: none; pointer-events: none; z-index: 2000;
        }

        /* Context Menu */
        #context-menu {
            display: none; position: absolute; background: #252526; border: 1px solid #454545; min-width: 150px; z-index: 4000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);
        }
        .ctx-item {
            padding: 8px 15px; cursor: pointer; font-size: 13px; color: #ccc;
        }
        .ctx-item:hover { background: #094771; color: white; }
    </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="menubar">
        <div class="menu-group">
            <div class="menu-item">File
                <div class="dropdown">
                    <div class="dropdown-item" id="menu-new">New</div>
                    <div class="dropdown-item" id="menu-open">Open</div>
                    <div class="dropdown-item" id="menu-save">Save</div>
                    <div class="dropdown-item" id="menu-saveas">Save As</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" id="menu-import-model">Import Models...</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" id="menu-export-glb">Export GLB</div>
                    <div class="dropdown-item" id="menu-export-obj">Export OBJ</div>
                </div>
            </div>
            <div class="menu-item">Edit
                <div class="dropdown">
                    <div class="dropdown-item" id="tool-select">Select</div>
                    <div class="dropdown-item" id="tool-add">Add</div>
                    <div class="dropdown-item" id="tool-erase">Erase</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" id="tool-move">Move</div>
                    <div class="dropdown-item" id="tool-rotate">Rotate</div>
                    <div class="dropdown-item" id="tool-scale">Scale</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" id="tool-cam-move">Camera Move</div>
                    <div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
                    <div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
                    <div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
                </div>
            </div>
            <div class="menu-item">View
                <div class="dropdown">
                    <div class="dropdown-item" onclick="app.viewCam('default')">Default (F1)</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" onclick="app.viewCam('top')">Top (F2)</div>
                    <div class="dropdown-item" onclick="app.viewCam('bottom')">Bottom</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" onclick="app.viewCam('front')">Front</div>
                    <div class="dropdown-item" onclick="app.viewCam('back')">Back</div>
                    <div class="dropdown-item" onclick="app.viewCam('left')">Left</div>
                    <div class="dropdown-item" onclick="app.viewCam('right')">Right</div>
                </div>
            </div>
            <div class="menu-item" id="menu-setting">Setting</div>
        </div>
        <div style="font-size:12px; color:#666; margin-right:10px;">WFC Container Editor v1.8</div>
    </div>

    <div id="container">
        <div id="sidebar-left">
            <div class="panel-header">Assets</div>
            <div id="asset-list" tabindex="0">
                <div style="padding:10px; color:#666; font-size:11px; text-align:center;">
                    Right Click to Import<br>or Drop Files Here
                </div>
            </div>
        </div>

        <div id="canvas-container" tabindex="0">
            <div id="drop-zone-overlay"></div>
            <div id="info-overlay">
                <div id="mode-display">Select</div>
                <div id="height-display">Height: 0 Y</div>
            </div>
            <div id="status-bar">Ready</div>
            <div id="labels-container"></div>
        </div>
        
        <div id="properties-panel">
            <div class="prop-scroll">
                <div class="prop-section hidden" id="part-props-container">
                    <span class="prop-label" style="color:#4fc1ff">Selected Part</span>
                    <div>
                        <label class="prop-label">ID / Name</label>
                        <input type="text" id="p-id">
                        
                        <label class="prop-label">Position</label>
                        <div class="row">
                            <input type="number" id="p-pos-x" step="0.1"><input type="number" id="p-pos-y" step="0.1"><input type="number" id="p-pos-z" step="0.1">
                        </div>
                        <label class="prop-label">Rotation</label>
                        <div class="row">
                            <input type="number" id="p-rot-x" step="45"><input type="number" id="p-rot-y" step="45"><input type="number" id="p-rot-z" step="45">
                        </div>
                        <label class="prop-label">Scale</label>
                        <div class="row">
                            <input type="number" id="p-scl-x" step="0.1"><input type="number" id="p-scl-y" step="0.1"><input type="number" id="p-scl-z" step="0.1">
                        </div>
                    </div>
                </div>

                <div class="prop-section" id="container-props-container">
                    <span class="prop-label" style="color:#2ecc71">Container Settings</span>
                    <label class="prop-label">Container Name</label>
                    <input type="text" id="c-name" value="New Container">
                    
                    <label class="prop-label">Symmetry Type</label>
                    <select id="c-symmetry">
                        <option value="X">None (X)</option>
                        <option value="T">T-Symmetry</option>
                        <option value="I">I-Symmetry</option>
                        <option value="L">L-Symmetry</option>
                        <option value="D">D-Symmetry (All)</option>
                    </select>

                    <hr style="border:0; border-top:1px solid #444; margin:15px 0;">

                    <label class="prop-label">Connection Types (Sockets)</label>
                    <select id="socket-type-list" size="5" style="height:80px; text-align:left;"></select>
                    <div class="btn-group">
                        <div class="btn-sm" onclick="app.addSocketType()">Add</div>
                        <div class="btn-sm" onclick="app.deleteSocketType()">Delete</div>
                    </div>

                    <hr style="border:0; border-top:1px solid #444; margin:15px 0;">
                    
                    <label class="prop-label">Boundary Rules</label>
                    <div id="socket-controls">
                        <div class="face-row"><span class="face-tag" style="background:#440000; color:#ff4444">Right X+</span> <select class="c-socket" data-face="0"></select></div>
                        <div class="face-row"><span class="face-tag" style="background:#440000; color:#ff4444">Left X-</span> <select class="c-socket" data-face="1"></select></div>
                        <div class="face-row"><span class="face-tag" style="background:#004400; color:#44ff44">Top Y+</span> <select class="c-socket" data-face="2"></select></div>
                        <div class="face-row"><span class="face-tag" style="background:#004400; color:#44ff44">Bottom Y-</span> <select class="c-socket" data-face="3"></select></div>
                        <div class="face-row"><span class="face-tag" style="background:#000044; color:#0088ff">Front Z+</span> <select class="c-socket" data-face="4"></select></div>
                        <div class="face-row"><span class="face-tag" style="background:#000044; color:#0088ff">Back Z-</span> <select class="c-socket" data-face="5"></select></div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div id="context-menu">
        <div class="ctx-item" id="ctx-import">Import...</div>
        <div class="ctx-item" id="ctx-unload">Unload</div>
    </div>

    <input type="file" id="file-open-json" accept=".json" style="display:none">
    <input type="file" id="file-import-asset" multiple accept=".glb,.gltf,.fbx,.obj,.png,.jpg,.jpeg,.dds,.tga" style="display:none">

    <div id="modal-overlay">
        <div id="settings-dialog">
            <h3>Settings</h3>
            <div class="prop-section">
                <span class="prop-label">Voxel / Grid Size</span>
                <input type="number" id="set-voxel-size" value="1" step="0.1">
            </div>
            <div class="prop-section">
                <span class="prop-label">Grid Dimensions (X, Y, Z)</span>
                <div style="display:flex; gap:5px;">
                    <input type="number" id="set-grid-x" value="20">
                    <input type="number" id="set-grid-y" value="20">
                    <input type="number" id="set-grid-z" value="20">
                </div>
            </div>
            <div style="text-align:right;">
                <button class="dialog-btn cancel" id="btn-setting-cancel">Cancel</button>
                <button class="dialog-btn" id="btn-setting-ok">Apply</button>
            </div>
        </div>
    </div>

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
        import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
        import { DDSLoader } from 'three/addons/loaders/DDSLoader.js';
        import { TGALoader } from 'three/addons/loaders/TGALoader.js';
        import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
        import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';

        class EditorApp {
            constructor() {
                this.parts = []; 
                this.assets = []; 
                this.currentAsset = null;
                this.clipboard = null;
                this.contextTargetAsset = null;
                
                // Asset Persistence
                this.fileMap = {}; // Maps filename -> blobURL (runtime)
                this.savedAssets = {}; // Maps filename -> base64 (for saving)
                
                this.emptyTexture = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY/j///8/AAn7Ag+jUaW0AAAAAElFTkSuQmCC';

                this.containerData = {
                    name: 'New Container',
                    symmetry: 'X',
                    sockets: ['empty','empty','empty','empty','empty','empty']
                };
                
                this.socketTypes = [
                    { id: 'empty', name: 'Empty' },
                    { id: 'wall', name: 'Wall' }
                ];
                
                this.faceColors = [
                    0xff4444, 0xff4444, 0x44ff44, 0x44ff44, 0x0088ff, 0x0088ff 
                ];

                this.currentTool = 'select';
                this.selectedPart = null;
                this.voxelSize = 1;
                this.gridSize = { x: 20, y: 20, z: 20 };
                
                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
                this.isDragging = false;
                this.dragStartMouse = new THREE.Vector2();
                this.axisLock = { x: false, y: false, z: false };
                this.dragStartVal = new THREE.Vector3(); 
                
                this.isLapping = false;
                this.lapsQ = new THREE.Quaternion();
                this.lapsTargetQ = new THREE.Quaternion();
                this.lapsRadius = 30;

                this.axisArrows = { x: null, y: null, z: null };

                this.initThree();
                this.initUI();
                this.updateEnvironment();
                this.renderSocketUI();
                this.animate();
            }

            initThree() {
                const container = document.getElementById('canvas-container');
                this.scene = new THREE.Scene();
                this.scene.background = new THREE.Color(0x1e1e1e);

                this.camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000);
                this.camera.position.set(15, 15, 15);
                this.camera.lookAt(0,0,0);

                this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
                this.renderer.setSize(container.clientWidth, container.clientHeight);
                this.renderer.shadowMap.enabled = true;
                container.appendChild(this.renderer.domElement);

                this.controls = new OrbitControls(this.camera, this.renderer.domElement);
                this.controls.enableDamping = true;
                this.controls.dampingFactor = 0.1;

                const ambient = new THREE.AmbientLight(0xffffff, 0.6);
                this.scene.add(ambient);
                const sun = new THREE.DirectionalLight(0xffffff, 0.8);
                sun.position.set(10, 30, 20);
                sun.castShadow = true;
                this.scene.add(sun);

                this.gridGroup = new THREE.Group();
                this.scene.add(this.gridGroup);
                
                const pgGeo = new THREE.PlaneGeometry(2000, 2000);
                const pgMat = new THREE.MeshBasicMaterial({ visible: false });
                this.planeGround = new THREE.Mesh(pgGeo, pgMat);
                this.planeGround.rotation.x = -Math.PI / 2;
                this.scene.add(this.planeGround);

                // Axis Guides Group
                this.axisGuideGroup = new THREE.Group();
                this.scene.add(this.axisGuideGroup);
                
                const origin = new THREE.Vector3(0,0,0);
                const len = 5;
                this.axisArrows.x = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), origin, len, 0xff0000, 1, 0.5);
                this.axisArrows.y = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), origin, len, 0x00ff00, 1, 0.5);
                this.axisArrows.z = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), origin, len, 0x0088ff, 1, 0.5);
                this.axisGuideGroup.add(this.axisArrows.x);
                this.axisGuideGroup.add(this.axisArrows.y);
                this.axisGuideGroup.add(this.axisArrows.z);
                this.axisGuideGroup.visible = false;

                this.initCursors();

                const cvs = this.renderer.domElement;
                cvs.addEventListener('pointermove', (e) => this.onPointerMove(e));
                cvs.addEventListener('pointerdown', (e) => this.onPointerDown(e));
                cvs.addEventListener('pointerup', (e) => this.onPointerUp(e));
                window.addEventListener('resize', () => this.onResize());
                window.addEventListener('keydown', (e) => this.onKeyDown(e));
                window.addEventListener('keyup', (e) => this.onKeyUp(e));

                cvs.addEventListener('dragover', (e) => { e.preventDefault(); document.getElementById('drop-zone-overlay').style.display = 'block'; });
                cvs.addEventListener('dragleave', () => document.getElementById('drop-zone-overlay').style.display = 'none');
                cvs.addEventListener('drop', (e) => this.handleViewportDrop(e));

                window.addEventListener('click', () => document.getElementById('context-menu').style.display = 'none');
            }

            initCursors() {
                const geometry = new THREE.BoxGeometry(1, 1, 1);
                const edges = new THREE.EdgesGeometry(geometry);
                const material = new THREE.LineBasicMaterial({ color: 0xffffff, depthTest: false, opacity: 0.8, transparent: true });
                
                this.cursorPoint = new THREE.LineSegments(edges, material);
                this.scene.add(this.cursorPoint);
                this.cursorPoint.visible = true;

                const selMat = new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false, linewidth: 2 });
                this.cursorSelect = new THREE.LineSegments(edges, selMat);
                this.cursorSelect.visible = false;
                this.scene.add(this.cursorSelect);
            }

            initUI() {
                document.getElementById('menu-new').onclick = () => this.newFile();
                document.getElementById('menu-open').onclick = () => document.getElementById('file-open-json').click();
                document.getElementById('menu-save').onclick = () => this.saveJSON(false);
                document.getElementById('menu-saveas').onclick = () => this.saveJSON(true);
                document.getElementById('menu-import-model').onclick = () => document.getElementById('file-import-asset').click();
                document.getElementById('menu-export-glb').onclick = () => this.exportModel('glb');
                document.getElementById('menu-export-obj').onclick = () => this.exportModel('obj');

                const tools = ['select','add','erase','move','rotate','scale','cam-move','cam-rotate','cam-zoom','cam-laps'];
                tools.forEach(t => {
                    const el = document.getElementById('tool-'+t);
                    if(el) el.onclick = () => this.setTool(t);
                });

                document.getElementById('file-import-asset').addEventListener('change', (e) => this.handleImportAssets(e.target.files));
                document.getElementById('file-open-json').addEventListener('change', (e) => this.handleOpenJSON(e));

                ['p-id','p-pos-x','p-pos-y','p-pos-z','p-rot-x','p-rot-y','p-rot-z','p-scl-x','p-scl-y','p-scl-z'].forEach(id => {
                    document.getElementById(id).addEventListener('change', () => this.applyPartProps());
                });

                document.getElementById('c-name').addEventListener('input', (e) => this.containerData.name = e.target.value);
                document.getElementById('c-symmetry').addEventListener('change', (e) => this.containerData.symmetry = e.target.value);
                document.querySelectorAll('.c-socket').forEach(sel => {
                    sel.addEventListener('change', (e) => {
                        this.containerData.sockets[parseInt(e.target.dataset.face)] = e.target.value;
                        this.updateSocketVisuals();
                    });
                });

                document.getElementById('menu-setting').onclick = () => document.getElementById('modal-overlay').style.display = 'flex';
                document.getElementById('btn-setting-cancel').onclick = () => document.getElementById('modal-overlay').style.display = 'none';
                document.getElementById('btn-setting-ok').onclick = () => {
                    this.gridSize.x = parseInt(document.getElementById('set-grid-x').value);
                    this.gridSize.y = parseInt(document.getElementById('set-grid-y').value);
                    this.gridSize.z = parseInt(document.getElementById('set-grid-z').value);
                    this.voxelSize = parseFloat(document.getElementById('set-voxel-size').value);
                    this.updateEnvironment();
                    document.getElementById('modal-overlay').style.display = 'none';
                };

                const assetList = document.getElementById('asset-list');
                assetList.addEventListener('contextmenu', (e) => {
                    e.preventDefault();
                    const ctxMenu = document.getElementById('context-menu');
                    ctxMenu.style.display = 'block';
                    ctxMenu.style.left = e.pageX + 'px';
                    ctxMenu.style.top = e.pageY + 'px';
                    
                    const item = e.target.closest('.asset-item');
                    if (item) {
                        const idx = parseInt(item.dataset.index);
                        this.contextTargetAsset = this.assets[idx];
                        document.getElementById('ctx-unload').style.display = 'block';
                        document.getElementById('ctx-import').style.display = 'none';
                    } else {
                        this.contextTargetAsset = null;
                        document.getElementById('ctx-unload').style.display = 'none';
                        document.getElementById('ctx-import').style.display = 'block';
                    }
                });

                document.getElementById('ctx-import').onclick = () => document.getElementById('file-import-asset').click();
                document.getElementById('ctx-unload').onclick = () => {
                    if (this.contextTargetAsset) this.unloadAsset(this.contextTargetAsset);
                };
            }

            unloadAsset(asset) {
                if(!asset) return;
                for(let i=this.parts.length-1; i>=0; i--) {
                    if(this.parts[i].userData.assetName === asset.name) {
                        this.deletePart(this.parts[i]);
                    }
                }
                this.assets = this.assets.filter(a => a !== asset);
                delete this.savedAssets[asset.name]; 
                if(this.currentAsset === asset) this.currentAsset = null;
                this.renderAssetList();
            }

            updateEnvironment() {
                while(this.gridGroup.children.length > 0) this.gridGroup.remove(this.gridGroup.children[0]);
                document.getElementById('labels-container').innerHTML = '';

                const sizeX = this.gridSize.x * this.voxelSize;
                const sizeZ = this.gridSize.z * this.voxelSize;
                
                const floorGrid = new THREE.GridHelper(Math.max(sizeX, sizeZ), Math.max(this.gridSize.x, this.gridSize.z), 0x555555, 0x2a2a2a);
                this.gridGroup.add(floorGrid);

                const halfX = sizeX / 2;
                const halfZ = sizeZ / 2;
                
                const addWall = (w, h, rot, pos) => {
                    const g = new THREE.GridHelper(w, Math.round(w/this.voxelSize), 0x444444, 0x222222);
                    g.rotation.copy(rot);
                    g.position.copy(pos);
                    this.gridGroup.add(g);
                    return g;
                };

                const rotX = new THREE.Euler(-Math.PI/2, 0, 0);
                const rotZ = new THREE.Euler(0, 0, -Math.PI/2);

                addWall(sizeX, this.gridSize.y, rotX, new THREE.Vector3(0, sizeX/2, -halfZ));
                addWall(sizeX, this.gridSize.y, rotX, new THREE.Vector3(0, sizeX/2, halfZ));
                addWall(sizeZ, this.gridSize.z, rotZ, new THREE.Vector3(-halfX, sizeZ/2, 0));
                addWall(sizeZ, this.gridSize.z, rotZ, new THREE.Vector3(halfX, sizeZ/2, 0));

                const addLabel = (pos, txt, col) => {
                    const el = document.createElement('div');
                    el.className = 'arrow-label';
                    el.style.color = col; el.innerText = txt;
                    el.dataset.pos = JSON.stringify(pos);
                    document.getElementById('labels-container').appendChild(el);
                };
                addLabel({x: halfX+2, y:0, z:0}, "X+", "#ff4444");
                addLabel({x: 0, y:0, z:halfZ+2}, "Z+", "#0088ff");
                addLabel({x: 0, y:sizeX+1, z:-halfZ}, "Y+", "#44ff44");

                this.cursorPoint.scale.set(this.voxelSize, this.voxelSize, this.voxelSize);
                this.cursorSelect.scale.set(this.voxelSize, this.voxelSize, this.voxelSize);

                this.updateSocketVisuals();
            }

            // --- Asset Import & Saving Logic ---
            
            // Helper to load model into 3D scene
            loadModel(name, url, ext) {
                return new Promise((resolve, reject) => {
                    // Setup Manager for this load to handle textures
                    const manager = new THREE.LoadingManager();
                    manager.addHandler( /\.dds$/i, new DDSLoader() );
                    manager.addHandler( /\.tga$/i, new TGALoader() );
                    
                    // Universal URL modifier for this load
                    manager.setURLModifier((reqUrl) => {
                        if (!reqUrl || reqUrl.startsWith('data:') || reqUrl.startsWith('blob:')) return reqUrl;
                        const cleanName = reqUrl.replace(/^.*[\\\/]/, '');
                        if (this.fileMap[cleanName]) return this.fileMap[cleanName];
                        if (this.fileMap[cleanName.toLowerCase()]) return this.fileMap[cleanName.toLowerCase()];
                        return this.emptyTexture;
                    });

                    let loader = null;
                    if (ext === 'glb' || ext === 'gltf') loader = new GLTFLoader(manager);
                    else if (ext === 'fbx') loader = new FBXLoader(manager);
                    else if (ext === 'obj') loader = new OBJLoader(manager);
                    
                    if(!loader) { reject("Unknown format"); return; }

                    loader.load(url, (loaded) => {
                        let model = loaded.scene || loaded;
                        
                        // Check if asset entry exists, if not create/update it
                        let assetObj = this.assets.find(a => a.name === name);
                        if(assetObj) {
                            assetObj.model = model;
                        } else {
                            assetObj = { name: name, model: model };
                            this.assets.push(assetObj);
                        }
                        this.renderAssetList();
                        resolve(assetObj);
                    }, undefined, (err) => {
                        console.error(err);
                        reject(err);
                    });
                });
            }

            handleImportAssets(files) {
                const modelsToLoad = [];
                
                Array.from(files).forEach(file => {
                    const ext = file.name.split('.').pop().toLowerCase();
                    const url = URL.createObjectURL(file);
                    
                    // 1. Store in FileMap for texture resolution
                    this.fileMap[file.name] = url; 
                    this.fileMap[file.name.toLowerCase()] = url; 

                    // 2. Read file as Base64 for Saving (Async)
                    const reader = new FileReader();
                    reader.onload = (e) => {
                        this.savedAssets[file.name] = e.target.result;
                    };
                    reader.readAsDataURL(file);

                    // 3. Queue for model loading
                    if (['glb', 'gltf', 'fbx', 'obj'].includes(ext)) {
                        modelsToLoad.push({ name: file.name, url: url, ext: ext });
                    }
                });

                if (modelsToLoad.length === 0) return;

                let loadedCount = 0;
                let lastAsset = null;

                modelsToLoad.forEach(item => {
                    document.getElementById('status-bar').innerText = `Loading ${item.name}...`;
                    
                    this.loadModel(item.name, item.url, item.ext).then((asset) => {
                        lastAsset = asset;
                        loadedCount++;
                        if (loadedCount === modelsToLoad.length) {
                             document.getElementById('status-bar').innerText = `Ready. Imported ${loadedCount} assets.`;
                             if (modelsToLoad.length === 1) {
                                 this.currentAsset = lastAsset;
                                 this.renderAssetList(); 
                                 this.setTool('add');
                             }
                        }
                    }).catch(() => {
                        document.getElementById('status-bar').innerText = `Error loading ${item.name}`;
                    });
                });
                
                document.getElementById('file-import-asset').value = '';
            }

            renderAssetList() {
                const list = document.getElementById('asset-list');
                list.innerHTML = '';
                this.assets.forEach((asset, idx) => {
                    const el = document.createElement('div');
                    el.className = 'asset-item';
                    el.dataset.index = idx;
                    el.tabIndex = 0;
                    if(this.currentAsset === asset) el.classList.add('active');
                    el.draggable = true;
                    el.innerHTML = `<div class="asset-icon"></div> ${asset.name}`;
                    
                    el.onclick = () => {
                        this.currentAsset = asset;
                        this.renderAssetList();
                        this.setTool('add');
                    };
                    el.addEventListener('dragstart', (e) => {
                        e.dataTransfer.setData('assetIndex', idx);
                    });
                    list.appendChild(el);
                });
            }

            instantiateAsset(asset, pos, fromDrag) {
                let obj;
                let assetName = "";

                if(!asset || typeof asset === 'string') {
                    // Fallback placeholder
                    assetName = (typeof asset === 'string') ? asset : "Missing_Asset"; 
                    const geo = new THREE.BoxGeometry(this.voxelSize, this.voxelSize, this.voxelSize);
                    const mat = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true });
                    obj = new THREE.Mesh(geo, mat);
                } else {
                    assetName = asset.name;
                    obj = asset.model.clone(true);
                    
                    // Auto-Scale
                    obj.scale.set(1, 1, 1);
                    const box = new THREE.Box3().setFromObject(obj);
                    const size = new THREE.Vector3(); box.getSize(size);
                    const maxDim = Math.max(size.x, size.y, size.z);
                    let scale = 1;
                    if(maxDim > this.voxelSize) scale = this.voxelSize / maxDim;
                    obj.scale.set(scale, scale, scale);
                }

                const wrapper = new THREE.Group();
                wrapper.add(obj);
                wrapper.position.copy(pos);
                
                const hitGeo = new THREE.BoxGeometry(this.voxelSize, this.voxelSize, this.voxelSize);
                const hitMat = new THREE.MeshBasicMaterial({ visible: false });
                const hitMesh = new THREE.Mesh(hitGeo, hitMat);
                hitMesh.userData.isHitBox = true;
                wrapper.add(hitMesh);
                
                wrapper.userData = {
                    isPart: true,
                    assetName: assetName,
                    id: assetName.split('.')[0] + '_' + this.parts.length,
                    hitBox: hitMesh
                };

                this.scene.add(wrapper);
                this.parts.push(wrapper);
                
                if(!fromDrag) this.selectPart(wrapper);
            }

            handleViewportDrop(e) {
                e.preventDefault();
                document.getElementById('drop-zone-overlay').style.display = 'none';

                const rect = this.renderer.domElement.getBoundingClientRect();
                const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
                const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
                
                this.raycaster.setFromCamera(new THREE.Vector2(mx, my), this.camera);
                const intersects = this.raycaster.intersectObject(this.planeGround);
                
                if(intersects.length > 0) {
                    const p = intersects[0].point;
                    const sp = new THREE.Vector3(
                        Math.round(p.x / this.voxelSize) * this.voxelSize,
                        Math.round((p.y + this.voxelSize/2) / this.voxelSize) * this.voxelSize,
                        Math.round(p.z / this.voxelSize) * this.voxelSize
                    );

                    if(e.dataTransfer.files.length > 0) {
                        this.handleImportAssets(e.dataTransfer.files);
                    } else {
                        const idx = e.dataTransfer.getData('assetIndex');
                        if(idx !== null && idx !== undefined && idx !== "") {
                            const asset = this.assets[idx];
                            this.instantiateAsset(asset, sp, true);
                        }
                    }
                }
            }

            onPointerMove(e) {
                const rect = this.renderer.domElement.getBoundingClientRect();
                this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
                this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;

                this.raycaster.setFromCamera(this.mouse, this.camera);

                if(this.isLapping && this.isDragging) {
                     const deltaX = e.clientX - this.dragStartMouse.x;
                     const deltaY = e.clientY - this.dragStartMouse.y;
                     const rotSpeed = 0.005;
                     const deltaQ = new THREE.Quaternion();
                     const qy = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), -deltaX * rotSpeed);
                     deltaQ.multiply(qy);
                     const camRight = new THREE.Vector3(1,0,0).applyQuaternion(this.camera.quaternion);
                     const qx = new THREE.Quaternion().setFromAxisAngle(camRight, -deltaY * rotSpeed);
                     deltaQ.premultiply(qx);
                     this.lapsTargetQ.premultiply(deltaQ);
                     this.dragStartMouse.set(e.clientX, e.clientY);
                     return;
                }

                if(this.isDragging && this.selectedPart && ['move','rotate','scale'].includes(this.currentTool)) {
                    const deltaX = e.clientX - this.dragStartMouse.x;
                    const deltaY = e.clientY - this.dragStartMouse.y;
                    const isLocked = this.axisLock.x || this.axisLock.y || this.axisLock.z;
                    
                    if(this.currentTool === 'move') {
                        if(isLocked) {
                            const mag = (deltaX - deltaY) * 0.05;
                            const base = this.dragStartVal;
                            const snap = (v) => Math.round(v / this.voxelSize) * this.voxelSize;
                            if(this.axisLock.x) this.selectedPart.position.x = snap(base.x + mag);
                            if(this.axisLock.y) this.selectedPart.position.y = snap(base.y + mag);
                            if(this.axisLock.z) this.selectedPart.position.z = snap(base.z + mag);
                        } else {
                             const intersects = this.raycaster.intersectObject(this.planeGround);
                             if(intersects.length > 0) {
                                 const p = intersects[0].point;
                                 this.selectedPart.position.x = Math.round(p.x / this.voxelSize) * this.voxelSize;
                                 this.selectedPart.position.z = Math.round(p.z / this.voxelSize) * this.voxelSize;
                             }
                        }
                    } else if (this.currentTool === 'rotate') {
                         const mag = deltaX * 0.02;
                         const snap = Math.PI / 4; 
                         if(this.axisLock.x) this.selectedPart.rotation.x = Math.round((this.dragStartVal.x + mag)/snap)*snap;
                         else if(this.axisLock.z) this.selectedPart.rotation.z = Math.round((this.dragStartVal.z + mag)/snap)*snap;
                         else this.selectedPart.rotation.y = Math.round((this.dragStartVal.y + mag)/snap)*snap;
                    } else if (this.currentTool === 'scale') {
                        const mag = -deltaY * 0.01 + 1;
                        this.selectedPart.scale.copy(this.dragStartVal).multiplyScalar(mag);
                    }
                    this.updateSelectionBox();
                    this.updatePropsUI();
                    return;
                }

                if (!this.currentTool.startsWith('cam-')) {
                    const intersectsGround = this.raycaster.intersectObject(this.planeGround);
                    if(intersectsGround.length > 0) {
                        const p = intersectsGround[0].point;
                        const ix = Math.round(p.x / this.voxelSize);
                        const iz = Math.round(p.z / this.voxelSize);
                        
                        let maxY = 0;
                        this.parts.forEach(part => {
                            const dx = Math.abs(part.position.x - ix*this.voxelSize);
                            const dz = Math.abs(part.position.z - iz*this.voxelSize);
                            if (dx < 0.1 && dz < 0.1) {
                                maxY = Math.max(maxY, part.position.y + this.voxelSize/2); 
                            }
                        });

                        const cy = (maxY === 0) ? this.voxelSize/2 : (maxY + this.voxelSize/2);
                        this.cursorPoint.position.set(ix*this.voxelSize, cy, iz*this.voxelSize);
                        document.getElementById('height-display').innerText = `Height: ${Math.round((cy - this.voxelSize/2)/this.voxelSize)} Y`;
                    }
                }
            }

            onPointerDown(e) {
                if(e.button !== 0) return;
                this.isDragging = true;
                this.dragStartMouse.set(e.clientX, e.clientY);

                if (this.currentTool.startsWith('cam-')) {
                    if (this.currentTool === 'cam-laps') {
                        this.controls.enabled = false;
                    }
                    return;
                }

                const cursorGridPos = this.cursorPoint.position.clone();
                this.raycaster.setFromCamera(this.mouse, this.camera);
                
                const intersects = this.raycaster.intersectObjects(this.parts, true);
                
                let hitPart = null;
                if(intersects.length > 0) {
                    let obj = intersects[0].object;
                    while(obj) {
                        if(obj.userData && obj.userData.isPart) {
                            hitPart = obj;
                            break;
                        }
                        obj = obj.parent;
                    }
                }

                if(this.currentTool === 'add') {
                    if(this.currentAsset) {
                        this.instantiateAsset(this.currentAsset, cursorGridPos);
                    } else {
                        alert("Select an asset first.");
                    }
                    return;
                }

                if(this.currentTool === 'select') {
                    this.selectPart(hitPart);
                } else if (this.currentTool === 'erase') {
                    if(hitPart) this.deletePart(hitPart);
                } else if (['move','rotate','scale'].includes(this.currentTool)) {
                    if(hitPart) {
                        this.selectPart(hitPart);
                        this.controls.enabled = false;
                        
                        let startVal;
                        if(this.currentTool === 'move') startVal = hitPart.position.clone();
                        else if(this.currentTool === 'rotate') startVal = new THREE.Vector3(hitPart.rotation.x, hitPart.rotation.y, hitPart.rotation.z);
                        else startVal = hitPart.scale.clone();
                        
                        this.dragStartVal.copy(startVal);
                    } else {
                        this.selectPart(null);
                    }
                }
            }

            onPointerUp() {
                this.isDragging = false;
                this.controls.enabled = true;
            }

            onKeyDown(e) {
                if(e.target.tagName === 'INPUT') return;

                if(e.key === 'PageUp') { e.preventDefault(); this.camera.position.multiplyScalar(0.9); }
                if(e.key === 'PageDown') { e.preventDefault(); this.camera.position.multiplyScalar(1.1); }
                if(e.key === 'ArrowUp') { e.preventDefault(); this.cursorPoint.position.y += this.voxelSize; }
                if(e.key === 'ArrowDown') { e.preventDefault(); this.cursorPoint.position.y -= this.voxelSize; }

                if(e.key.toLowerCase() === 'c') {
                    if(this.selectedPart) {
                        this.clipboard = {
                            assetName: this.selectedPart.userData.assetName,
                            rot: this.selectedPart.rotation.clone(),
                            scl: this.selectedPart.scale.clone()
                        };
                        document.getElementById('status-bar').innerText = "Copied to clipboard";
                    }
                }
                if(e.key.toLowerCase() === 'v') {
                    if(this.clipboard) {
                        const asset = this.assets.find(a => a.name === this.clipboard.assetName);
                        this.instantiateAsset(asset || this.clipboard.assetName, this.cursorPoint.position);
                        const pasted = this.parts[this.parts.length-1];
                        pasted.rotation.copy(this.clipboard.rot);
                        pasted.scale.copy(this.clipboard.scl);
                        this.selectPart(pasted);
                        document.getElementById('status-bar').innerText = "Pasted";
                    }
                }

                if(e.key === 'Delete') {
                    if (document.activeElement && document.activeElement.classList.contains('asset-item')) {
                        const idx = parseInt(document.activeElement.dataset.index);
                        const asset = this.assets[idx];
                        if (asset) this.unloadAsset(asset);
                        return;
                    } else {
                        this.deleteSelection();
                    }
                }

                if(e.key.toLowerCase() === 'x') this.axisLock.x = true;
                if(e.key.toLowerCase() === 'y') this.axisLock.y = true;
                if(e.key.toLowerCase() === 'z') this.axisLock.z = true;
                
                if (e.key === 'F1') { e.preventDefault(); this.viewCam('default'); }
                if (e.key === 'F2') { e.preventDefault(); this.viewCam('top'); }
            }

            onKeyUp(e) {
                if(e.key.toLowerCase() === 'x') this.axisLock.x = false;
                if(e.key.toLowerCase() === 'y') this.axisLock.y = false;
                if(e.key.toLowerCase() === 'z') this.axisLock.z = false;
            }

            setTool(tool) {
                this.currentTool = tool;
                const names = { 
                    'select':'Select', 'add':'Add Part', 'erase':'Erase', 
                    'move':'Move', 'rotate':'Rotate', 'scale':'Scale',
                    'cam-move':'Camera Pan', 'cam-rotate':'Camera Rotate', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps'
                };
                document.getElementById('mode-display').innerText = names[tool] || tool;
                document.getElementById('mode-display').style.borderLeftColor = (tool === 'add' ? '#e74c3c' : '#007fd4');
                
                this.isLapping = (tool === 'cam-laps');
                if (this.isLapping) {
                    this.controls.enabled = false;
                    const relPos = this.camera.position.clone();
                    this.lapsRadius = relPos.length();
                    const vBase = new THREE.Vector3(0,0,1);
                    this.lapsQ.setFromUnitVectors(vBase, relPos.normalize());
                    this.lapsTargetQ.copy(this.lapsQ);
                } else {
                    this.controls.enabled = true;
                    if (tool === 'cam-move') this.controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
                    else if (tool === 'cam-rotate') this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
                    else if (tool === 'cam-zoom') this.controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
                    else this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
                }
            }

            selectPart(part) {
                this.selectedPart = part;
                if(part) {
                    this.cursorSelect.visible = true;
                    this.updateSelectionBox();
                    document.getElementById('part-props-container').classList.remove('hidden');
                    document.getElementById('container-props-container').classList.add('hidden');
                    this.updatePropsUI();
                } else {
                    this.cursorSelect.visible = false;
                    document.getElementById('part-props-container').classList.add('hidden');
                    document.getElementById('container-props-container').classList.remove('hidden');
                }
            }

            deletePart(part) {
                if(!part) return;
                this.scene.remove(part);
                this.parts = this.parts.filter(p => p !== part);
                if(this.selectedPart === part) this.selectPart(null);
            }

            deleteSelection() {
                this.deletePart(this.selectedPart);
            }

            updateSelectionBox() {
                if(!this.selectedPart) return;
                const box = new THREE.Box3().setFromObject(this.selectedPart.userData.hitBox);
                const size = new THREE.Vector3(); box.getSize(size);
                const center = new THREE.Vector3(); box.getCenter(center);
                this.cursorSelect.position.copy(center);
                this.cursorSelect.scale.copy(size);
            }

            updatePropsUI() {
                if(!this.selectedPart) return;
                const p = this.selectedPart;
                document.getElementById('p-id').value = p.userData.id;
                document.getElementById('p-pos-x').value = p.position.x;
                document.getElementById('p-pos-y').value = p.position.y;
                document.getElementById('p-pos-z').value = p.position.z;
                
                const toDeg = (r) => Math.round(r * 180/Math.PI);
                document.getElementById('p-rot-x').value = toDeg(p.rotation.x);
                document.getElementById('p-rot-y').value = toDeg(p.rotation.y);
                document.getElementById('p-rot-z').value = toDeg(p.rotation.z);
                
                document.getElementById('p-scl-x').value = p.scale.x;
                document.getElementById('p-scl-y').value = p.scale.y;
                document.getElementById('p-scl-z').value = p.scale.z;
            }

            applyPartProps() {
                if(!this.selectedPart) return;
                const p = this.selectedPart;
                p.userData.id = document.getElementById('p-id').value;
                
                const px = parseFloat(document.getElementById('p-pos-x').value);
                const py = parseFloat(document.getElementById('p-pos-y').value);
                const pz = parseFloat(document.getElementById('p-pos-z').value);
                p.position.set(px, py, pz);
                
                const toRad = (d) => d * Math.PI / 180;
                const rx = toRad(parseFloat(document.getElementById('p-rot-x').value));
                const ry = toRad(parseFloat(document.getElementById('p-rot-y').value));
                const rz = toRad(parseFloat(document.getElementById('p-rot-z').value));
                p.rotation.set(rx, ry, rz);
                
                const sx = parseFloat(document.getElementById('p-scl-x').value);
                const sy = parseFloat(document.getElementById('p-scl-y').value);
                const sz = parseFloat(document.getElementById('p-scl-z').value);
                p.scale.set(sx, sy, sz);

                this.updateSelectionBox();
            }

            // --- File Ops with Embedded Assets ---
            newFile() {
                if(confirm("Clear scene?")) {
                    this.parts.forEach(p => this.scene.remove(p));
                    this.parts = [];
                    this.selectPart(null);
                }
            }

            saveJSON(asNew) {
                const data = {
                    container: this.containerData,
                    socketTypes: this.socketTypes,
                    grid: { voxelSize: this.voxelSize, dim: this.gridSize },
                    // Save Asset Data Library
                    assets: this.savedAssets,
                    parts: this.parts.map(p => ({
                        assetName: p.userData.assetName,
                        id: p.userData.id,
                        pos: p.position.toArray(),
                        rot: p.rotation.toArray(),
                        scl: p.scale.toArray()
                    }))
                };
                const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
                const a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                let name = this.containerData.name + '.json';
                if(asNew) {
                    const input = prompt("Filename:", name);
                    if(!input) return;
                    name = input;
                    if(!name.endsWith('.json')) name += '.json';
                }
                a.download = name;
                a.click();
            }

            async handleOpenJSON(e) {
                const file = e.target.files[0];
                if(!file) return;
                
                document.getElementById('status-bar').innerText = "Loading JSON...";
                const text = await file.text();
                const data = JSON.parse(text);
                
                // 1. Restore Assets
                if (data.assets) {
                    document.getElementById('status-bar').innerText = "Restoring assets...";
                    for (const [name, dataURI] of Object.entries(data.assets)) {
                        this.savedAssets[name] = dataURI;
                        
                        // Convert back to blob for Loading Manager
                        const res = await fetch(dataURI);
                        const blob = await res.blob();
                        const url = URL.createObjectURL(blob);
                        
                        this.fileMap[name] = url;
                        this.fileMap[name.toLowerCase()] = url;
                        
                        // Re-load models if they are GLB/FBX/OBJ
                        const ext = name.split('.').pop().toLowerCase();
                        if (['glb','gltf','fbx','obj'].includes(ext)) {
                            try {
                                await this.loadModel(name, url, ext);
                            } catch(err) {
                                console.warn("Failed to restore model:", name, err);
                            }
                        }
                    }
                }

                // 2. Clear Scene
                this.parts.forEach(p => this.scene.remove(p));
                this.parts = [];
                this.selectPart(null);

                // 3. Restore Data
                if(data.container) {
                    this.containerData = data.container;
                    document.getElementById('c-name').value = this.containerData.name;
                    document.getElementById('c-symmetry').value = this.containerData.symmetry;
                    this.containerData.sockets.forEach((s, i) => {
                        const sel = document.querySelector(`.c-socket[data-face="${i}"]`);
                        if(sel) sel.value = s;
                    });
                }
                if(data.socketTypes) {
                    this.socketTypes = data.socketTypes;
                    this.renderSocketUI();
                }
                if(data.parts) {
                    data.parts.forEach(pData => {
                        // Find Loaded Asset
                        const asset = this.assets.find(a => a.name === pData.assetName);
                        // Instantiate
                        this.instantiateAsset(asset || pData.assetName, new THREE.Vector3().fromArray(pData.pos));
                        
                        // Apply Transforms
                        const newPart = this.parts[this.parts.length-1];
                        newPart.rotation.fromArray(pData.rot);
                        newPart.scale.fromArray(pData.scl);
                        newPart.userData.id = pData.id;
                    });
                }
                this.updateSocketVisuals();
                document.getElementById('status-bar').innerText = "Ready.";
                e.target.value = '';
            }

            exportModel(format) {
                const exporter = format === 'glb' ? new GLTFExporter() : new OBJExporter();
                const exportGroup = new THREE.Group();
                this.parts.forEach(p => {
                    const wrapperClone = p.clone();
                    const toRemove = [];
                    wrapperClone.traverse(c => {
                        if (c.userData.isHitBox || (c.isMesh && c.material && c.material.visible === false)) {
                             toRemove.push(c);
                        }
                    });
                    toRemove.forEach(c => { if(c.parent) c.parent.remove(c); });
                    
                    wrapperClone.traverse(c => {
                        if (c.isMesh && c.material) {
                            if (c.material.type !== 'MeshStandardMaterial' && c.material.type !== 'MeshBasicMaterial') {
                                const oldMat = c.material;
                                const newMat = new THREE.MeshStandardMaterial();
                                if(oldMat.color) newMat.color.copy(oldMat.color);
                                if(oldMat.map) newMat.map = oldMat.map;
                                c.material = newMat;
                            }
                        }
                    });
                    exportGroup.add(wrapperClone);
                });
                exportGroup.updateMatrixWorld(true);

                if(format === 'glb') {
                    exporter.parse(exportGroup, (result) => {
                        const blob = new Blob([result], { type: 'application/octet-stream' });
                        const a = document.createElement('a');
                        a.href = URL.createObjectURL(blob);
                        a.download = 'model.glb';
                        a.click();
                    }, (err) => { console.error(err); alert("Export failed."); }, { binary: true });
                } else {
                    const result = exporter.parse(exportGroup);
                    const blob = new Blob([result], { type: 'text/plain' });
                    const a = document.createElement('a');
                    a.href = URL.createObjectURL(blob);
                    a.download = 'model.obj';
                    a.click();
                }
            }

            // --- WFC Sockets ---
            addSocketType() {
                const name = prompt("New Socket Name:");
                if(name) {
                    const id = name.toLowerCase().replace(/\s/g,'_');
                    this.socketTypes.push({ id, name });
                    this.renderSocketUI();
                }
            }
            deleteSocketType() {
                const sel = document.getElementById('socket-type-list');
                const val = sel.value;
                if(!val || val === 'empty' || val === 'wall') return;
                if(confirm("Delete socket type?")) {
                    this.socketTypes = this.socketTypes.filter(t => t.id !== val);
                    this.renderSocketUI();
                    this.updateSocketVisuals();
                }
            }
            renderSocketUI() {
                const list = document.getElementById('socket-type-list');
                list.innerHTML = '';
                this.socketTypes.forEach(t => {
                    const opt = document.createElement('option');
                    opt.value = t.id; opt.innerText = t.name;
                    list.appendChild(opt);
                });
                document.querySelectorAll('.c-socket').forEach(sel => {
                    const val = sel.value;
                    sel.innerHTML = '';
                    this.socketTypes.forEach(t => {
                        const opt = document.createElement('option');
                        opt.value = t.id; opt.innerText = t.name;
                        sel.appendChild(opt);
                    });
                    if(val) sel.value = val;
                });
            }
            updateSocketVisuals() {
                for(let i=this.scene.children.length-1; i>=0; i--) {
                    if(this.scene.children[i].userData.isSocketHelper) this.scene.remove(this.scene.children[i]);
                }
                const rx = (this.gridSize.x * this.voxelSize) / 2;
                const ry = (this.gridSize.y * this.voxelSize) / 2;
                const rz = (this.gridSize.z * this.voxelSize) / 2;
                const centerY = ry; 
                const dirs = [
                    {v: new THREE.Vector3(rx,centerY,0), c:0}, {v: new THREE.Vector3(-rx,centerY,0), c:1},
                    {v: new THREE.Vector3(0,centerY*2,0), c:2}, {v: new THREE.Vector3(0,0,0), c:3},
                    {v: new THREE.Vector3(0,centerY,rz), c:4}, {v: new THREE.Vector3(0,centerY,-rz), c:5}
                ];
                dirs.forEach((d, i) => {
                    const faceColor = this.faceColors[i];
                    const geo = new THREE.SphereGeometry(this.voxelSize * 0.4, 16, 16);
                    const mat = new THREE.MeshBasicMaterial({ color: faceColor, wireframe: true });
                    const mesh = new THREE.Mesh(geo, mat);
                    mesh.position.copy(d.v);
                    mesh.userData.isSocketHelper = true;
                    this.scene.add(mesh);
                });
            }

            viewCam(dir) {
                const dist = 30;
                switch(dir) {
                    case 'default': this.camera.position.set(15,15,15); break;
                    case 'top': this.camera.position.set(0, dist, 0); break;
                    case 'bottom': this.camera.position.set(0, -dist, 0); break;
                    case 'front': this.camera.position.set(0, 0, dist); break;
                    case 'back': this.camera.position.set(0, 0, -dist); break;
                    case 'left': this.camera.position.set(-dist, 0, 0); break;
                    case 'right': this.camera.position.set(dist, 0, 0); break;
                }
                this.camera.lookAt(0,0,0);
            }

            onResize() {
                const c = document.getElementById('canvas-container');
                this.camera.aspect = c.clientWidth / c.clientHeight;
                this.camera.updateProjectionMatrix();
                this.renderer.setSize(c.clientWidth, c.clientHeight);
            }

            addAxisLabel(pos, text, color) {
                const div = document.createElement('div');
                div.className = 'axis-label';
                div.textContent = text;
                div.style.backgroundColor = color;
                div.dataset.pos = JSON.stringify(pos);
                div.dataset.type = 'dynamic'; 
                document.getElementById('labels-container').appendChild(div);
            }

            updateAxisGuides() {
                const dynamics = Array.from(document.querySelectorAll('.axis-label[data-type="dynamic"]'));
                dynamics.forEach(el => el.remove());

                let pos = null;
                if(this.selectedPart) pos = this.selectedPart.position;
                else if(this.cursorPoint.visible) pos = this.cursorPoint.position;
                
                if(!pos || (!this.axisLock.x && !this.axisLock.y && !this.axisLock.z)) {
                    this.axisGuideGroup.visible = false;
                    return;
                }

                this.axisGuideGroup.visible = true;
                this.axisGuideGroup.position.copy(pos);
                this.axisArrows.x.visible = this.axisLock.x;
                this.axisArrows.y.visible = this.axisLock.y;
                this.axisArrows.z.visible = this.axisLock.z;

                if(this.axisLock.x) {
                    this.addAxisLabel(pos.clone().add(new THREE.Vector3(3,0,0)), "+X", "#aa0000");
                    this.addAxisLabel(pos.clone().add(new THREE.Vector3(-3,0,0)), "-X", "#aa0000");
                }
                if(this.axisLock.y) {
                    this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,3,0)), "+Y", "#00aa00");
                    this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,-3,0)), "-Y", "#00aa00");
                }
                if(this.axisLock.z) {
                    this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,0,3)), "+Z", "#0044aa");
                    this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,0,-3)), "-Z", "#0044aa");
                }
            }

            animate() {
                requestAnimationFrame(() => this.animate());
                if(this.isLapping) {
                    this.lapsQ.slerp(this.lapsTargetQ, 0.1);
                    const v = new THREE.Vector3(0, 0, this.lapsRadius);
                    v.applyQuaternion(this.lapsQ);
                    this.camera.position.copy(v);
                    this.camera.lookAt(0,0,0);
                    this.controls.update();
                } else {
                    this.controls.update();
                }
                this.updateAxisGuides();
                
                const width = this.renderer.domElement.clientWidth;
                const height = this.renderer.domElement.clientHeight;
                const labels = Array.from(document.querySelectorAll('.arrow-label, .axis-label'));
                labels.forEach(el => {
                    const pos = JSON.parse(el.dataset.pos);
                    const v = new THREE.Vector3(pos.x, pos.y, pos.z);
                    v.project(this.camera);
                    if(v.z > 1) { el.style.display = 'none'; }
                    else {
                        el.style.display = 'block';
                        el.style.left = ((v.x+1)/2 * width) + 'px';
                        el.style.top = (-(v.y-1)/2 * height) + 'px';
                    }
                });
                this.renderer.render(this.scene, this.camera);
            }
        }

        window.app = new EditorApp();
    </script>
</body>
</html>


・マップ・エディタのソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>WFC Map Editor Ultimate</title>
    <style>
        /* --- Common UI Styles --- */
        body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #ddd; display: flex; flex-direction: column; height: 100vh; user-select: none; }
        
        /* Menu Bar */
        #menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 5px; border-bottom: 1px solid #3e3e3e; }
        .menu-item { padding: 5px 10px; cursor: pointer; font-size: 13px; color: #ccc; position: relative; }
        .menu-item:hover { background: #3e3e3e; color: #fff; }
        
        .dropdown { position: relative; display: inline-block; }
        .dropdown-content {
            display: none; position: absolute; top: 100%; left: 0; background-color: #252526; 
            min-width: 180px; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.5); z-index: 1000;
            border: 1px solid #454545;
        }
        .dropdown:hover .dropdown-content { display: block; }
        .dd-item { color: #ddd; padding: 8px 16px; display: block; font-size: 12px; cursor: pointer; }
        .dd-item:hover { background-color: #094771; color: white; }
        .dd-separator { border-top: 1px solid #454545; margin: 4px 0; }
        .shortcut { float: right; color: #888; font-size: 10px; }

        /* Workspace */
        #workspace { display: flex; flex: 1; overflow: hidden; }
        .sidebar { width: 280px; background: #252526; display: flex; flex-direction: column; border-right: 1px solid #3e3e3e; border-left: 1px solid #3e3e3e; }
        #sidebar-right { border-left: 1px solid #3e3e3e; border-right: none; }
        .panel-header { background: #2d2d2d; padding: 8px 10px; font-weight: bold; font-size: 12px; text-transform: uppercase; color: #aaa; }
        .panel-content { flex: 1; overflow-y: auto; padding: 10px; }

        /* Viewport */
        #viewport { flex: 1; position: relative; background: #1e1e1e; outline: none; overflow: hidden; }
        #loading-overlay {
            position: absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7);
            display:none; align-items:center; justify-content:center; color:white; font-size:20px; z-index:20;
        }

        /* Palette List */
        .palette-item { 
            padding: 8px; margin-bottom: 4px; background: #333; border-radius: 2px; cursor: pointer; font-size: 12px; 
            display: flex; align-items: center; border: 1px solid transparent;
        }
        .palette-item:hover { background: #3e3e3e; }
        .palette-item.selected { border-color: #007acc; background: #2a2d2e; }
        .color-dot { width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; border:1px solid #555; }

        /* Forms */
        label { display: block; margin-top: 10px; font-size: 11px; color: #888; }
        input, button, select { 
            width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #555; 
            color: #eee; padding: 6px; margin-top: 4px; border-radius: 2px; font-size: 12px;
        }
        input:focus, button:focus { border-color: #007acc; outline: none; }
        button { cursor: pointer; background: #0e639c; color: white; border: none; }
        button:hover { background: #1177bb; }
        button.secondary { background: #444; }
        button.secondary:hover { background: #555; }
        
        .row { display: flex; gap: 5px; }
        hr { border: 0; border-top: 1px solid #444; margin: 15px 0; }

        #cursor-info {
            position: absolute; bottom: 10px; left: 10px; 
            background: rgba(0,0,0,0.6); padding: 5px 10px; 
            border-radius: 4px; pointer-events: none; font-size: 12px; color: #fff;
        }
    </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="menubar">
        <div class="dropdown">
            <div class="menu-item">File</div>
            <div class="dropdown-content">
                <div class="dd-item" onclick="app.newMap()">New Map</div>
                <div class="dd-item" onclick="document.getElementById('file-map-json').click()">Open Map JSON...</div>
                <div class="dd-item" onclick="app.saveMap()">Save Map JSON</div>
                <div class="dd-separator"></div>
                <div class="dd-item" onclick="document.getElementById('file-assets').click()">1. Import Assets (GLB/FBX)</div>
                <div class="dd-item" onclick="document.getElementById('file-config').click()">2. Import Container Config</div>
            </div>
        </div>
        <div class="dropdown">
            <div class="menu-item">Edit</div>
            <div class="dropdown-content">
                <div class="dd-item" onclick="app.wfc.reset()">Clear Map</div>
            </div>
        </div>
        <div style="flex:1"></div>
        <div style="font-size:11px; color:#666; margin-right:10px;">WFC Map Editor Ultimate</div>
    </div>

    <div id="workspace">
        <div class="sidebar">
            <div class="panel-header">Container Palette</div>
            <div class="panel-content" id="palette-list">
                <div style="text-align:center; color:#666; font-size:11px; margin-top:20px;">
                    Please import<br>Container Config JSON
                </div>
            </div>
        </div>

        <div id="viewport">
            <div id="loading-overlay">Processing...</div>
            <div id="cursor-info">Ready</div>
        </div>

        <div class="sidebar" id="sidebar-right">
            <div class="panel-header">Map Settings</div>
            <div class="panel-content">
                <label>Grid Size</label>
                <div class="row">
                    <input type="number" id="grid-x" value="8" min="2">
                    <input type="number" id="grid-y" value="4" min="1">
                    <input type="number" id="grid-z" value="8" min="2">
                </div>
                <button class="secondary" onclick="app.resizeMap()">Resize Map</button>

                <hr>
                <label>Wave Function Collapse</label>
                <button onclick="app.runAutoFill()">Auto Fill (Solve)</button>
                <button class="secondary" onclick="app.stepWFC()" style="margin-top:5px;">1 Step</button>
                
                <hr>
                <label>Cell Properties</label>
                <div id="cell-info" style="font-size:11px; color:#aaa; margin-top:5px;">
                    Select a cell...
                </div>
            </div>
        </div>
    </div>

    <input type="file" id="file-assets" multiple accept=".glb,.gltf,.fbx,.obj" style="display:none">
    <input type="file" id="file-config" accept=".json" style="display:none">
    <input type="file" id="file-map-json" accept=".json" style="display:none">

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
        import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
        import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';

        // --- WFC Logic ---
        class WFCModule {
            constructor(config, rotation = 0) {
                this.id = config.container.name; // Unique identifier base
                this.uid = `${config.container.name}_r${rotation}`; // Variant ID
                this.config = config;
                this.rotation = rotation; // 0, 1, 2, 3 (x90 deg)
                
                // Calculate rotated sockets
                // Original: [px, nx, py, ny, pz, nz]
                // Rot 1 (90): px->pz, nx->nz, pz->nx, nz->px (simplified Y-rotation)
                this.sockets = this.rotateSockets(config.container.sockets, rotation);
            }

            rotateSockets(sockets, rot) {
                // sockets: 0:px, 1:nx, 2:py, 3:ny, 4:pz, 5:nz
                let s = [...sockets];
                for(let i=0; i<rot; i++) {
                    const next = [...s];
                    next[0] = s[5]; // px <- nz
                    next[1] = s[4]; // nx <- pz
                    next[4] = s[0]; // pz <- px
                    next[5] = s[1]; // nz <- nx
                    // py(2), ny(3) rotate in place? Assuming sockets have rotational symmetry IDs or just match strings.
                    // For simple string matching "wall"=="wall", no change needed for py/ny unless socket itself has direction.
                    // We assume sockets are isotropic for now.
                    s = next;
                }
                return s;
            }
        }

        class WFCCell {
            constructor(x, y, z, modules) {
                this.x = x; this.y = y; this.z = z;
                this.collapsed = false;
                this.options = [...modules]; // Available modules
                this.module = null; // Final choice
            }
        }

        class WFCManager {
            constructor(sx, sy, sz, configs) {
                this.size = {x:sx, y:sy, z:sz};
                this.configs = configs;
                this.modules = this.generateModules(configs);
                this.cells = [];
                this.initGrid();
            }

            generateModules(configs) {
                let mods = [];
                // Always add Empty module
                const emptyConf = { container: { name: 'Empty', sockets: ['empty','empty','empty','empty','empty','empty'], symmetry:'X' }, parts:[] };
                mods.push(new WFCModule(emptyConf, 0));

                configs.forEach(c => {
                    // Generate variants based on symmetry
                    const sym = c.container.symmetry || 'X';
                    const rots = (sym === 'X') ? [0] : 
                                 (sym === 'I') ? [0, 1] : 
                                 [0, 1, 2, 3]; // T, L, D usually imply 4 rotations in grid
                    
                    rots.forEach(r => {
                        mods.push(new WFCModule(c, r));
                    });
                });
                return mods;
            }

            initGrid() {
                this.cells = [];
                for(let x=0; x<this.size.x; x++) {
                    this.cells[x] = [];
                    for(let y=0; y<this.size.y; y++) {
                        this.cells[x][y] = [];
                        for(let z=0; z<this.size.z; z++) {
                            this.cells[x][y][z] = new WFCCell(x, y, z, this.modules);
                        }
                    }
                }
            }

            reset() { this.initGrid(); }

            // Collapse specific cell manually
            forceCollapse(x, y, z, moduleBaseName) {
                const cell = this.cells[x][y][z];
                // Find a variant that matches this name (default to rot 0)
                const target = this.modules.find(m => m.config.container.name === moduleBaseName);
                if(target) {
                    cell.collapsed = true;
                    cell.module = target;
                    cell.options = [target];
                    this.propagate(cell);
                    return true;
                }
                return false;
            }

            clearCell(x, y, z) {
                // Hard reset... logic needs full rebuild usually.
                // Simplified: Just reset this cell to Empty or Reset Whole Grid options (expensive)
                // For this editor, we'll re-init grid but keep collapsed cells
                const oldCells = this.cells;
                this.initGrid();
                
                // Restore others
                for(let ix=0; ix<this.size.x; ix++) for(let iy=0; iy<this.size.y; iy++) for(let iz=0; iz<this.size.z; iz++) {
                    if(ix===x && iy===y && iz===z) continue; // Skip target
                    const old = oldCells[ix][iy][iz];
                    if(old.collapsed) {
                        const newC = this.cells[ix][iy][iz];
                        newC.collapsed = true;
                        newC.module = old.module;
                        newC.options = [old.module];
                        this.propagate(newC);
                    }
                }
            }

            solveStep() {
                // Find min entropy
                let minEnt = Infinity;
                let candidates = [];
                
                this.loopCells(c => {
                    if(!c.collapsed && c.options.length > 0) {
                        if(c.options.length < minEnt) {
                            minEnt = c.options.length;
                            candidates = [c];
                        } else if(c.options.length === minEnt) {
                            candidates.push(c);
                        }
                    }
                });

                if(candidates.length === 0) return false; // Done or failed
                
                const target = candidates[Math.floor(Math.random() * candidates.length)];
                this.collapse(target);
                this.propagate(target);
                return true;
            }

            collapse(cell) {
                cell.collapsed = true;
                if(cell.options.length === 0) return; // Contradiction
                cell.module = cell.options[Math.floor(Math.random() * cell.options.length)];
                cell.options = [cell.module];
            }

            propagate(startCell) {
                const stack = [startCell];
                // 0:px, 1:nx, 2:py, 3:ny, 4:pz, 5:nz
                // Opposites: 0<->1, 2<->3, 4<->5
                const dirs = [
                    {x:1, y:0, z:0, d:0, o:1}, {x:-1, y:0, z:0, d:1, o:0},
                    {x:0, y:1, z:0, d:2, o:3}, {x:0, y:-1, z:0, d:3, o:2},
                    {x:0, y:0, z:1, d:4, o:5}, {x:0, y:0, z:-1, d:5, o:4}
                ];

                while(stack.length > 0) {
                    const cur = stack.pop();
                    const curOpts = cur.options;
                    if(curOpts.length === 0) continue; // Contradiction state

                    for(let dir of dirs) {
                        const nx = cur.x + dir.x, ny = cur.y + dir.y, nz = cur.z + dir.z;
                        if(nx<0||nx>=this.size.x || ny<0||ny>=this.size.y || nz<0||nz>=this.size.z) continue;
                        
                        const neighbor = this.cells[nx][ny][nz];
                        if(neighbor.collapsed) continue;

                        const origCount = neighbor.options.length;
                        neighbor.options = neighbor.options.filter(nOpt => {
                            // Can nOpt connect to ANY of curOpts?
                            return curOpts.some(cOpt => {
                                return cOpt.sockets[dir.d] === nOpt.sockets[dir.o];
                            });
                        });

                        if(neighbor.options.length === 0) {
                            console.warn("Contradiction at", nx, ny, nz);
                        }

                        if(neighbor.options.length < origCount) {
                            stack.push(neighbor);
                        }
                    }
                }
            }

            loopCells(cb) {
                for(let x=0; x<this.size.x; x++) for(let y=0; y<this.size.y; y++) for(let z=0; z<this.size.z; z++) cb(this.cells[x][y][z]);
            }
        }

        // --- App Logic ---
        class MapEditor {
            constructor() {
                this.scene = null; 
                this.gridSize = {x:8, y:4, z:8};
                this.configs = []; // Loaded Container Configs
                this.assets = {}; // Name -> Three.Object3D
                this.wfc = null;
                this.selectedModuleId = null; // From Palette
                this.cellMeshes = []; // Visualization

                this.initThree();
                this.initUI();
                this.animate();
            }

            initThree() {
                const vp = document.getElementById('viewport');
                this.scene = new THREE.Scene();
                this.scene.background = new THREE.Color(0x222);
                
                this.camera = new THREE.PerspectiveCamera(50, vp.clientWidth/vp.clientHeight, 0.1, 1000);
                this.camera.position.set(10, 10, 10);
                
                this.renderer = new THREE.WebGLRenderer({antialias:true});
                this.renderer.setSize(vp.clientWidth, vp.clientHeight);
                this.renderer.shadowMap.enabled = true;
                vp.appendChild(this.renderer.domElement);
                
                this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
                
                // Lights
                this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
                const dl = new THREE.DirectionalLight(0xffffff, 0.8);
                dl.position.set(10, 20, 10);
                dl.castShadow = true;
                this.scene.add(dl);

                // Grid Helper
                this.gridHelper = new THREE.Group();
                this.scene.add(this.gridHelper);

                // Raycaster
                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
                this.renderer.domElement.addEventListener('pointerdown', (e) => this.onMouseDown(e));
                this.renderer.domElement.addEventListener('mousemove', (e) => this.onMouseMove(e));

                // Hover Box
                this.hoverBox = new THREE.Mesh(
                    new THREE.BoxGeometry(1.05, 1.05, 1.05),
                    new THREE.MeshBasicMaterial({color:0x00ff00, wireframe:true, opacity:0.5, transparent:true})
                );
                this.scene.add(this.hoverBox);
                this.hoverBox.visible = false;
            }

            initUI() {
                // Inputs
                document.getElementById('file-assets').addEventListener('change', (e) => this.loadAssets(e.target.files));
                document.getElementById('file-config').addEventListener('change', (e) => this.loadConfig(e.target.files[0]));
                document.getElementById('file-map-json').addEventListener('change', (e) => this.loadMapJSON(e.target.files[0]));
                
                window.addEventListener('resize', () => {
                    const vp = document.getElementById('viewport');
                    this.camera.aspect = vp.clientWidth/vp.clientHeight;
                    this.camera.updateProjectionMatrix();
                    this.renderer.setSize(vp.clientWidth, vp.clientHeight);
                });

                // Init empty map
                this.resizeMap();
            }

            // --- Logic ---

            async loadAssets(files) {
                const loaders = { 'glb':new GLTFLoader(), 'fbx':new FBXLoader(), 'obj':new OBJLoader() };
                document.getElementById('loading-overlay').style.display = 'flex';
                
                for(let file of files) {
                    const ext = file.name.split('.').pop().toLowerCase();
                    const loader = loaders[ext] || loaders['glb'];
                    const url = URL.createObjectURL(file);
                    try {
                        const gltf = await loader.loadAsync(url);
                        this.assets[file.name] = (gltf.scene || gltf); // Handle GLTF vs FBX
                        console.log("Loaded Asset:", file.name);
                    } catch(e) { console.error(e); }
                }
                document.getElementById('loading-overlay').style.display = 'none';
                this.updateVisuals(); // Refresh if meshes were missing
            }

            loadConfig(file) {
                const reader = new FileReader();
                reader.onload = (e) => {
                    const data = JSON.parse(e.target.result);
                    // data is array of objects from Container Editor
                    // Wrap each in a structure easy to use
                    // The Container Editor exported: [{id, type, sockets, symmetry, transform...}] which is actually Parts?
                    // Ah, the Container Editor exported the SCENE objects list.
                    // We need to group them. But wait, the previous Container Editor exported a LIST of parts, but didn't explicitly group them into "Modules".
                    // **Correction**: The Container Editor logic I wrote previously exports a FLAT list of parts in the scene. 
                    // This implies the scene represents *ONE* container.
                    
                    // Let's assume the user imports MULTIPLE json files, each representing ONE container module.
                    // OR, let's assume the user constructed the scene as multiple containers? 
                    // No, usually Container Editor edits ONE module.
                    
                    // Let's treat the imported JSON as ONE module definition.
                    // Prompt for name? Or use filename.
                    const name = file.name.replace('.json', '');
                    const moduleConfig = {
                        container: {
                            name: name,
                            // Extract container props from the first object that has them, or defaults
                            sockets: data[0]?.sockets || ['empty','empty','empty','empty','empty','empty'],
                            symmetry: data[0]?.symmetry || 'X'
                        },
                        parts: data // The whole list is the parts
                    };
                    
                    // Remove existing if overwrite
                    this.configs = this.configs.filter(c => c.container.name !== name);
                    this.configs.push(moduleConfig);
                    
                    this.renderPalette();
                    // Re-init WFC with new configs
                    this.wfc = new WFCManager(this.gridSize.x, this.gridSize.y, this.gridSize.z, this.configs);
                };
                reader.readAsText(file);
            }

            resizeMap() {
                const x = parseInt(document.getElementById('grid-x').value);
                const y = parseInt(document.getElementById('grid-y').value);
                const z = parseInt(document.getElementById('grid-z').value);
                this.gridSize = {x, y, z};
                
                this.wfc = new WFCManager(x, y, z, this.configs);
                this.initVisualGrid();
            }

            renderPalette() {
                const p = document.getElementById('palette-list');
                p.innerHTML = '';
                
                // Add Empty
                const empty = document.createElement('div');
                empty.className = 'palette-item';
                empty.innerHTML = `<div class="color-dot" style="background:#000"></div>Empty (Clear)`;
                empty.onclick = () => {
                    document.querySelectorAll('.palette-item').forEach(e=>e.classList.remove('selected'));
                    empty.classList.add('selected');
                    this.selectedModuleId = 'Empty';
                };
                p.appendChild(empty);

                this.configs.forEach(c => {
                    const el = document.createElement('div');
                    el.className = 'palette-item';
                    el.innerHTML = `<div class="color-dot" style="background:#007acc"></div>${c.container.name}`;
                    el.onclick = () => {
                        document.querySelectorAll('.palette-item').forEach(e=>e.classList.remove('selected'));
                        el.classList.add('selected');
                        this.selectedModuleId = c.container.name;
                    };
                    p.appendChild(el);
                });
            }

            // --- Visualization ---
            initVisualGrid() {
                // Clear old
                this.cellMeshes.forEach(m => this.scene.remove(m));
                this.cellMeshes = [];
                
                // Center camera
                this.orbit.target.set(this.gridSize.x/2, this.gridSize.y/2, this.gridSize.z/2);

                // Create placeholder meshes for each cell
                const geo = new THREE.BoxGeometry(0.95, 0.95, 0.95);
                
                for(let x=0; x<this.gridSize.x; x++) {
                    for(let y=0; y<this.gridSize.y; y++) {
                        for(let z=0; z<this.gridSize.z; z++) {
                            // Wireframe placeholder
                            const mat = new THREE.MeshBasicMaterial({color:0x333333, wireframe:true, transparent:true, opacity:0.1});
                            const mesh = new THREE.Mesh(geo, mat);
                            mesh.position.set(x + 0.5, y + 0.5, z + 0.5);
                            mesh.userData = {x, y, z, isCell:true};
                            this.scene.add(mesh);
                            this.cellMeshes.push(mesh);
                        }
                    }
                }
            }

            updateVisuals() {
                if(!this.wfc) return;

                // Loop all cells and update representation
                this.wfc.loopCells(cell => {
                    const idx = cell.x * (this.gridSize.y * this.gridSize.z) + cell.y * this.gridSize.z + cell.z;
                    // Find the mesh corresponding to this cell (Naive linear mapping works if order preserved)
                    const mesh = this.cellMeshes.find(m => m.userData.x===cell.x && m.userData.y===cell.y && m.userData.z===cell.z);
                    
                    if(!mesh) return;

                    if(cell.collapsed && cell.module) {
                        // Check if we already have the complex model attached
                        if(mesh.userData.currentModuleUid !== cell.module.uid) {
                            // Clear children
                            while(mesh.children.length > 0) mesh.remove(mesh.children[0]);
                            
                            if(cell.module.config.container.name === 'Empty') {
                                mesh.material.opacity = 0.05;
                                mesh.material.wireframe = true;
                            } else {
                                mesh.material.opacity = 0; // Hide box
                                mesh.material.wireframe = false;
                                
                                // Instantiate Composite Model
                                const group = new THREE.Group();
                                
                                cell.module.config.parts.forEach(part => {
                                    // part.assetName comes from Container Editor (or mapped from file)
                                    // The file import might have extensions, the ID might not.
                                    // Try fuzzy match
                                    let modelKey = Object.keys(this.assets).find(k => k.includes(part.assetName) || part.assetName.includes(k));
                                    if(!modelKey) modelKey = part.assetName; // Try direct

                                    if(this.assets[modelKey]) {
                                        const clone = this.assets[modelKey].clone();
                                        // Apply Part Transform
                                        if(part.transform) {
                                            clone.position.fromArray(part.transform.position);
                                            clone.rotation.fromArray(part.transform.rotation);
                                            clone.scale.fromArray(part.transform.scale);
                                        }
                                        group.add(clone);
                                    } else {
                                        // Placeholder for missing asset
                                        const box = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.5,0.5), new THREE.MeshBasicMaterial({color:0xff0000}));
                                        if(part.transform) box.position.fromArray(part.transform.position);
                                        group.add(box);
                                    }
                                });

                                // Apply Module Rotation (Symmetry)
                                // cell.module.rotation is 0,1,2,3 (x90 deg around Y)
                                group.rotation.y = -cell.module.rotation * (Math.PI / 2); // Minus for standard clockwise
                                
                                mesh.add(group);
                            }
                            mesh.userData.currentModuleUid = cell.module.uid;
                        }
                    } else {
                        // Uncollapsed
                        while(mesh.children.length > 0) mesh.remove(mesh.children[0]);
                        mesh.material.opacity = 0.1;
                        mesh.material.wireframe = true;
                        mesh.userData.currentModuleUid = null;
                    }
                });
            }

            // --- Interaction ---
            onMouseMove(e) {
                const rect = this.renderer.domElement.getBoundingClientRect();
                this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
                this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;

                this.raycaster.setFromCamera(this.mouse, this.camera);
                const intersects = this.raycaster.intersectObjects(this.cellMeshes);
                
                if(intersects.length > 0) {
                    const hit = intersects[0].object;
                    this.hoverBox.position.copy(hit.position);
                    this.hoverBox.visible = true;
                    
                    const c = this.wfc.cells[hit.userData.x][hit.userData.y][hit.userData.z];
                    const status = c.collapsed ? (c.module ? c.module.config.container.name : "Error") : `Candidates: ${c.options.length}`;
                    document.getElementById('cursor-info').innerText = `[${c.x},${c.y},${c.z}] ${status}`;
                } else {
                    this.hoverBox.visible = false;
                    document.getElementById('cursor-info').innerText = "Ready";
                }
            }

            onMouseDown(e) {
                if(!this.hoverBox.visible) return;
                const h = this.hoverBox.position;
                // Coords correspond to floor(position) usually, but we centered at +0.5.
                const x = Math.floor(h.x), y = Math.floor(h.y), z = Math.floor(h.z);
                
                if(e.button === 0) { // Left Click: Place
                    if(this.selectedModuleId) {
                        if(this.selectedModuleId === 'Empty') {
                            this.wfc.forceCollapse(x, y, z, 'Empty');
                        } else {
                            this.wfc.forceCollapse(x, y, z, this.selectedModuleId);
                        }
                        this.updateVisuals();
                    }
                } else if(e.button === 2) { // Right Click: Clear
                    this.wfc.clearCell(x, y, z);
                    this.updateVisuals();
                }
            }

            // --- Actions ---
            runAutoFill() {
                // Run steps until done or max iter
                let limit = 1000;
                const run = () => {
                    if(limit-- <= 0) return;
                    if(this.wfc.solveStep()) {
                        this.updateVisuals();
                        requestAnimationFrame(run);
                    } else {
                        alert("Finished!");
                    }
                };
                run();
            }

            stepWFC() {
                if(this.wfc.solveStep()) this.updateVisuals();
                else alert("No more moves or done.");
            }

            newMap() {
                if(confirm("Create new map?")) {
                    this.wfc.reset();
                    this.updateVisuals();
                }
            }

            saveMap() {
                // Export cells: {x,y,z, moduleId, rotation}
                const data = [];
                this.wfc.loopCells(c => {
                    if(c.collapsed && c.module) {
                        data.push({
                            x:c.x, y:c.y, z:c.z, 
                            module: c.module.config.container.name,
                            rotation: c.module.rotation
                        });
                    }
                });
                const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
                const a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = 'map.json';
                a.click();
            }

            loadMapJSON(file) {
                const reader = new FileReader();
                reader.onload = (e) => {
                    const data = JSON.parse(e.target.result);
                    this.wfc.reset();
                    data.forEach(d => {
                        const cell = this.wfc.cells[d.x][d.y][d.z];
                        // Find specific module variant
                        const mod = this.wfc.modules.find(m => m.config.container.name === d.module && m.rotation === (d.rotation||0));
                        if(mod) {
                            cell.collapsed = true;
                            cell.module = mod;
                            cell.options = [mod];
                            this.wfc.propagate(cell);
                        }
                    });
                    this.updateVisuals();
                };
                reader.readAsText(file);
            }

            animate() {
                requestAnimationFrame(()=>this.animate());
                this.orbit.update();
                this.renderer.render(this.scene, this.camera);
            }
        }

        window.app = new MapEditor();
    </script>
</body>
</html>

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