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>

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

コメント

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