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="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WFC Map Editor Ultimate v2.0</title>
    <style>
        /* --- Base Styles based on Model Editor --- */
        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: 200px; 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: 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-group { 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 { border-color: #007fd4; outline: none; }
        .row { display: flex; gap: 4px; }

        /* Palette List */
        #palette-list { display: flex; flex-direction: column; gap: 2px; max-height: 200px; overflow-y: auto; margin-bottom: 10px; border: 1px solid #3e3e3e; padding: 2px; }
        .palette-item { 
            padding: 6px; background: #333; cursor: pointer; font-size: 12px; display: flex; align-items: center; border: 1px solid transparent; 
        }
        .palette-item:hover { background: #3e3e3e; color: #fff; }
        .palette-item.active { background: #094771; border-color: #007fd4; color: #fff; }
        .palette-icon { width: 10px; height: 10px; background: #569cd6; margin-right: 8px; border-radius: 2px; }
        
        /* 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; 
        }
        
        /* 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; 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;
        }

        /* 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:hover { background: #1177bb; }
        .dialog-btn.cancel { background: #3e3e3e; margin-left: 10px; }

        /* Helpers */
        .hidden { display: none !important; }
        .face-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; font-size: 11px; color:#aaa; }
        .face-tag { width: 20px; font-weight: bold; text-align: center; }

    </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 Map</div>
                    <div class="dropdown-item" id="menu-open">Open Map JSON</div>
                    <div class="dropdown-item" id="menu-save">Save Map JSON</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" id="menu-import-config">1. Import Config (JSON)...</div>
                    <div class="dropdown-item" id="menu-import-assets">2. Import Models (GLB/OBJ)...</div>
                    <div class="separator"></div>
                    <div class="dropdown-item" id="menu-export-glb">Export Scene GLB</div>
                    <div class="dropdown-item" id="menu-export-obj">Export Scene OBJ</div>
                </div>
            </div>
            <div class="menu-item">Edit
                <div class="dropdown">
                    <div class="dropdown-item" id="tool-select">Select (Esc)</div>
                    <div class="dropdown-item" id="tool-add">Add (WFC Solve)</div>
                    <div class="dropdown-item" id="tool-erase">Erase (Del)</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="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="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 Map Editor v2.0</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">
            <div class="prop-scroll">
                
                <div class="prop-group">
                    <span class="prop-label">Container Palette</span>
                    <div id="palette-list">
                        <div style="padding:10px; color:#666; font-size:11px; text-align:center;">
                            No Containers.<br>Import Config & Models.
                        </div>
                    </div>
                </div>

                <div id="prop-selected" class="hidden">
                    <div class="prop-group">
                        <span class="prop-label" style="color:#4fc1ff">Selected Container</span>
                        <label class="prop-label">Module Name</label>
                        <input type="text" id="p-name" disabled>
                        
                        <label class="prop-label">Grid Position (X, Y, Z)</label>
                        <div class="row">
                            <input type="number" id="p-pos-x" disabled>
                            <input type="number" id="p-pos-y" disabled>
                            <input type="number" id="p-pos-z" disabled>
                        </div>
                        
                        <label class="prop-label">Rotation (0-3)</label>
                        <input type="number" id="p-rot" min="0" max="3" step="1">

                        <label class="prop-label" style="margin-top:10px">Sockets (Current Rot)</label>
                        <div id="p-sockets-list"></div>
                    </div>
                </div>

                <div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">
                    No container selected.
                </div>
            </div>
        </div>
    </div>

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

    <div id="modal-overlay">
        <div id="settings-dialog">
            <h3>Settings</h3>
            <div class="prop-group">
                <span class="prop-label">Grid / Container Size</span>
                <input type="number" id="set-voxel-size" value="1" step="0.1">
            </div>
            <div class="prop-group">
                <span class="prop-label">Grid Dimensions (X, Y, Z)</span>
                <div class="row">
                    <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 { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
        import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';

        // --- Data Classes ---
        class MapCell {
            constructor(x, y, z) {
                this.x = x; this.y = y; this.z = z;
                this.moduleName = null;
                this.rotation = 0; // 0, 1, 2, 3 (x90 deg around Y)
                this.mesh = null; // Visual representation
            }
        }

        class ContainerConfig {
            constructor(name, symmetry, sockets, parts) {
                this.name = name;
                this.symmetry = symmetry; // X, T, I, L, D
                this.sockets = sockets; // [px, nx, py, ny, pz, nz]
                this.parts = parts; // [{assetName, pos, rot, scl}]
            }
        }

        // --- Editor Application ---
        class EditorApp {
            constructor() {
                // State
                this.cells = []; 
                this.configs = []; // Loaded Container Configurations
                this.assets = {}; // Name -> THREE.Object3D
                this.fileMap = {}; // Name -> Blob URL (for textures)
                
                this.currentTool = 'select';
                this.currentConfig = null; // Selected from palette
                this.selectedCell = null;
                
                this.gridSize = { x: 20, y: 20, z: 20 };
                this.voxelSize = 1;

                // Visuals
                this.scene = null;
                this.camera = null;
                this.renderer = null;
                this.controls = null;
                
                // Helpers
                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
                this.gridGroup = new THREE.Group();
                this.axisGuideGroup = new THREE.Group();
                this.cursorTarget = null; // Red box for adding
                this.cursorSelect = null; // Yellow box for selection
                this.shadowPool = [];

                // Interaction State
                this.isDragging = false;
                this.dragStartMouse = new THREE.Vector2();
                this.axisLock = { x: false, y: false, z: false };
                this.isLapping = false;
                this.lapsQ = new THREE.Quaternion();
                this.lapsTargetQ = new THREE.Quaternion();
                this.lapsRadius = 30;

                this.init();
            }

            init() {
                this.initThree();
                this.initUI();
                this.updateEnvironment();
                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;

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

                // Groups
                this.scene.add(this.gridGroup);
                this.scene.add(this.axisGuideGroup);

                // Ground Plane for Raycast
                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);

                this.initCursors();
                this.initAxisGuides();

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

            initCursors() {
                // Target Cursor (Red transparent box for Add)
                const tGeo = new THREE.BoxGeometry(1, 1, 1);
                const tMat = new THREE.MeshBasicMaterial({ color: 0xff3333, transparent: true, opacity: 0.3, depthTest: false });
                this.cursorTarget = new THREE.Mesh(tGeo, tMat);
                this.scene.add(this.cursorTarget);
                this.cursorTarget.visible = false;

                // Selection Cursor (Yellow wireframe)
                const sGeo = new THREE.BoxGeometry(1.05, 1.05, 1.05);
                const sEdges = new THREE.EdgesGeometry(sGeo);
                this.cursorSelect = new THREE.LineSegments(sEdges, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false }));
                this.scene.add(this.cursorSelect);
                this.cursorSelect.visible = false;
            }

            initAxisGuides() {
                const origin = new THREE.Vector3(0,0,0);
                const len = 5;
                const ax = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), origin, len, 0xff0000, 1, 0.5);
                const ay = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), origin, len, 0x00ff00, 1, 0.5);
                const az = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), origin, len, 0x0088ff, 1, 0.5);
                this.axisGuideGroup.add(ax, ay, az);
                this.axisGuideGroup.visible = false;
            }

            initUI() {
                // Menu Bindings
                document.getElementById('menu-new').onclick = () => this.newMap();
                document.getElementById('menu-import-config').onclick = () => document.getElementById('file-config').click();
                document.getElementById('menu-import-assets').onclick = () => document.getElementById('file-assets').click();
                document.getElementById('menu-save').onclick = () => this.saveMapJSON();
                document.getElementById('menu-open').onclick = () => document.getElementById('file-map-json').click();
                document.getElementById('menu-export-glb').onclick = () => this.exportScene('glb');
                document.getElementById('menu-export-obj').onclick = () => this.exportScene('obj');

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

                // File Inputs
                document.getElementById('file-config').onchange = (e) => this.handleImportConfig(e.target.files[0]);
                document.getElementById('file-assets').onchange = (e) => this.handleImportAssets(e.target.files);
                document.getElementById('file-map-json').onchange = (e) => this.handleOpenMap(e.target.files[0]);

                // Settings
                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);
                    const newVoxel = parseFloat(document.getElementById('set-voxel-size').value);
                    if (newVoxel !== this.voxelSize) {
                        this.voxelSize = newVoxel;
                        this.refreshAllCells(); // Re-scale everything
                    }
                    this.updateEnvironment();
                    document.getElementById('modal-overlay').style.display = 'none';
                };

                // Property Inputs
                document.getElementById('p-rot').onchange = (e) => {
                    if(this.selectedCell) {
                        this.selectedCell.rotation = parseInt(e.target.value);
                        this.refreshCellVisual(this.selectedCell);
                        this.updatePropsUI();
                    }
                };
            }

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

                // Update cursors
                this.cursorTarget.scale.set(this.voxelSize, this.voxelSize, this.voxelSize);
                this.cursorSelect.scale.set(this.voxelSize*1.05, this.voxelSize*1.05, this.voxelSize*1.05);
            }

            // --- Logic: WFC & Cells ---

            // Returns {px, nx, py, ny, pz, nz} sockets for a given module & rotation
            getRotatedSockets(config, rot) {
                // rot is 0..3 (x 90deg Y-axis clockwise)
                // Original: 0:px, 1:nx, 2:py, 3:ny, 4:pz, 5:nz
                // Standard Y-Rot 90: px->pz, pz->nx, nx->nz, nz->px
                
                let s = [...config.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/ny rotate "content" but socket ID typically stays if isotropic
                    // For now assume top/bottom don't change by Y-rotation
                    s = next;
                }
                return s;
            }

            getNeighbors(x, y, z) {
                const getC = (cx, cy, cz) => this.cells.find(c => c.x===cx && c.y===cy && c.z===cz);
                return [
                    { dir: 0, opp: 1, cell: getC(x+1, y, z) }, // px
                    { dir: 1, opp: 0, cell: getC(x-1, y, z) }, // nx
                    { dir: 2, opp: 3, cell: getC(x, y+1, z) }, // py
                    { dir: 3, opp: 2, cell: getC(x, y-1, z) }, // ny
                    { dir: 4, opp: 5, cell: getC(x, y, z+1) }, // pz
                    { dir: 5, opp: 4, cell: getC(x, y, z-1) }  // nz
                ];
            }

            solveWFC(x, y, z) {
                // Find all loaded configs
                let candidates = [];
                
                // Add "Empty" as implicit option? Or explicit?
                // For this editor, we assume user wants to place *something*.
                
                if (this.configs.length === 0) {
                    alert("No configs loaded.");
                    return null;
                }

                // Expand all configs * all rotations
                const allModules = [];
                this.configs.forEach(conf => {
                    const rots = (conf.symmetry === 'X') ? [0] : (conf.symmetry === 'I' ? [0,1] : [0,1,2,3]);
                    rots.forEach(r => {
                        allModules.push({ config: conf, rotation: r, sockets: this.getRotatedSockets(conf, r) });
                    });
                });

                // Get Neighbors
                const neighbors = this.getNeighbors(x, y, z);
                
                // Filter
                candidates = allModules.filter(mod => {
                    for (let n of neighbors) {
                        if (n.cell) {
                            // Neighbor exists. Check compatibility.
                            // Neighbor's socket at 'opp' must match my socket at 'dir'
                            // Get neighbor sockets
                            const nConfig = this.configs.find(c => c.name === n.cell.moduleName);
                            if (nConfig) {
                                const nSockets = this.getRotatedSockets(nConfig, n.cell.rotation);
                                if (nSockets[n.opp] !== mod.sockets[n.dir]) {
                                    return false; // Mismatch
                                }
                            }
                        }
                    }
                    return true;
                });

                if (candidates.length === 0) return null;
                return candidates[Math.floor(Math.random() * candidates.length)];
            }

            addCell(x, y, z, forcedModule = null, forcedRot = 0) {
                // Check if occupied
                const existing = this.cells.find(c => c.x===x && c.y===y && c.z===z);
                if(existing) this.deleteCell(existing);

                let chosen = null;

                if (forcedModule) {
                    chosen = { config: forcedModule, rotation: forcedRot };
                } else {
                    chosen = this.solveWFC(x, y, z);
                }

                if (!chosen) {
                    this.setStatus("WFC: No fitting module found!");
                    return;
                }

                const cell = new MapCell(x, y, z);
                cell.moduleName = chosen.config.name;
                cell.rotation = chosen.rotation;
                this.cells.push(cell);
                this.refreshCellVisual(cell);
                this.setStatus(`Placed ${cell.moduleName} (Rot ${cell.rotation})`);
            }

            deleteCell(cell) {
                if(!cell) return;
                if(cell.mesh) this.scene.remove(cell.mesh);
                this.cells = this.cells.filter(c => c !== cell);
                if(this.selectedCell === cell) this.selectCell(null);
            }

            // --- Visuals ---
            
            refreshCellVisual(cell) {
                if(cell.mesh) this.scene.remove(cell.mesh);
                
                const config = this.configs.find(c => c.name === cell.moduleName);
                if(!config) return;

                const wrapper = new THREE.Group();
                
                config.parts.forEach(part => {
                    // Find asset
                    // Loose matching: part.assetName "wall.glb" vs keys "wall.glb", "wall", etc.
                    let assetObj = this.assets[part.assetName];
                    if(!assetObj) {
                        // try fuzzy
                         const key = Object.keys(this.assets).find(k => k.includes(part.assetName));
                         if(key) assetObj = this.assets[key];
                    }

                    if(assetObj) {
                        const clone = assetObj.clone();
                        
                        // Internal Part Transform (from Container Editor)
                        if(part.pos) clone.position.fromArray(part.pos);
                        if(part.rot) clone.rotation.fromArray(part.rot);
                        if(part.scl) clone.scale.fromArray(part.scl);

                        // Auto-Fit Logic: Scale the internal content so the whole module fits voxelSize?
                        // Actually, Container Editor defines relative layout. 
                        // We should scale the ROOT of the asset to fit voxelSize if needed.
                        // Assuming Assets were designed for unit size or we scale them here.
                        // Simplest: Scale the final group.
                        
                        wrapper.add(clone);
                    } else {
                        // Placeholder
                        const box = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.5,0.5), new THREE.MeshBasicMaterial({color:0xff0000}));
                        if(part.pos) box.position.fromArray(part.pos);
                        wrapper.add(box);
                    }
                });

                // Apply Module Rotation (WFC)
                wrapper.rotation.y = -cell.rotation * (Math.PI / 2);

                // Position in Grid
                wrapper.position.set(
                    cell.x * this.voxelSize,
                    cell.y * this.voxelSize + this.voxelSize/2, // Pivot usually bottom center? Let's assume center.
                    cell.z * this.voxelSize
                );
                
                // Correction: If models are pivot-bottom, adjust Y. 
                // Let's assume pivot is center of voxel.
                
                // Store ref
                cell.mesh = wrapper;
                cell.mesh.userData.cell = cell;
                this.scene.add(wrapper);
            }

            refreshAllCells() {
                // When voxel size changes
                this.cells.forEach(c => this.refreshCellVisual(c));
            }

            // --- Asset Imports ---
            
            handleImportConfig(file) {
                if(!file) return;
                const r = new FileReader();
                r.onload = (e) => {
                    const data = JSON.parse(e.target.result);
                    // Container Editor exports { container:..., parts:..., assets:... } or just the container object?
                    // Let's handle the "WFC Container Editor" format provided in previous prompt.
                    // Format: { container: {name, symmetry, sockets}, parts: [...] }
                    
                    let newConfig = null;
                    if(data.container && data.parts) {
                         newConfig = new ContainerConfig(data.container.name, data.container.symmetry, data.container.sockets, data.parts);
                    } else {
                        alert("Invalid Config JSON");
                        return;
                    }

                    // Remove old if exists
                    this.configs = this.configs.filter(c => c.name !== newConfig.name);
                    this.configs.push(newConfig);
                    this.renderPalette();
                    this.setStatus(`Imported Config: ${newConfig.name}`);
                };
                r.readAsText(file);
                document.getElementById('file-config').value = '';
            }

            handleImportAssets(files) {
                const manager = new THREE.LoadingManager();
                // Texture blob mapping
                Array.from(files).forEach(f => {
                    if(f.name.match(/\.(jpg|jpeg|png|dds|tga)$/i)) {
                        const url = URL.createObjectURL(f);
                        this.fileMap[f.name] = url;
                    }
                });

                manager.setURLModifier((url) => {
                    const name = url.replace(/^.*[\\\/]/, '');
                    if(this.fileMap[name]) return this.fileMap[name];
                    return url;
                });

                const loaders = { 'glb':new GLTFLoader(manager), 'gltf':new GLTFLoader(manager), 'fbx':new FBXLoader(manager), 'obj':new OBJLoader(manager) };
                
                Array.from(files).forEach(f => {
                    const ext = f.name.split('.').pop().toLowerCase();
                    if(loaders[ext]) {
                        this.setStatus(`Loading ${f.name}...`);
                        const url = URL.createObjectURL(f);
                        loaders[ext].load(url, (res) => {
                            let obj = res.scene || res;
                            
                            // Auto-Scale Logic: Fit to Unit (1.0)
                            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);
                            if(maxDim > 0) {
                                const scale = (this.voxelSize) / maxDim; // Fit inside voxel
                                obj.scale.set(scale, scale, scale);
                            }
                            
                            this.assets[f.name] = obj;
                            this.setStatus(`Loaded ${f.name}`);
                        });
                    }
                });
                document.getElementById('file-assets').value = '';
            }

            renderPalette() {
                const list = document.getElementById('palette-list');
                list.innerHTML = '';
                if(this.configs.length === 0) {
                    list.innerHTML = '<div style="padding:10px;text-align:center;color:#666">No Configs</div>';
                    return;
                }
                this.configs.forEach(conf => {
                    const el = document.createElement('div');
                    el.className = 'palette-item';
                    if(this.currentConfig === conf) el.classList.add('active');
                    el.innerHTML = `<div class="palette-icon"></div> ${conf.name} [${conf.symmetry}]`;
                    el.onclick = () => {
                        this.currentConfig = conf;
                        this.renderPalette();
                    };
                    list.appendChild(el);
                });
            }

            // --- Interaction ---

            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;

                // Drag Logic
                if(this.isDragging && !this.isLapping) {
                    if(this.currentTool === 'move' && this.selectedCell) {
                         const deltaX = e.clientX - this.dragStartMouse.x;
                         const deltaY = e.clientY - this.dragStartMouse.y;
                         if (this.axisLock.x || this.axisLock.y || this.axisLock.z) {
                             // Axis movement logic (simplified)
                         }
                         // For Map Editor, usually we just move grid pos
                         // Impl: Snap to grid
                         this.raycaster.setFromCamera(this.mouse, this.camera);
                         const intersects = this.raycaster.intersectObject(this.planeGround);
                         if(intersects.length > 0) {
                             const p = intersects[0].point;
                             const nx = Math.round(p.x / this.voxelSize);
                             const nz = Math.round(p.z / this.voxelSize);
                             if (nx !== this.selectedCell.x || nz !== this.selectedCell.z) {
                                 this.selectedCell.x = nx; 
                                 this.selectedCell.z = nz;
                                 this.refreshCellVisual(this.selectedCell);
                                 this.updatePropsUI();
                             }
                         }
                    }
                    return;
                }
                
                if(this.isLapping) {
                     // Laps logic (same as model editor)
                     // ... omitted for brevity, basic cam laps implemented below
                     return;
                }

                // Hover Logic
                this.raycaster.setFromCamera(this.mouse, this.camera);
                const intersects = this.raycaster.intersectObject(this.planeGround);
                
                if(intersects.length > 0) {
                    const p = intersects[0].point;
                    const ix = Math.round(p.x / this.voxelSize);
                    const iz = Math.round(p.z / this.voxelSize);
                    
                    // Height finding
                    let iy = 0;
                    // Find max Y at this X,Z
                    this.cells.forEach(c => {
                        if(c.x===ix && c.z===iz && c.y >= iy) iy = c.y + 1;
                    });

                    const worldY = iy * this.voxelSize + this.voxelSize/2;

                    if(this.currentTool === 'add') {
                        this.cursorTarget.visible = true;
                        this.cursorTarget.position.set(ix*this.voxelSize, worldY, iz*this.voxelSize);
                        document.getElementById('height-display').innerText = `H: ${iy}`;
                    } else {
                        this.cursorTarget.visible = false;
                        
                        // Select Highlight
                        const cellHits = this.raycaster.intersectObjects(this.scene.children, true);
                        // Find closest cell mesh
                        let hitCell = null;
                        for(let hit of cellHits) {
                            let obj = hit.object;
                            while(obj) {
                                if(obj.userData && obj.userData.cell) {
                                    hitCell = obj.userData.cell;
                                    break;
                                }
                                obj = obj.parent;
                            }
                            if(hitCell) break;
                        }

                        if(hitCell && (this.currentTool === 'select' || this.currentTool === 'erase')) {
                            // Move highlight to cell
                            // (Not implemented: separate hover highlight, reusing cursorSelect for selection)
                        }
                    }
                }
            }

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

                if(this.currentTool.startsWith('cam-')) {
                    // Controls handled by OrbitControls mostly
                    return;
                }

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

                if (this.currentTool === 'add') {
                    // Add Logic
                    const pos = this.cursorTarget.position;
                    const gx = Math.round(pos.x / this.voxelSize);
                    const gy = Math.round((pos.y - this.voxelSize/2) / this.voxelSize);
                    const gz = Math.round(pos.z / this.voxelSize);

                    if (e.shiftKey && this.currentConfig) {
                        // Force place
                        this.addCell(gx, gy, gz, this.currentConfig, 0);
                    } else {
                        // WFC Solve
                        this.addCell(gx, gy, gz);
                    }
                    return;
                }

                // Hit Test Cells
                const hits = this.raycaster.intersectObjects(this.scene.children, true);
                let clickedCell = null;
                for(let hit of hits) {
                    let obj = hit.object;
                    while(obj) {
                        if(obj.userData && obj.userData.cell) {
                            clickedCell = obj.userData.cell;
                            break;
                        }
                        obj = obj.parent;
                    }
                    if(clickedCell) break;
                }

                if(this.currentTool === 'select') {
                    this.selectCell(clickedCell);
                } else if (this.currentTool === 'erase') {
                    if(clickedCell) this.deleteCell(clickedCell);
                }
            }

            onPointerUp() { this.isDragging = false; }

            selectCell(cell) {
                this.selectedCell = cell;
                if(cell) {
                    this.cursorSelect.visible = true;
                    this.cursorSelect.position.copy(cell.mesh.position);
                    this.cursorSelect.rotation.copy(cell.mesh.rotation);
                    this.updatePropsUI();
                    document.getElementById('prop-selected').classList.remove('hidden');
                    document.getElementById('prop-empty').classList.add('hidden');
                } else {
                    this.cursorSelect.visible = false;
                    document.getElementById('prop-selected').classList.add('hidden');
                    document.getElementById('prop-empty').classList.remove('hidden');
                }
            }

            updatePropsUI() {
                if(!this.selectedCell) return;
                const c = this.selectedCell;
                document.getElementById('p-name').value = c.moduleName;
                document.getElementById('p-pos-x').value = c.x;
                document.getElementById('p-pos-y').value = c.y;
                document.getElementById('p-pos-z').value = c.z;
                document.getElementById('p-rot').value = c.rotation;

                // Show Sockets
                const conf = this.configs.find(cfg => cfg.name === c.moduleName);
                if(conf) {
                    const socks = this.getRotatedSockets(conf, c.rotation);
                    const list = document.getElementById('p-sockets-list');
                    list.innerHTML = '';
                    const labels = ['X+', 'X-', 'Y+', 'Y-', 'Z+', 'Z-'];
                    const colors = ['#f44','#f44', '#4f4','#4f4', '#44f','#44f'];
                    socks.forEach((s, i) => {
                        list.innerHTML += `<div class="face-row"><span class="face-tag" style="background:${colors[i]};color:#000">${labels[i]}</span> ${s}</div>`;
                    });
                }
            }

            // --- Tooling ---
            setTool(t) {
                this.currentTool = t;
                const names = { 
                    'select':'Select', 'add':'Add (WFC)', 'erase':'Erase', 
                    'move':'Move', 'rotate':'Rotate',
                    'cam-move':'Camera Pan', 'cam-rotate':'Camera Orbit', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps'
                };
                document.getElementById('mode-display').innerText = names[t] || t;
                document.getElementById('mode-display').style.borderLeftColor = (t === 'add' ? '#e74c3c' : '#007fd4');
                
                this.cursorTarget.visible = (t === 'add');
                if(t !== 'select') this.cursorSelect.visible = false;

                // Controls Config
                this.isLapping = (t === 'cam-laps');
                this.controls.enabled = !this.isLapping;
                
                if (t === 'cam-move') this.controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
                else if (t === 'cam-zoom') this.controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
                else this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
            }

            // --- Export ---
            exportScene(fmt) {
                // Combine content
                const exportGroup = new THREE.Group();
                this.cells.forEach(c => {
                    if(c.mesh) {
                        const clone = c.mesh.clone();
                        // Flatten hierarchy?
                        // Just add to group.
                        exportGroup.add(clone);
                    }
                });
                
                exportGroup.updateMatrixWorld(true);

                if(fmt === 'obj') {
                    const res = new OBJExporter().parse(exportGroup);
                    this.download(new Blob([res], {type:'text/plain'}), 'map.obj');
                } else {
                    new GLTFExporter().parse(exportGroup, (res) => {
                         this.download(new Blob([res], {type:'application/octet-stream'}), 'map.glb');
                    }, undefined, { binary: true });
                }
            }

            saveMapJSON() {
                const data = this.cells.map(c => ({
                    x: c.x, y: c.y, z: c.z,
                    module: c.moduleName,
                    rotation: c.rotation
                }));
                this.download(new Blob([JSON.stringify(data)], {type:'application/json'}), 'map_data.json');
            }

            handleOpenMap(file) {
                const r = new FileReader();
                r.onload = (e) => {
                    const data = JSON.parse(e.target.result);
                    this.newMap();
                    data.forEach(d => {
                        this.addCell(d.x, d.y, d.z, {name:d.module}, d.rotation);
                    });
                };
                r.readAsText(file);
                document.getElementById('file-map-json').value = '';
            }

            newMap() {
                this.cells.forEach(c => this.scene.remove(c.mesh));
                this.cells = [];
                this.selectCell(null);
            }

            download(blob, fname) {
                const a = document.createElement('a');
                a.href = URL.createObjectURL(blob);
                a.download = fname;
                a.click();
            }

            setStatus(msg) {
                document.getElementById('status-bar').innerText = msg;
                setTimeout(() => document.getElementById('status-bar').innerText = 'Ready', 3000);
            }

            viewCam(dir) {
                const dist = 30;
                const p = this.camera.position;
                switch(dir) {
                    case 'default': p.set(15,15,15); break;
                    case 'top': p.set(0, dist, 0); break;
                    case 'bottom': p.set(0, -dist, 0); 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;
                }
                this.camera.lookAt(0,0,0);
                this.controls.update();
            }

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

            animate() {
                requestAnimationFrame(() => this.animate());
                
                if(this.isLapping) {
                     // Simple auto-rotate or slerp logic
                     this.controls.autoRotate = true;
                     this.controls.update();
                } else {
                    this.controls.autoRotate = false;
                    this.controls.update();
                }

                // Update axis guides pos
                if(this.selectedCell) {
                    this.axisGuideGroup.visible = true;
                    this.axisGuideGroup.position.copy(this.selectedCell.mesh.position);
                } else {
                    this.axisGuideGroup.visible = false;
                }

                // Update Labels
                const width = this.renderer.domElement.clientWidth;
                const height = this.renderer.domElement.clientHeight;
                document.querySelectorAll('.arrow-label').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);
            }
            
            // Stub for Keys
            onKeyDown(e) { 
                if(e.key === 'Escape') this.setTool('select');
                if(e.key === 'Delete') { if(this.selectedCell) this.deleteCell(this.selectedCell); }
            }
            onKeyUp(e) {}
        }

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

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

小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
3Dマップエディタ|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

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