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>

コメント