3Dマップエディタ



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

[ 操作説明 ]

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

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

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

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

画像
コンテナエディタの編集画面


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


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


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

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

        /* Viewport */
        #viewport { flex: 1; position: relative; background: #1e1e1e; outline: none; overflow: hidden; }
        #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: 10;
        }
        
        /* Mode Indicator */
        #mode-indicator {
            position: absolute; top: 10px; right: 10px; 
            background: rgba(0,0,0,0.6); color: #00ffcc; padding: 5px 10px; 
            border-radius: 4px; font-size: 12px; pointer-events: none;
        }

        /* Asset List */
        .asset-item { 
            padding: 8px; margin-bottom: 2px; background: #333; border-radius: 2px; cursor: grab; font-size: 13px; 
            display: flex; align-items: center; border: 1px solid transparent;
        }
        .asset-item:hover { background: #3e3e3e; border-color: #555; }
        .asset-icon { width: 12px; height: 12px; background: #569cd6; margin-right: 8px; }

        /* Tabs */
        .tabs { display: flex; background: #2d2d2d; border-bottom: 1px solid #3e3e3e; }
        .tab { flex: 1; padding: 8px; text-align: center; cursor: pointer; font-size: 11px; color: #888; background: #252526; }
        .tab.active { color: #fff; background: #333; border-top: 2px solid #007acc; }
        .tab-content { display: none; padding-top: 10px; }
        .tab-content.active { display: block; }

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

        /* Context Menu */
        #context-menu {
            display: none; position: absolute; z-index: 2000;
            background: #252526; border: 1px solid #454545; box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
            min-width: 120px;
        }
        .ctx-item { padding: 8px 12px; font-size: 12px; cursor: pointer; color: #ddd; }
        .ctx-item:hover { background: #094771; color: white; }

        /* Helpers */
        .hidden { display: none !important; }
        hr { border: 0; border-top: 1px solid #444; margin: 10px 0; }
        .face-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; font-size: 11px; }
        .face-tag { width: 60px; font-weight: bold; }
    </style>
    
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>

    <div id="menubar">
        <div class="dropdown">
            <div class="menu-item">File</div>
            <div class="dropdown-content">
                <div class="dd-item" onclick="app.newFile()">New <span class="shortcut">Ctrl+N</span></div>
                <div class="dd-item" onclick="app.openFile()">Open JSON... <span class="shortcut">Ctrl+O</span></div>
                <div class="dd-separator"></div>
                <div class="dd-item" onclick="app.saveFile()">Save (Overwrite) <span class="shortcut">Ctrl+S</span></div>
                <div class="dd-item" onclick="app.saveAsFile()">Save As... <span class="shortcut">Ctrl+Shift+S</span></div>
            </div>
        </div>

        <div class="dropdown">
            <div class="menu-item">Edit</div>
            <div class="dropdown-content">
                <div class="dd-item" onclick="app.setMode('camera')">Camera Mode <span class="shortcut">Q</span></div>
                <div class="dd-item" onclick="app.setMode('select')">Select Mode <span class="shortcut">W</span></div>
                <div class="dd-separator"></div>
                <div class="dd-item" onclick="app.setMode('translate')">Move <span class="shortcut">T</span></div>
                <div class="dd-item" onclick="app.setMode('rotate')">Rotate <span class="shortcut">R</span></div>
                <div class="dd-item" onclick="app.setMode('scale')">Scale <span class="shortcut">S</span></div>
                <div class="dd-separator"></div>
                <div class="dd-item" onclick="app.cut()">Cut <span class="shortcut">Ctrl+X</span></div>
                <div class="dd-item" onclick="app.copy()">Copy <span class="shortcut">Ctrl+C</span></div>
                <div class="dd-item" onclick="app.paste()">Paste <span class="shortcut">Ctrl+V</span></div>
                <div class="dd-item" onclick="app.deleteSelection()">Delete <span class="shortcut">Del</span></div>
            </div>
        </div>
        
        <div style="flex:1"></div>
        <div style="font-size:11px; color:#666; margin-right:10px;">WFC Container Editor Ultimate</div>
    </div>

    <div id="workspace">
        <div class="sidebar" id="sidebar-left">
            <div class="panel-header">Part List (Assets)</div>
            <div class="panel-content" id="asset-list">
                <div style="color:#666; font-size:11px; text-align:center; padding-top:20px;">
                    Right-click to Add<br>Drag to Viewport
                </div>
            </div>
        </div>

        <div id="viewport">
            <div id="drop-zone-overlay"></div>
            <div id="mode-indicator">Mode: Camera</div>
        </div>

        <div class="sidebar" id="sidebar-right">
            <div class="tabs">
                <div class="tab active" onclick="app.switchTab('part')">Part Props</div>
                <div class="tab" onclick="app.switchTab('container')">Container Props</div>
            </div>

            <div class="panel-content">
                <div id="tab-part" class="tab-content active">
                    <div id="part-props-content" class="hidden">
                        <label>Part Name / ID</label>
                        <input type="text" id="p-id">
                        
                        <hr>
                        <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>Rotation (Deg)</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>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>
                        <hr>
                        <button class="btn" style="background:#a61d24" onclick="app.deleteSelection()">Delete Part</button>
                    </div>
                    <div id="no-part-msg" style="color:#666; text-align:center; margin-top:20px;">No part selected</div>
                </div>

                <div id="tab-container" class="tab-content">
                    <label>Container Name</label>
                    <input type="text" id="c-name" value="New Container">
                    
                    <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>
                    <div style="display:flex; justify-content:space-between; align-items:center;">
                        <label style="margin:0">Socket Definitions</label>
                        <button style="width:auto; padding:2px 6px; margin:0;" onclick="app.addSocketType()">+ Add</button>
                    </div>
                    
                    <label style="margin-top:10px;">Boundary Sockets (Rules)</label>
                    <div id="socket-controls">
                        <div class="face-row"><span class="face-tag" style="color:#e74c3c">Right X+</span> <select class="c-socket" data-face="0"></select></div>
                        <div class="face-row"><span class="face-tag" style="color:#e74c3c">Left X-</span> <select class="c-socket" data-face="1"></select></div>
                        <div class="face-row"><span class="face-tag" style="color:#2ecc71">Top Y+</span> <select class="c-socket" data-face="2"></select></div>
                        <div class="face-row"><span class="face-tag" style="color:#2ecc71">Bottom Y-</span> <select class="c-socket" data-face="3"></select></div>
                        <div class="face-row"><span class="face-tag" style="color:#3498db">Front Z+</span> <select class="c-socket" data-face="4"></select></div>
                        <div class="face-row"><span class="face-tag" style="color:#3498db">Back Z-</span> <select class="c-socket" data-face="5"></select></div>
                    </div>
                </div>
            </div>
        </div>
    </div>

    <div id="context-menu">
        </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" style="display:none">

    <script type="module">
        import * as THREE from 'three';
        import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
        import { TransformControls } from 'three/addons/controls/TransformControls.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';

        class EditorApp {
            constructor() {
                // State
                this.assets = []; 
                this.sceneObjects = [];
                this.socketTypes = [
                    { id: 'empty', name: 'Empty', color: '#ffffff' },
                    { id: 'wall', name: 'Wall', color: '#7f8c8d' }
                ];
                
                // Container Data
                this.containerData = {
                    name: 'New Container',
                    symmetry: 'X',
                    sockets: ['empty','empty','empty','empty','empty','empty']
                };

                this.currentMode = 'camera'; // camera, select, translate, rotate, scale
                this.selectedObject = null;
                this.clipboard = null;
                this.currentFileName = "container.json";

                this.initThree();
                this.initUI();
                this.updateModeUI();
                this.renderAssetList();
                this.renderSocketOptions();
                this.animate();
            }

            initThree() {
                this.scene = new THREE.Scene();
                this.scene.background = new THREE.Color(0x1e1e1e);
                
                // Helpers
                this.grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
                this.scene.add(this.grid);
                this.axes = new THREE.AxesHelper(1);
                this.scene.add(this.axes);

                // Camera
                const vp = document.getElementById('viewport');
                this.camera = new THREE.PerspectiveCamera(50, vp.clientWidth / vp.clientHeight, 0.1, 1000);
                this.camera.position.set(5, 5, 8);

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

                // Controls
                this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
                this.orbit.enableDamping = true;
                this.orbit.dampingFactor = 0.1;
                // Left click orbit by default
                this.orbit.mouseButtons = {
                    LEFT: THREE.MOUSE.ROTATE,
                    MIDDLE: THREE.MOUSE.DOLLY,
                    RIGHT: THREE.MOUSE.PAN
                };

                this.transformControl = new TransformControls(this.camera, this.renderer.domElement);
                this.transformControl.addEventListener('dragging-changed', (event) => {
                    this.orbit.enabled = !event.value;
                });
                this.transformControl.addEventListener('change', () => this.updatePartPropsUI());
                this.scene.add(this.transformControl);

                // Lights
                const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.8);
                this.scene.add(hemi);
                const dir = new THREE.DirectionalLight(0xffffff, 1);
                dir.position.set(5, 10, 7);
                this.scene.add(dir);

                // Events
                this.raycaster = new THREE.Raycaster();
                this.mouse = new THREE.Vector2();
                
                this.renderer.domElement.addEventListener('pointerdown', (e) => this.onMouseDown(e));
                window.addEventListener('resize', () => this.onResize());
                
                // Keyboard Shortcuts
                window.addEventListener('keydown', (e) => this.onKeyDown(e));
            }

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

                // Drag & Drop
                const vp = document.getElementById('viewport');
                vp.addEventListener('dragover', (e) => { e.preventDefault(); document.getElementById('drop-zone-overlay').style.display = 'block'; });
                vp.addEventListener('dragleave', () => document.getElementById('drop-zone-overlay').style.display = 'none');
                vp.addEventListener('drop', (e) => this.handleViewportDrop(e));

                // Container Props Inputs
                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) => {
                        const idx = parseInt(e.target.dataset.face);
                        this.containerData.sockets[idx] = e.target.value;
                        this.updateSocketVisuals();
                    });
                });

                // Part Props Inputs
                ['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.applyPartPropsFromUI());
                });

                // Context Menu
                const assetList = document.getElementById('asset-list');
                assetList.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'asset-list'));
                document.addEventListener('click', () => document.getElementById('context-menu').style.display = 'none');
            }

            // --- Mode & Actions ---

            setMode(mode) {
                this.currentMode = mode;
                document.getElementById('mode-indicator').innerText = "Mode: " + mode.charAt(0).toUpperCase() + mode.slice(1);
                
                // Config Controls based on mode
                this.transformControl.detach();
                
                if (mode === 'camera') {
                    this.orbit.enabled = true;
                    // Orbit on Left Click
                    this.orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
                    this.renderer.domElement.style.cursor = 'grab';
                } else if (mode === 'select') {
                    this.orbit.enabled = true;
                    // Orbit still enabled, but click selects
                    this.orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
                    this.renderer.domElement.style.cursor = 'default';
                } else {
                    // Transform Modes
                    if (this.selectedObject) {
                        this.transformControl.setMode(mode);
                        this.transformControl.attach(this.selectedObject);
                    }
                    this.orbit.enabled = true;
                    this.renderer.domElement.style.cursor = 'default';
                }
            }

            // --- File Operations ---

            newFile() {
                if(confirm("Create new file? Unsaved changes will be lost.")) {
                    this.clearScene();
                    this.containerData = { name: 'New Container', symmetry: 'X', sockets: ['empty','empty','empty','empty','empty','empty'] };
                    this.currentFileName = "container.json";
                    this.updateContainerUI();
                }
            }

            openFile() {
                document.getElementById('file-open-json').click();
            }

            handleOpenJSON(e) {
                const file = e.target.files[0];
                if(!file) return;
                
                const reader = new FileReader();
                reader.onload = (evt) => {
                    try {
                        const data = JSON.parse(evt.target.result);
                        this.loadFromData(data);
                        this.currentFileName = file.name;
                    } catch(err) {
                        alert("Invalid JSON");
                    }
                };
                reader.readAsText(file);
                e.target.value = ''; // reset
            }

            saveFile() {
                // In browser, "Save" acts as "Download" with the current filename
                this.downloadJSON(this.currentFileName);
            }

            saveAsFile() {
                let name = prompt("Enter filename:", this.currentFileName);
                if(name) {
                    if(!name.endsWith('.json')) name += '.json';
                    this.currentFileName = name;
                    this.downloadJSON(name);
                }
            }

            downloadJSON(filename) {
                const data = this.serializeData();
                const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
                const url = URL.createObjectURL(blob);
                const a = document.createElement('a');
                a.href = url;
                a.download = filename;
                a.click();
            }

            serializeData() {
                // Gather Container Data
                return {
                    container: this.containerData,
                    socketTypes: this.socketTypes,
                    parts: this.sceneObjects.map(obj => ({
                        assetName: obj.userData.assetName,
                        id: obj.userData.id,
                        transform: {
                            pos: obj.position.toArray(),
                            rot: obj.rotation.toArray(),
                            scl: obj.scale.toArray()
                        }
                    }))
                };
            }

            loadFromData(data) {
                this.clearScene();
                if(data.container) this.containerData = data.container;
                if(data.socketTypes) {
                    this.socketTypes = data.socketTypes;
                    this.renderSocketOptions();
                }
                
                // Reconstruct parts
                // Note: This assumes assets are already loaded or we use placeholders if missing
                // In a real app, we'd bundle assets or ask user to reload them.
                if(data.parts) {
                    data.parts.forEach(p => {
                        this.instantiateAsset(p.assetName, p);
                    });
                }
                this.updateContainerUI();
            }

            clearScene() {
                this.selectObject(null);
                this.sceneObjects.forEach(o => this.scene.remove(o));
                this.sceneObjects = [];
                // Socket visual helper group clear
                this.updateSocketVisuals(); 
            }

            // --- Edit Operations ---

            cut() {
                if(!this.selectedObject) return;
                this.copy();
                this.deleteSelection();
            }

            copy() {
                if(!this.selectedObject) return;
                this.clipboard = {
                    assetName: this.selectedObject.userData.assetName,
                    rotation: this.selectedObject.rotation.clone(),
                    scale: this.selectedObject.scale.clone()
                };
                console.log("Copied to clipboard");
            }

            paste() {
                if(!this.clipboard) return;
                const pData = {
                    assetName: this.clipboard.assetName,
                    transform: {
                        pos: [0,0,0], // Paste at origin or near camera center
                        rot: this.clipboard.rotation.toArray(),
                        scl: this.clipboard.scale.toArray()
                    }
                };
                this.instantiateAsset(this.clipboard.assetName, pData);
            }

            deleteSelection() {
                if(!this.selectedObject) return;
                this.transformControl.detach();
                this.scene.remove(this.selectedObject);
                this.sceneObjects = this.sceneObjects.filter(o => o !== this.selectedObject);
                this.selectObject(null);
            }

            // --- Interaction ---

            onMouseDown(e) {
                // If interacting with Gizmo, don't select
                if (this.transformControl.dragging) return;

                // Left click for selection in Select/Transform modes
                if (e.button === 0 && this.currentMode !== 'camera') {
                    const rect = this.renderer.domElement.getBoundingClientRect();
                    this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
                    this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;

                    this.raycaster.setFromCamera(this.mouse, this.camera);
                    const intersects = this.raycaster.intersectObjects(this.sceneObjects, true);

                    if (intersects.length > 0) {
                        let target = intersects[0].object;
                        while(target.parent && !target.userData.isPart) {
                            target = target.parent;
                        }
                        if(target.userData.isPart) this.selectObject(target);
                    } else {
                        this.selectObject(null);
                    }
                }
            }

            selectObject(obj) {
                this.selectedObject = obj;
                if(obj) {
                    if (this.currentMode !== 'camera' && this.currentMode !== 'select') {
                        this.transformControl.attach(obj);
                    }
                    this.switchTab('part');
                    this.updatePartPropsUI();
                } else {
                    this.transformControl.detach();
                    document.getElementById('part-props-content').classList.add('hidden');
                    document.getElementById('no-part-msg').classList.remove('hidden');
                }
            }

            // --- Asset Management ---

            handleImportAssets(files) {
                const loaders = { 'glb': new GLTFLoader(), 'gltf': new GLTFLoader(), 'fbx': new FBXLoader(), 'obj': new OBJLoader() };
                
                Array.from(files).forEach(file => {
                    const ext = file.name.split('.').pop().toLowerCase();
                    const loader = loaders[ext];
                    if(!loader) return;

                    const url = URL.createObjectURL(file);
                    loader.load(url, (loaded) => {
                        this.assets.push({ name: file.name, model: loaded });
                        this.renderAssetList();
                    });
                });
                // reset input
                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.draggable = true;
                    el.dataset.index = idx;
                    el.innerHTML = `<div class="asset-icon"></div> ${asset.name}`;
                    el.addEventListener('dragstart', (e) => {
                        e.dataTransfer.setData('assetName', asset.name);
                    });
                    list.appendChild(el);
                });
            }

            instantiateAsset(assetName, data=null) {
                const asset = this.assets.find(a => a.name === assetName);
                if(!asset) {
                    // Fallback placeholder if asset missing (e.g. loaded from JSON without files)
                    console.warn("Missing asset:", assetName);
                    return;
                }

                let obj;
                if(asset.model.scene) obj = asset.model.scene.clone();
                else obj = asset.model.clone();

                // Normalize size
                const box = new THREE.Box3().setFromObject(obj);
                const size = new THREE.Vector3(); box.getSize(size);
                const max = Math.max(size.x, size.y, size.z);
                if(max > 0) obj.scale.multiplyScalar(1/max);

                // Wrapper
                const wrapper = new THREE.Group();
                wrapper.add(obj);
                wrapper.userData = {
                    isPart: true,
                    assetName: assetName,
                    id: data ? data.id : (assetName + '_' + Math.floor(Math.random()*1000))
                };

                if(data && data.transform) {
                    wrapper.position.fromArray(data.transform.pos);
                    wrapper.rotation.fromArray(data.transform.rot);
                    wrapper.scale.fromArray(data.transform.scl);
                }

                this.scene.add(wrapper);
                this.sceneObjects.push(wrapper);
                this.selectObject(wrapper);
            }

            handleViewportDrop(e) {
                e.preventDefault();
                document.getElementById('drop-zone-overlay').style.display = 'none';
                const name = e.dataTransfer.getData('assetName');
                if(name) {
                    // Drop position raycast? (Simplified: drop at 0,0,0)
                    this.instantiateAsset(name);
                } else {
                    // Handle file drop directly
                    if(e.dataTransfer.files.length > 0) {
                        this.handleImportAssets(e.dataTransfer.files);
                    }
                }
            }

            // --- Context Menu ---
            showContextMenu(e, context) {
                e.preventDefault();
                const menu = document.getElementById('context-menu');
                menu.style.display = 'block';
                menu.style.left = e.pageX + 'px';
                menu.style.top = e.pageY + 'px';
                menu.innerHTML = '';

                if (context === 'asset-list') {
                    const item = e.target.closest('.asset-item');
                    if (item) {
                        // On Item
                        const idx = item.dataset.index;
                        menu.innerHTML = `<div class="ctx-item" id="ctx-del">Delete Asset</div>`;
                        document.getElementById('ctx-del').onclick = () => {
                            this.assets.splice(idx, 1);
                            this.renderAssetList();
                        };
                    } else {
                        // On Empty Space
                        menu.innerHTML = `<div class="ctx-item" id="ctx-add">Add Asset...</div>`;
                        document.getElementById('ctx-add').onclick = () => {
                            document.getElementById('file-import-asset').click();
                        };
                    }
                }
            }

            // --- UI Updates ---

            switchTab(name) {
                document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
                document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
                
                if(name === 'part') {
                    document.querySelectorAll('.tab')[0].classList.add('active');
                    document.getElementById('tab-part').classList.add('active');
                } else {
                    document.querySelectorAll('.tab')[1].classList.add('active');
                    document.getElementById('tab-container').classList.add('active');
                }
            }

            updatePartPropsUI() {
                if(!this.selectedObject) return;
                const o = this.selectedObject;
                document.getElementById('part-props-content').classList.remove('hidden');
                document.getElementById('no-part-msg').classList.add('hidden');

                document.getElementById('p-id').value = o.userData.id;
                
                const toDeg = (rad) => (rad * 180 / Math.PI).toFixed(1);
                
                document.getElementById('p-pos-x').value = o.position.x.toFixed(2);
                document.getElementById('p-pos-y').value = o.position.y.toFixed(2);
                document.getElementById('p-pos-z').value = o.position.z.toFixed(2);
                
                document.getElementById('p-rot-x').value = toDeg(o.rotation.x);
                document.getElementById('p-rot-y').value = toDeg(o.rotation.y);
                document.getElementById('p-rot-z').value = toDeg(o.rotation.z);
                
                document.getElementById('p-scl-x').value = o.scale.x.toFixed(2);
                document.getElementById('p-scl-y').value = o.scale.y.toFixed(2);
                document.getElementById('p-scl-z').value = o.scale.z.toFixed(2);
            }

            applyPartPropsFromUI() {
                if(!this.selectedObject) return;
                const o = this.selectedObject;
                o.userData.id = document.getElementById('p-id').value;
                
                const toRad = (deg) => deg * Math.PI / 180;

                o.position.set(
                    parseFloat(document.getElementById('p-pos-x').value),
                    parseFloat(document.getElementById('p-pos-y').value),
                    parseFloat(document.getElementById('p-pos-z').value)
                );
                o.rotation.set(
                    toRad(parseFloat(document.getElementById('p-rot-x').value)),
                    toRad(parseFloat(document.getElementById('p-rot-y').value)),
                    toRad(parseFloat(document.getElementById('p-rot-z').value))
                );
                o.scale.set(
                    parseFloat(document.getElementById('p-scl-x').value),
                    parseFloat(document.getElementById('p-scl-y').value),
                    parseFloat(document.getElementById('p-scl-z').value)
                );
            }

            updateContainerUI() {
                document.getElementById('c-name').value = this.containerData.name;
                document.getElementById('c-symmetry').value = this.containerData.symmetry;
                document.querySelectorAll('.c-socket').forEach((sel, i) => {
                    sel.value = this.containerData.sockets[i];
                });
                this.updateSocketVisuals();
            }

            // --- WFC Sockets ---
            addSocketType() {
                const name = prompt("New Socket Name:");
                if(name) {
                    const id = name.toLowerCase().replace(/\s/g,'_');
                    const color = '#' + Math.floor(Math.random()*16777215).toString(16);
                    this.socketTypes.push({ id, name, color });
                    this.renderSocketOptions();
                }
            }

            renderSocketOptions() {
                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() {
                // Clear old helpers
                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]);
                    }
                }
                
                // Draw 6 spheres representing container boundary sockets (Assuming 2x2x2 boundary for visualization)
                const range = 2; 
                const dirs = [
                    {v: new THREE.Vector3(range,0,0), c:0}, {v: new THREE.Vector3(-range,0,0), c:1},
                    {v: new THREE.Vector3(0,range,0), c:2}, {v: new THREE.Vector3(0,-range,0), c:3},
                    {v: new THREE.Vector3(0,0,range), c:4}, {v: new THREE.Vector3(0,0,-range), c:5}
                ];

                dirs.forEach(d => {
                    const sID = this.containerData.sockets[d.c];
                    const sType = this.socketTypes.find(t=>t.id===sID);
                    if(sType) {
                        const geo = new THREE.SphereGeometry(0.3, 16, 16);
                        const mat = new THREE.MeshBasicMaterial({ color: sType.color, wireframe:true });
                        const mesh = new THREE.Mesh(geo, mat);
                        mesh.position.copy(d.v);
                        mesh.userData.isSocketHelper = true;
                        this.scene.add(mesh);
                        
                        // Line to center
                        const pts = [new THREE.Vector3(0,0,0), d.v];
                        const line = new THREE.Line(
                            new THREE.BufferGeometry().setFromPoints(pts),
                            new THREE.LineBasicMaterial({ color: sType.color, transparent:true, opacity:0.3 })
                        );
                        line.userData.isSocketHelper = true;
                        this.scene.add(line);
                    }
                });
            }

            updateModeUI() {
                // Done in setMode
            }

            onResize() {
                const vp = document.getElementById('viewport');
                this.camera.aspect = vp.clientWidth / vp.clientHeight;
                this.camera.updateProjectionMatrix();
                this.renderer.setSize(vp.clientWidth, vp.clientHeight);
            }
            
            onKeyDown(e) {
                // Shortcuts
                if(e.key === 'Delete') this.deleteSelection();
                if(e.ctrlKey) {
                    if(e.key === 'x') this.cut();
                    if(e.key === 'c') this.copy();
                    if(e.key === 'v') this.paste();
                    if(e.key === 's') { e.preventDefault(); e.shiftKey ? this.saveAsFile() : this.saveFile(); }
                    if(e.key === 'n') { e.preventDefault(); this.newFile(); }
                    if(e.key === 'o') { e.preventDefault(); this.openFile(); }
                }
                if(document.activeElement.tagName !== 'INPUT') {
                    if(e.key === 'q') this.setMode('camera');
                    if(e.key === 'w') this.setMode('select');
                    if(e.key === 't') this.setMode('translate');
                    if(e.key === 'r') this.setMode('rotate');
                    if(e.key === 's') this.setMode('scale');
                }
            }

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

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

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

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

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

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

        #cursor-info {
            position: absolute; bottom: 10px; left: 10px; 
            background: rgba(0,0,0,0.6); padding: 5px 10px; 
            border-radius: 4px; pointer-events: none; font-size: 12px; color: #fff;
        }
    </style>
    
    <script type="importmap">
        {
            "imports": {
                "three": "https://unpkg.com/three@0.160.0/build/three.module.js",
                "three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
            }
        }
    </script>
</head>
<body>

    <div id="menubar">
        <div class="dropdown">
            <div class="menu-item">File</div>
            <div class="dropdown-content">
                <div class="dd-item" onclick="app.newMap()">New Map</div>
                <div class="dd-item" onclick="document.getElementById('file-map-json').click()">Open Map JSON...</div>
                <div class="dd-item" onclick="app.saveMap()">Save Map JSON</div>
                <div class="dd-separator"></div>
                <div class="dd-item" onclick="document.getElementById('file-assets').click()">1. Import Assets (GLB/FBX)</div>
                <div class="dd-item" onclick="document.getElementById('file-config').click()">2. Import Container Config</div>
            </div>
        </div>
        <div class="dropdown">
            <div class="menu-item">Edit</div>
            <div class="dropdown-content">
                <div class="dd-item" onclick="app.wfc.reset()">Clear Map</div>
            </div>
        </div>
        <div style="flex:1"></div>
        <div style="font-size:11px; color:#666; margin-right:10px;">WFC Map Editor Ultimate</div>
    </div>

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

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

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

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

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

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

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

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

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

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

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

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

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

            reset() { this.initGrid(); }

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

            // --- Logic ---

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

        window.app = new MapEditor();
    </script>
</body>
</html>
<!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; /* Right Align Numbers */
        }
        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: #eee; 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 class="dropdown-item" id="menu-export-fbx">Export FBX</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 (for Add and Paint)
        let currentBrush = {
            color: '#4fc1ff',
            textureSrc: null
        };

        // Laps
        let isLapping = false;
        let lapAngle = 0;
        let lapDist = 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
        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
            if(isLapping) {
                const r = lapDist;
                camera.position.x = Math.sin(lapAngle) * r;
                camera.position.z = Math.cos(lapAngle) * r;
                camera.position.y = r * 0.5; 
                camera.lookAt(0,0,0);
                controls.update(); 
            } else {
                controls.update();
                
                // Enforce Camera Constraints during animation/update if dragging
                if(isDragging) {
                    if(currentTool === 'cam-move') {
                        // Restore locked axes
                        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;
                        }
                    } 
                    else if(currentTool === 'cam-rotate') {
                        // Limit Polar/Azimuth
                        if(axisLock.x) { // Horizontal only
                            controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar;
                        } else if (axisLock.y) { // Vertical only
                            controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth;
                        } else {
                            // unlock
                            controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI;
                            controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity;
                        }
                    }
                }
            }

            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(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;
                
                if(isLapping) {
                     lapAngle = dragStartVal.x - deltaX * 0.01;
                     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; // Up increases scale
                    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);

                    // Re-center voxel to align with grid
                    // Center = GridOrigin + (Size*VoxelSize)/2
                    // We assume GridOrigin is the nearest integer coordinate of the 'min' face.
                    // But simpler: just round the current position to nearest valid center for this size.
                    // Valid center for size S on axis is: k * VS + (S * VS)/2.
                    // Let's normalize position based on old center? No, just snap.
                    
                    const fixPos = (pos, scale) => {
                        const halfS = (scale * voxelSize) / 2;
                        // Determine "base" integer coord
                        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);
                    
                    // Logic to find highest Y
                    let maxY = 0;
                    for(let v of voxels) {
                        // Check if voxel covers this x/z column
                        // Since voxels can be scaled, we check bounds.
                        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);
                        
                        // Bounds are [min, max). Integers.
                        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;
                            // Need to find the specific voxel top center to snap correctly?
                            // Just snap to grid stack top
                            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;
                            // For scaled voxels, cursor should probably match their size or stay unit?
                            // Requirement: "Scaled voxels cannot be selected".
                            // If we just position cursor at center, it's fine.
                            cursorPoint.position.copy(vPos);
                            // Scale cursor to match?
                            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);
                dragStartVal.set(lapAngle, 0, 0);
                controls.enabled = false;
                return;
            }

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

            if (isLocked) {
                isDragging = true;
                dragStartMouse.set(e.clientX, e.clientY);
                controls.enabled = false; // Disable orbit if locking axis (we handle it manually or limit it)
                
                // If it's a camera tool, we might need to allow OrbitControls but constrain it?
                // Actually, if we disable controls, we can't orbit.
                // So for Camera tools, we MUST keep controls enabled but reset values.
                if(currentTool.startsWith('cam-')) {
                    controls.enabled = true;
                }
                
                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 if (!currentTool.startsWith('cam-')) {
                    let targetCursor = (currentTool === 'add') ? cursorTarget : cursorPoint;
                    startCursorPos.copy(targetCursor.position);
                }
                if (!currentTool.startsWith('cam-')) return; 
            }

            if (currentTool.startsWith('cam-')) {
                isDragging = true; // Track drag for camera to apply constraint logic
                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;
            
            // Reset Constraints
            controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI;
            controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity;
        }

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

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

            // Helper to get grid coord (rounded)
            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();
                
                // Apply Paint
                v.material.color.setHex(brushColorInt);
                if(currentBrush.textureSrc) {
                    // Set texture
                    loadTexture(v.material, currentBrush.textureSrc);
                } else {
                    // Clear texture
                    v.material.map = null;
                    v.material.userData.textureSrc = null;
                    v.material.needsUpdate = true;
                }

                // Check Neighbors
                const c = getCoord(v);
                // 6 directions
                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) {
                    // Find voxel at this coord
                    // Optimization: Build a spatial map instead of O(N) search? 
                    // For small scenes O(N) is ok.
                    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)) {
                        // Check match criteria
                        const nColor = neighbor.material.color.getHex();
                        const nTex = neighbor.material.userData.textureSrc;
                        
                        // Strict match?
                        if(nColor === targetColor && nTex === targetTex) {
                            processed.add(neighbor.uuid);
                            queue.push(neighbor);
                        }
                    }
                }
            }
        }

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

            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;

            const mat = new THREE.MeshLambertMaterial({ 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(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;
            }
        };

        // 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') {
                         // Real-time grid snapping for manual input
                         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-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) => {
                const group = new THREE.Group();
                voxels.forEach(v => group.add(v.clone()));
                if(fmt === 'obj') {
                    const { OBJExporter } = await import('three/addons/exporters/OBJExporter.js');
                    downloadFile(new Blob([new OBJExporter().parse(group)], {type:'text/plain'}), 'model.obj');
                }
                if(fmt === 'glb') {
                    const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
                    new GLTFExporter().parse(group, (res) => {
                        downloadFile(new Blob([JSON.stringify(res)], {type:'application/json'}), 'model.glb');
                    }, (e)=>console.error(e), {binary:true});
                }
                if(fmt === 'fbx') {
                    const { FBXExporter } = await import('three/addons/exporters/FBXExporter.js');
                    downloadFile(new Blob([new FBXExporter().parse(group)], {type:'text/plain'}), 'model.fbx');
                }
            };
            document.getElementById('menu-export-obj').onclick = () => doExport('obj');
            document.getElementById('menu-export-glb').onclick = () => doExport('glb');
            document.getElementById('menu-export-fbx').onclick = () => doExport('fbx');
        }

        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>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
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