3Dマップエディタ



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


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


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


<!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</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;
        }
        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);
        }
        #mode-display {
            position: absolute; top: 10px; left: 10px; background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; pointer-events: none; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4;
        }
        
        /* Wall Labels */
        .wall-label {
            position: absolute; color: #ff5555; font-family: 'Consolas', monospace; font-weight: bold; font-size: 16px; pointer-events: none; text-shadow: 2px 2px 0px #000; transform: translate(-50%, -50%); white-space: nowrap;
        }
        /* Arrow 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;
        }

        /* 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-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="mode-display">Tool: Select</div>
            <div id="status-bar">Ready</div>
            <div id="labels-container"></div>
        </div>
        
        <div id="properties-panel">
            <h3>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">
                </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>
                </div>
                <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="15">
                        <input type="number" id="prop-ry" step="15">
                        <input type="number" id="prop-rz" step="15">
                    </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="0.1">
                        <input type="number" id="prop-sy" step="0.1">
                        <input type="number" id="prop-sz" step="0.1">
                    </div>
                </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;
        
        // Tools: 'select', 'add', 'erase', 'move', 'rotate', 'scale'
        // Camera Tools: 'cam-move', 'cam-rotate', 'cam-zoom', 'cam-laps'
        let currentTool = 'select'; 
        
        // Camera Laps State
        let isLapping = false;
        let lapSpeed = 0.005;
        let lapAngle = 0;
        let lapDist = 30;

        // View State for Toggles
        let currentSideViewIndex = 0; // 0:Front, 1:Right, 2:Back, 3:Left
        let isTopView = true; // Toggle for F2

        // 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 labelsContainer;

        // Cursors
        let cursorTarget; // Red Panel (Keyboard)
        let cursorPoint;  // White Wireframe (Mouse Hover)
        let cursorSelect; // Yellow Wireframe (Selection)

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

        // Interaction State
        let isDragging = false;
        let dragStartMouse = new THREE.Vector2();
        let dragStartVal = new THREE.Vector3(); 
        let planeGround; 
        
        // --- Init ---
        function init() {
            const container = document.getElementById('canvas-container');
            labelsContainer = document.getElementById('labels-container');

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

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

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

            // Lights
            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);

            // Helpers & Cursors
            scene.add(gridGroup);
            scene.add(guideLineGroup);
            initCursors();
            updateEnvironment(); 

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

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

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

            window.addEventListener('resize', onResize);
            window.addEventListener('keydown', onKeyDown);
            
            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'); // Default tool
            animate();
        }

        function initCursors() {
            // Target (Red) - Keyboard
            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);

            // Point (White) - Mouse
            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);

            // Selection (Yellow)
            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 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;

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

            // 2. Wall Grids
            const wallZGrid = new THREE.GridHelper(sizeX, gridSize.x, 0x444444, 0x222222);
            wallZGrid.rotation.x = -Math.PI / 2; 
            wallZGrid.position.set(0, sizeX/2, -halfZ); 
            gridGroup.add(wallZGrid);

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

            // 3. Arrow Labels
            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");
            addArrowLabel(new THREE.Vector3(-halfX, sizeZ + 1, 0), "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); 
            labelsContainer.appendChild(div);
        }

        function animate() {
            requestAnimationFrame(animate);
            
            // Camera Laps Logic
            if(isLapping) {
                lapAngle += lapSpeed;
                const r = lapDist;
                camera.position.x = Math.sin(lapAngle) * r;
                camera.position.z = Math.cos(lapAngle) * r;
                camera.position.y = r * 0.5; // Slightly elevated
                camera.lookAt(0,0,0);
                controls.update(); // Sync controls target
            } else {
                if(controls) controls.update();
            }

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

        // --- Logic: Walls & Projections ---
        function updateGuides() {
            while(guideLineGroup.children.length > 0) guideLineGroup.remove(guideLineGroup.children[0]);
            
            const wallZ = -(gridSize.z * voxelSize) / 2;
            const wallX = -(gridSize.x * voxelSize) / 2;

            if(cursorTarget) drawProjection(cursorTarget.position, 0xff5555, "Target", wallX, wallZ); 
            
            if(cursorPoint && document.activeElement === document.getElementById('canvas-container')) {
                 drawProjection(cursorPoint.position, 0xffffff, "Pointer", wallX, wallZ); 
            }

            if(selectedVoxel && selectedVoxel.visible) {
                drawProjection(selectedVoxel.position, 0xffff00, "Select", wallX, wallZ); 
            }
        }

        function drawProjection(pos, color, id, limitX, limitZ) {
            const mat = new THREE.LineBasicMaterial({ color: color, transparent: true, opacity: 0.6 });
            
            const pZ = new THREE.Vector3(pos.x, pos.y, limitZ);
            const geoZ = new THREE.BufferGeometry().setFromPoints([pos, pZ]);
            guideLineGroup.add(new THREE.Line(geoZ, mat));
            addWallLabel(pZ, `X:${Math.round(pos.x/voxelSize)} Y:${Math.round(pos.y/voxelSize)}`, color, id + "_Z");

            const pX = new THREE.Vector3(limitX, pos.y, pos.z);
            const geoX = new THREE.BufferGeometry().setFromPoints([pos, pX]);
            guideLineGroup.add(new THREE.Line(geoX, mat));
            addWallLabel(pX, `Z:${Math.round(pos.z/voxelSize)} Y:${Math.round(pos.y/voxelSize)}`, color, id + "_X");
        }

        function addWallLabel(pos3d, text, colorHex, id) {
            let el = document.getElementById(id);
            if (!el) {
                el = document.createElement('div');
                el.id = id;
                el.className = 'wall-label';
                labelsContainer.appendChild(el);
            }
            el.textContent = text;
            el.style.color = '#' + new THREE.Color(colorHex).getHexString();
            el.dataset.pos = JSON.stringify(pos3d);
            el.dataset.active = "true";
        }

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

            Array.from(labelsContainer.children).forEach(div => {
                if(div.dataset.active === "false") {
                    div.style.display = 'none';
                    return;
                }
                if(div.classList.contains('wall-label')) div.dataset.active = "false"; 

                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';
                }
            });
        }

        // --- 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;
                
                if (currentTool === 'move' && selectedVoxel) {
                    raycaster.setFromCamera(mouse, camera);
                    const intersects = raycaster.intersectObject(planeGround);
                    if(intersects.length > 0) {
                        const p = intersects[0].point;
                        selectedVoxel.position.x = Math.round(p.x / voxelSize) * voxelSize;
                        selectedVoxel.position.z = Math.round(p.z / voxelSize) * voxelSize;
                    }
                } 
                else if (currentTool === 'rotate' && selectedVoxel) {
                    const rotY = dragStartVal.y + (deltaX * 0.02); 
                    selectedVoxel.rotation.y = Math.round(rotY / (Math.PI/12)) * (Math.PI/12); 
                } 
                else if (currentTool === 'scale' && selectedVoxel) {
                    const s = Math.max(0.1, dragStartVal.x - (deltaY * 0.01));
                    selectedVoxel.scale.set(s, s, s);
                }
                updateSelectionBox();
                updateUIFromSelection();
                return;
            }

            // Passive Hover
            raycaster.setFromCamera(mouse, camera);
            const intersects = raycaster.intersectObjects([...voxels, planeGround]);
            
            if (intersects.length > 0) {
                const hit = intersects[0];
                const p = hit.point;
                const n = hit.face.normal;

                if (hit.object === planeGround) {
                    pointPos.set(Math.round(p.x/voxelSize), 0, Math.round(p.z/voxelSize));
                } else {
                    const vPos = hit.object.position;
                    pointPos.copy(vPos).divideScalar(voxelSize).round().add(n);
                }
                
                if(cursorPoint) {
                    cursorPoint.position.set(
                        pointPos.x * voxelSize,
                        pointPos.y * voxelSize + voxelSize/2, 
                        pointPos.z * voxelSize
                    );
                }
            }
        }

        function onPointerDown(e) {
            if (e.button !== 0) return; 
            
            // Camera Tools Logic (Handle mouse down only if we need to track drag)
            if (currentTool.startsWith('cam-')) {
                // OrbitControls handles rotation/pan/zoom via mouse interaction based on config.
                // We just need to make sure OrbitControls is configured correctly.
                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') {
                createVoxel(pointPos);
            }
            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;
            // Re-enable controls if not in a drag-based voxel tool
            if (!['move', 'rotate', 'scale'].includes(currentTool)) controls.enabled = true;
            else controls.enabled = true; // Always re-enable orbit after drag
        }

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

            // View Shortcuts
            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 === 'PageUp') {
                e.preventDefault();
                camera.zoom = Math.min(camera.zoom + 0.1, 5);
                camera.updateProjectionMatrix();
            }
            if (e.key === 'PageDown') {
                e.preventDefault();
                camera.zoom = Math.max(camera.zoom - 0.1, 0.1);
                camera.updateProjectionMatrix();
            }

            // Target Cursor Move
            if (e.shiftKey) {
                if (e.key.toLowerCase() === 'a') targetPos.y++;
                if (e.key.toLowerCase() === 'z') targetPos.y--;
            } else {
                if (e.key === 'ArrowUp') targetPos.z--;
                if (e.key === 'ArrowDown') targetPos.z++;
                if (e.key === 'ArrowLeft') targetPos.x--;
                if (e.key === 'ArrowRight') targetPos.x++;
            }
            updateTargetCursor();

            if (e.key === 'Delete') { if (selectedVoxel) deleteVoxel(selectedVoxel); }
            if (e.ctrlKey && e.key === 'c') { if (selectedVoxel) copyVoxel(selectedVoxel); }
            if (e.ctrlKey && e.key === 'v') { if (clipboard) pasteVoxel(); }
        }

        function updateTargetCursor() {
            if(cursorTarget) {
                cursorTarget.position.set(
                    targetPos.x * voxelSize,
                    targetPos.y * voxelSize + 0.05, 
                    targetPos.z * voxelSize
                );
            }
        }

        function createVoxel(gPos, data=null) {
            const geo = new THREE.BoxGeometry(voxelSize, voxelSize, voxelSize);
            const color = data ? data.color : Math.random() * 0xffffff;
            const mat = new THREE.MeshLambertMaterial({ color: color });
            
            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(
                    gPos.x * voxelSize,
                    gPos.y * voxelSize + voxelSize/2,
                    gPos.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) {
                cursorSelect.visible = true;
                updateSelectionBox();
                updateUIFromSelection();
            } else {
                cursorSelect.visible = false;
                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;
            const data = { 
                ...clipboard, 
                x: targetPos.x * voxelSize, 
                y: targetPos.y * voxelSize + voxelSize/2, 
                z: targetPos.z * voxelSize 
            };
            createVoxel(null, data);
        }

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

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

            // Handle Camera Modes Configuration
            isLapping = (tool === 'cam-laps');
            
            // OrbitControls Configuration
            if(controls) {
                controls.enabled = true;
                
                // Reset Mouse mapping
                controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
                
                if (tool === 'cam-move') {
                    // Left drag = Pan
                    controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
                }
                else if (tool === 'cam-rotate') {
                    // Left drag = Rotate
                    controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
                }
                else if (tool === 'cam-zoom') {
                    // Left drag = Dolly (Zoom)
                    controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
                }
            }
        };

        // UI Event Listeners for Tool Buttons
        document.getElementById('tool-select').onclick = () => setTool('select');
        document.getElementById('tool-add').onclick = () => setTool('add');
        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;
            // Reset laps if manual view change
            if(isLapping) {
                isLapping = false;
                setTool('select'); // drop to default
            }

            switch(dir) {
                case 'default': p.set(dist, dist, dist); break; // F1
                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();
        };

        // --- Properties UI ---
        function setupUI() {
            const bind = (id, prop, axis, isRot=false) => {
                const el = document.getElementById(id);
                el.addEventListener('input', () => {
                    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;
                    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(selectedVoxel) selectedVoxel.material.color.set(e.target.value);
            });

            document.getElementById('prop-tex-file').addEventListener('change', (e) => {
                if(!selectedVoxel || !e.target.files[0]) return;
                const reader = new FileReader();
                reader.onload = (ev) => loadTexture(selectedVoxel.material, ev.target.result);
                reader.readAsDataURL(e.target.files[0]);
            });
            document.getElementById('btn-clear-tex').onclick = () => {
                if(selectedVoxel) {
                    selectedVoxel.material.map = null;
                    selectedVoxel.material.userData.textureSrc = null;
                    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';
            };

            document.getElementById('menu-save').onclick = () => {
                const data = 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
                }));
                const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
                const link = document.createElement('a');
                link.href = URL.createObjectURL(blob);
                link.download = 'scene.json';
                link.click();
            };
            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');
                    const res = new OBJExporter().parse(group);
                    download(new Blob([res], {type:'text/plain'}), 'model.obj');
                }
                if(fmt === 'glb') {
                    const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
                    new GLTFExporter().parse(group, (res) => {
                        download(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');
                    download(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 download(blob, name) {
            const link = document.createElement('a');
            link.href = URL.createObjectURL(blob);
            link.download = name;
            link.click();
        }

        function updateUIFromSelection() {
            if(!selectedVoxel) 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;
        }

        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