3Dマップエディタ
[ 操作説明 ]
・上キーを押すと、上の段に移動する。
・下キーを押すと、下の段に移動する。
・x,y,zキーのいずれかを押しながらマウスドラッグで操作をすると、
移動方向がx,y,zのいずれかに制約できる。
・cキーを押すと、選択中のボクセルをコピーする。
・vキーを押すと、ポイント・カーソル(白色)の位置に
コピーしたボクセルをペーストする。
・deleteキーを押すと、選択中のボクセルを削除する。
・pageupキーを押すと、ズームイン。
・pagedownキーを押すと、ズームアウト。
・ダウンロードされる方はこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>WFC Container Editor Ultimate</title>
<style>
/* --- Styles --- */
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #ddd; display: flex; flex-direction: column; height: 100vh; user-select: none; }
/* Menu Bar & Dropdowns */
#menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 5px; border-bottom: 1px solid #3e3e3e; }
.menu-item {
padding: 5px 10px; cursor: pointer; font-size: 13px; color: #ccc; position: relative;
}
.menu-item:hover { background: #3e3e3e; color: #fff; }
.dropdown { position: relative; display: inline-block; }
.dropdown-content {
display: none; position: absolute; top: 100%; left: 0; background-color: #252526;
min-width: 180px; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.5); z-index: 1000;
border: 1px solid #454545;
}
.dropdown:hover .dropdown-content { display: block; }
.dd-item {
color: #ddd; padding: 8px 16px; text-decoration: none; display: block; font-size: 12px; cursor: pointer;
display: flex; justify-content: space-between;
}
.dd-item:hover { background-color: #094771; color: white; }
.dd-separator { border-top: 1px solid #454545; margin: 4px 0; }
.shortcut { color: #888; font-size: 10px; }
/* Main Layout */
#workspace { display: flex; flex: 1; overflow: hidden; }
.sidebar { width: 300px; background: #252526; display: flex; flex-direction: column; border-right: 1px solid #3e3e3e; border-left: 1px solid #3e3e3e; }
#sidebar-right { border-left: 1px solid #3e3e3e; border-right: none; }
.panel-header { background: #2d2d2d; padding: 8px 10px; font-weight: bold; font-size: 12px; text-transform: uppercase; color: #aaa; }
.panel-content { flex: 1; overflow-y: auto; padding: 10px; }
/* Viewport */
#viewport { flex: 1; position: relative; background: #1e1e1e; outline: none; overflow: hidden; }
#drop-zone-overlay {
position: absolute; top:0; left:0; width:100%; height:100%;
background: rgba(50, 150, 255, 0.2); border: 4px dashed #3296ff;
display: none; pointer-events: none; z-index: 10;
}
/* Mode Indicator */
#mode-indicator {
position: absolute; top: 10px; right: 10px;
background: rgba(0,0,0,0.6); color: #00ffcc; padding: 5px 10px;
border-radius: 4px; font-size: 12px; pointer-events: none;
}
/* Asset List */
.asset-item {
padding: 8px; margin-bottom: 2px; background: #333; border-radius: 2px; cursor: grab; font-size: 13px;
display: flex; align-items: center; border: 1px solid transparent;
}
.asset-item:hover { background: #3e3e3e; border-color: #555; }
.asset-icon { width: 12px; height: 12px; background: #569cd6; margin-right: 8px; }
/* Tabs */
.tabs { display: flex; background: #2d2d2d; border-bottom: 1px solid #3e3e3e; }
.tab { flex: 1; padding: 8px; text-align: center; cursor: pointer; font-size: 11px; color: #888; background: #252526; }
.tab.active { color: #fff; background: #333; border-top: 2px solid #007acc; }
.tab-content { display: none; padding-top: 10px; }
.tab-content.active { display: block; }
/* Forms */
label { display: block; margin-top: 8px; font-size: 11px; color: #888; }
input, select {
width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #555;
color: #eee; padding: 4px; margin-top: 2px; border-radius: 2px; font-size: 12px;
}
input:focus, select:focus { border-color: #007acc; outline: none; }
.row { display: flex; gap: 4px; }
.btn { width: 100%; padding: 6px; background: #0e639c; color: white; border: none; cursor: pointer; margin-top: 10px; font-size: 12px; }
.btn:hover { background: #1177bb; }
/* Context Menu */
#context-menu {
display: none; position: absolute; z-index: 2000;
background: #252526; border: 1px solid #454545; box-shadow: 2px 2px 5px rgba(0,0,0,0.5);
min-width: 120px;
}
.ctx-item { padding: 8px 12px; font-size: 12px; cursor: pointer; color: #ddd; }
.ctx-item:hover { background: #094771; color: white; }
/* Helpers */
.hidden { display: none !important; }
hr { border: 0; border-top: 1px solid #444; margin: 10px 0; }
.face-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; font-size: 11px; }
.face-tag { width: 60px; font-weight: bold; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="menubar">
<div class="dropdown">
<div class="menu-item">File</div>
<div class="dropdown-content">
<div class="dd-item" onclick="app.newFile()">New <span class="shortcut">Ctrl+N</span></div>
<div class="dd-item" onclick="app.openFile()">Open JSON... <span class="shortcut">Ctrl+O</span></div>
<div class="dd-separator"></div>
<div class="dd-item" onclick="app.saveFile()">Save (Overwrite) <span class="shortcut">Ctrl+S</span></div>
<div class="dd-item" onclick="app.saveAsFile()">Save As... <span class="shortcut">Ctrl+Shift+S</span></div>
</div>
</div>
<div class="dropdown">
<div class="menu-item">Edit</div>
<div class="dropdown-content">
<div class="dd-item" onclick="app.setMode('camera')">Camera Mode <span class="shortcut">Q</span></div>
<div class="dd-item" onclick="app.setMode('select')">Select Mode <span class="shortcut">W</span></div>
<div class="dd-separator"></div>
<div class="dd-item" onclick="app.setMode('translate')">Move <span class="shortcut">T</span></div>
<div class="dd-item" onclick="app.setMode('rotate')">Rotate <span class="shortcut">R</span></div>
<div class="dd-item" onclick="app.setMode('scale')">Scale <span class="shortcut">S</span></div>
<div class="dd-separator"></div>
<div class="dd-item" onclick="app.cut()">Cut <span class="shortcut">Ctrl+X</span></div>
<div class="dd-item" onclick="app.copy()">Copy <span class="shortcut">Ctrl+C</span></div>
<div class="dd-item" onclick="app.paste()">Paste <span class="shortcut">Ctrl+V</span></div>
<div class="dd-item" onclick="app.deleteSelection()">Delete <span class="shortcut">Del</span></div>
</div>
</div>
<div style="flex:1"></div>
<div style="font-size:11px; color:#666; margin-right:10px;">WFC Container Editor Ultimate</div>
</div>
<div id="workspace">
<div class="sidebar" id="sidebar-left">
<div class="panel-header">Part List (Assets)</div>
<div class="panel-content" id="asset-list">
<div style="color:#666; font-size:11px; text-align:center; padding-top:20px;">
Right-click to Add<br>Drag to Viewport
</div>
</div>
</div>
<div id="viewport">
<div id="drop-zone-overlay"></div>
<div id="mode-indicator">Mode: Camera</div>
</div>
<div class="sidebar" id="sidebar-right">
<div class="tabs">
<div class="tab active" onclick="app.switchTab('part')">Part Props</div>
<div class="tab" onclick="app.switchTab('container')">Container Props</div>
</div>
<div class="panel-content">
<div id="tab-part" class="tab-content active">
<div id="part-props-content" class="hidden">
<label>Part Name / ID</label>
<input type="text" id="p-id">
<hr>
<label>Position</label>
<div class="row">
<input type="number" id="p-pos-x" step="0.1"><input type="number" id="p-pos-y" step="0.1"><input type="number" id="p-pos-z" step="0.1">
</div>
<label>Rotation (Deg)</label>
<div class="row">
<input type="number" id="p-rot-x" step="45"><input type="number" id="p-rot-y" step="45"><input type="number" id="p-rot-z" step="45">
</div>
<label>Scale</label>
<div class="row">
<input type="number" id="p-scl-x" step="0.1"><input type="number" id="p-scl-y" step="0.1"><input type="number" id="p-scl-z" step="0.1">
</div>
<hr>
<button class="btn" style="background:#a61d24" onclick="app.deleteSelection()">Delete Part</button>
</div>
<div id="no-part-msg" style="color:#666; text-align:center; margin-top:20px;">No part selected</div>
</div>
<div id="tab-container" class="tab-content">
<label>Container Name</label>
<input type="text" id="c-name" value="New Container">
<label>Symmetry Type</label>
<select id="c-symmetry">
<option value="X">None (X)</option>
<option value="T">T-Symmetry</option>
<option value="I">I-Symmetry</option>
<option value="L">L-Symmetry</option>
<option value="D">D-Symmetry (All)</option>
</select>
<hr>
<div style="display:flex; justify-content:space-between; align-items:center;">
<label style="margin:0">Socket Definitions</label>
<button style="width:auto; padding:2px 6px; margin:0;" onclick="app.addSocketType()">+ Add</button>
</div>
<label style="margin-top:10px;">Boundary Sockets (Rules)</label>
<div id="socket-controls">
<div class="face-row"><span class="face-tag" style="color:#e74c3c">Right X+</span> <select class="c-socket" data-face="0"></select></div>
<div class="face-row"><span class="face-tag" style="color:#e74c3c">Left X-</span> <select class="c-socket" data-face="1"></select></div>
<div class="face-row"><span class="face-tag" style="color:#2ecc71">Top Y+</span> <select class="c-socket" data-face="2"></select></div>
<div class="face-row"><span class="face-tag" style="color:#2ecc71">Bottom Y-</span> <select class="c-socket" data-face="3"></select></div>
<div class="face-row"><span class="face-tag" style="color:#3498db">Front Z+</span> <select class="c-socket" data-face="4"></select></div>
<div class="face-row"><span class="face-tag" style="color:#3498db">Back Z-</span> <select class="c-socket" data-face="5"></select></div>
</div>
</div>
</div>
</div>
</div>
<div id="context-menu">
</div>
<input type="file" id="file-open-json" accept=".json" style="display:none">
<input type="file" id="file-import-asset" multiple accept=".glb,.gltf,.fbx,.obj" style="display:none">
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { TransformControls } from 'three/addons/controls/TransformControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
class EditorApp {
constructor() {
// State
this.assets = [];
this.sceneObjects = [];
this.socketTypes = [
{ id: 'empty', name: 'Empty', color: '#ffffff' },
{ id: 'wall', name: 'Wall', color: '#7f8c8d' }
];
// Container Data
this.containerData = {
name: 'New Container',
symmetry: 'X',
sockets: ['empty','empty','empty','empty','empty','empty']
};
this.currentMode = 'camera'; // camera, select, translate, rotate, scale
this.selectedObject = null;
this.clipboard = null;
this.currentFileName = "container.json";
this.initThree();
this.initUI();
this.updateModeUI();
this.renderAssetList();
this.renderSocketOptions();
this.animate();
}
initThree() {
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1e1e1e);
// Helpers
this.grid = new THREE.GridHelper(10, 10, 0x444444, 0x222222);
this.scene.add(this.grid);
this.axes = new THREE.AxesHelper(1);
this.scene.add(this.axes);
// Camera
const vp = document.getElementById('viewport');
this.camera = new THREE.PerspectiveCamera(50, vp.clientWidth / vp.clientHeight, 0.1, 1000);
this.camera.position.set(5, 5, 8);
// Renderer
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(vp.clientWidth, vp.clientHeight);
this.renderer.shadowMap.enabled = true;
vp.appendChild(this.renderer.domElement);
// Controls
this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
this.orbit.enableDamping = true;
this.orbit.dampingFactor = 0.1;
// Left click orbit by default
this.orbit.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN
};
this.transformControl = new TransformControls(this.camera, this.renderer.domElement);
this.transformControl.addEventListener('dragging-changed', (event) => {
this.orbit.enabled = !event.value;
});
this.transformControl.addEventListener('change', () => this.updatePartPropsUI());
this.scene.add(this.transformControl);
// Lights
const hemi = new THREE.HemisphereLight(0xffffff, 0x444444, 0.8);
this.scene.add(hemi);
const dir = new THREE.DirectionalLight(0xffffff, 1);
dir.position.set(5, 10, 7);
this.scene.add(dir);
// Events
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onMouseDown(e));
window.addEventListener('resize', () => this.onResize());
// Keyboard Shortcuts
window.addEventListener('keydown', (e) => this.onKeyDown(e));
}
initUI() {
// File Inputs
document.getElementById('file-open-json').addEventListener('change', (e) => this.handleOpenJSON(e));
document.getElementById('file-import-asset').addEventListener('change', (e) => this.handleImportAssets(e.target.files));
// Drag & Drop
const vp = document.getElementById('viewport');
vp.addEventListener('dragover', (e) => { e.preventDefault(); document.getElementById('drop-zone-overlay').style.display = 'block'; });
vp.addEventListener('dragleave', () => document.getElementById('drop-zone-overlay').style.display = 'none');
vp.addEventListener('drop', (e) => this.handleViewportDrop(e));
// Container Props Inputs
document.getElementById('c-name').addEventListener('input', (e) => this.containerData.name = e.target.value);
document.getElementById('c-symmetry').addEventListener('change', (e) => this.containerData.symmetry = e.target.value);
document.querySelectorAll('.c-socket').forEach(sel => {
sel.addEventListener('change', (e) => {
const idx = parseInt(e.target.dataset.face);
this.containerData.sockets[idx] = e.target.value;
this.updateSocketVisuals();
});
});
// Part Props Inputs
['p-id', 'p-pos-x','p-pos-y','p-pos-z','p-rot-x','p-rot-y','p-rot-z','p-scl-x','p-scl-y','p-scl-z'].forEach(id => {
document.getElementById(id).addEventListener('change', () => this.applyPartPropsFromUI());
});
// Context Menu
const assetList = document.getElementById('asset-list');
assetList.addEventListener('contextmenu', (e) => this.showContextMenu(e, 'asset-list'));
document.addEventListener('click', () => document.getElementById('context-menu').style.display = 'none');
}
// --- Mode & Actions ---
setMode(mode) {
this.currentMode = mode;
document.getElementById('mode-indicator').innerText = "Mode: " + mode.charAt(0).toUpperCase() + mode.slice(1);
// Config Controls based on mode
this.transformControl.detach();
if (mode === 'camera') {
this.orbit.enabled = true;
// Orbit on Left Click
this.orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
this.renderer.domElement.style.cursor = 'grab';
} else if (mode === 'select') {
this.orbit.enabled = true;
// Orbit still enabled, but click selects
this.orbit.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
this.renderer.domElement.style.cursor = 'default';
} else {
// Transform Modes
if (this.selectedObject) {
this.transformControl.setMode(mode);
this.transformControl.attach(this.selectedObject);
}
this.orbit.enabled = true;
this.renderer.domElement.style.cursor = 'default';
}
}
// --- File Operations ---
newFile() {
if(confirm("Create new file? Unsaved changes will be lost.")) {
this.clearScene();
this.containerData = { name: 'New Container', symmetry: 'X', sockets: ['empty','empty','empty','empty','empty','empty'] };
this.currentFileName = "container.json";
this.updateContainerUI();
}
}
openFile() {
document.getElementById('file-open-json').click();
}
handleOpenJSON(e) {
const file = e.target.files[0];
if(!file) return;
const reader = new FileReader();
reader.onload = (evt) => {
try {
const data = JSON.parse(evt.target.result);
this.loadFromData(data);
this.currentFileName = file.name;
} catch(err) {
alert("Invalid JSON");
}
};
reader.readAsText(file);
e.target.value = ''; // reset
}
saveFile() {
// In browser, "Save" acts as "Download" with the current filename
this.downloadJSON(this.currentFileName);
}
saveAsFile() {
let name = prompt("Enter filename:", this.currentFileName);
if(name) {
if(!name.endsWith('.json')) name += '.json';
this.currentFileName = name;
this.downloadJSON(name);
}
}
downloadJSON(filename) {
const data = this.serializeData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
}
serializeData() {
// Gather Container Data
return {
container: this.containerData,
socketTypes: this.socketTypes,
parts: this.sceneObjects.map(obj => ({
assetName: obj.userData.assetName,
id: obj.userData.id,
transform: {
pos: obj.position.toArray(),
rot: obj.rotation.toArray(),
scl: obj.scale.toArray()
}
}))
};
}
loadFromData(data) {
this.clearScene();
if(data.container) this.containerData = data.container;
if(data.socketTypes) {
this.socketTypes = data.socketTypes;
this.renderSocketOptions();
}
// Reconstruct parts
// Note: This assumes assets are already loaded or we use placeholders if missing
// In a real app, we'd bundle assets or ask user to reload them.
if(data.parts) {
data.parts.forEach(p => {
this.instantiateAsset(p.assetName, p);
});
}
this.updateContainerUI();
}
clearScene() {
this.selectObject(null);
this.sceneObjects.forEach(o => this.scene.remove(o));
this.sceneObjects = [];
// Socket visual helper group clear
this.updateSocketVisuals();
}
// --- Edit Operations ---
cut() {
if(!this.selectedObject) return;
this.copy();
this.deleteSelection();
}
copy() {
if(!this.selectedObject) return;
this.clipboard = {
assetName: this.selectedObject.userData.assetName,
rotation: this.selectedObject.rotation.clone(),
scale: this.selectedObject.scale.clone()
};
console.log("Copied to clipboard");
}
paste() {
if(!this.clipboard) return;
const pData = {
assetName: this.clipboard.assetName,
transform: {
pos: [0,0,0], // Paste at origin or near camera center
rot: this.clipboard.rotation.toArray(),
scl: this.clipboard.scale.toArray()
}
};
this.instantiateAsset(this.clipboard.assetName, pData);
}
deleteSelection() {
if(!this.selectedObject) return;
this.transformControl.detach();
this.scene.remove(this.selectedObject);
this.sceneObjects = this.sceneObjects.filter(o => o !== this.selectedObject);
this.selectObject(null);
}
// --- Interaction ---
onMouseDown(e) {
// If interacting with Gizmo, don't select
if (this.transformControl.dragging) return;
// Left click for selection in Select/Transform modes
if (e.button === 0 && this.currentMode !== 'camera') {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.sceneObjects, true);
if (intersects.length > 0) {
let target = intersects[0].object;
while(target.parent && !target.userData.isPart) {
target = target.parent;
}
if(target.userData.isPart) this.selectObject(target);
} else {
this.selectObject(null);
}
}
}
selectObject(obj) {
this.selectedObject = obj;
if(obj) {
if (this.currentMode !== 'camera' && this.currentMode !== 'select') {
this.transformControl.attach(obj);
}
this.switchTab('part');
this.updatePartPropsUI();
} else {
this.transformControl.detach();
document.getElementById('part-props-content').classList.add('hidden');
document.getElementById('no-part-msg').classList.remove('hidden');
}
}
// --- Asset Management ---
handleImportAssets(files) {
const loaders = { 'glb': new GLTFLoader(), 'gltf': new GLTFLoader(), 'fbx': new FBXLoader(), 'obj': new OBJLoader() };
Array.from(files).forEach(file => {
const ext = file.name.split('.').pop().toLowerCase();
const loader = loaders[ext];
if(!loader) return;
const url = URL.createObjectURL(file);
loader.load(url, (loaded) => {
this.assets.push({ name: file.name, model: loaded });
this.renderAssetList();
});
});
// reset input
document.getElementById('file-import-asset').value = '';
}
renderAssetList() {
const list = document.getElementById('asset-list');
list.innerHTML = '';
this.assets.forEach((asset, idx) => {
const el = document.createElement('div');
el.className = 'asset-item';
el.draggable = true;
el.dataset.index = idx;
el.innerHTML = `<div class="asset-icon"></div> ${asset.name}`;
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetName', asset.name);
});
list.appendChild(el);
});
}
instantiateAsset(assetName, data=null) {
const asset = this.assets.find(a => a.name === assetName);
if(!asset) {
// Fallback placeholder if asset missing (e.g. loaded from JSON without files)
console.warn("Missing asset:", assetName);
return;
}
let obj;
if(asset.model.scene) obj = asset.model.scene.clone();
else obj = asset.model.clone();
// Normalize size
const box = new THREE.Box3().setFromObject(obj);
const size = new THREE.Vector3(); box.getSize(size);
const max = Math.max(size.x, size.y, size.z);
if(max > 0) obj.scale.multiplyScalar(1/max);
// Wrapper
const wrapper = new THREE.Group();
wrapper.add(obj);
wrapper.userData = {
isPart: true,
assetName: assetName,
id: data ? data.id : (assetName + '_' + Math.floor(Math.random()*1000))
};
if(data && data.transform) {
wrapper.position.fromArray(data.transform.pos);
wrapper.rotation.fromArray(data.transform.rot);
wrapper.scale.fromArray(data.transform.scl);
}
this.scene.add(wrapper);
this.sceneObjects.push(wrapper);
this.selectObject(wrapper);
}
handleViewportDrop(e) {
e.preventDefault();
document.getElementById('drop-zone-overlay').style.display = 'none';
const name = e.dataTransfer.getData('assetName');
if(name) {
// Drop position raycast? (Simplified: drop at 0,0,0)
this.instantiateAsset(name);
} else {
// Handle file drop directly
if(e.dataTransfer.files.length > 0) {
this.handleImportAssets(e.dataTransfer.files);
}
}
}
// --- Context Menu ---
showContextMenu(e, context) {
e.preventDefault();
const menu = document.getElementById('context-menu');
menu.style.display = 'block';
menu.style.left = e.pageX + 'px';
menu.style.top = e.pageY + 'px';
menu.innerHTML = '';
if (context === 'asset-list') {
const item = e.target.closest('.asset-item');
if (item) {
// On Item
const idx = item.dataset.index;
menu.innerHTML = `<div class="ctx-item" id="ctx-del">Delete Asset</div>`;
document.getElementById('ctx-del').onclick = () => {
this.assets.splice(idx, 1);
this.renderAssetList();
};
} else {
// On Empty Space
menu.innerHTML = `<div class="ctx-item" id="ctx-add">Add Asset...</div>`;
document.getElementById('ctx-add').onclick = () => {
document.getElementById('file-import-asset').click();
};
}
}
}
// --- UI Updates ---
switchTab(name) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
if(name === 'part') {
document.querySelectorAll('.tab')[0].classList.add('active');
document.getElementById('tab-part').classList.add('active');
} else {
document.querySelectorAll('.tab')[1].classList.add('active');
document.getElementById('tab-container').classList.add('active');
}
}
updatePartPropsUI() {
if(!this.selectedObject) return;
const o = this.selectedObject;
document.getElementById('part-props-content').classList.remove('hidden');
document.getElementById('no-part-msg').classList.add('hidden');
document.getElementById('p-id').value = o.userData.id;
const toDeg = (rad) => (rad * 180 / Math.PI).toFixed(1);
document.getElementById('p-pos-x').value = o.position.x.toFixed(2);
document.getElementById('p-pos-y').value = o.position.y.toFixed(2);
document.getElementById('p-pos-z').value = o.position.z.toFixed(2);
document.getElementById('p-rot-x').value = toDeg(o.rotation.x);
document.getElementById('p-rot-y').value = toDeg(o.rotation.y);
document.getElementById('p-rot-z').value = toDeg(o.rotation.z);
document.getElementById('p-scl-x').value = o.scale.x.toFixed(2);
document.getElementById('p-scl-y').value = o.scale.y.toFixed(2);
document.getElementById('p-scl-z').value = o.scale.z.toFixed(2);
}
applyPartPropsFromUI() {
if(!this.selectedObject) return;
const o = this.selectedObject;
o.userData.id = document.getElementById('p-id').value;
const toRad = (deg) => deg * Math.PI / 180;
o.position.set(
parseFloat(document.getElementById('p-pos-x').value),
parseFloat(document.getElementById('p-pos-y').value),
parseFloat(document.getElementById('p-pos-z').value)
);
o.rotation.set(
toRad(parseFloat(document.getElementById('p-rot-x').value)),
toRad(parseFloat(document.getElementById('p-rot-y').value)),
toRad(parseFloat(document.getElementById('p-rot-z').value))
);
o.scale.set(
parseFloat(document.getElementById('p-scl-x').value),
parseFloat(document.getElementById('p-scl-y').value),
parseFloat(document.getElementById('p-scl-z').value)
);
}
updateContainerUI() {
document.getElementById('c-name').value = this.containerData.name;
document.getElementById('c-symmetry').value = this.containerData.symmetry;
document.querySelectorAll('.c-socket').forEach((sel, i) => {
sel.value = this.containerData.sockets[i];
});
this.updateSocketVisuals();
}
// --- WFC Sockets ---
addSocketType() {
const name = prompt("New Socket Name:");
if(name) {
const id = name.toLowerCase().replace(/\s/g,'_');
const color = '#' + Math.floor(Math.random()*16777215).toString(16);
this.socketTypes.push({ id, name, color });
this.renderSocketOptions();
}
}
renderSocketOptions() {
document.querySelectorAll('.c-socket').forEach(sel => {
const val = sel.value;
sel.innerHTML = '';
this.socketTypes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id;
opt.innerText = t.name;
sel.appendChild(opt);
});
if(val) sel.value = val;
});
}
updateSocketVisuals() {
// Clear old helpers
for(let i=this.scene.children.length-1; i>=0; i--) {
if(this.scene.children[i].userData.isSocketHelper) {
this.scene.remove(this.scene.children[i]);
}
}
// Draw 6 spheres representing container boundary sockets (Assuming 2x2x2 boundary for visualization)
const range = 2;
const dirs = [
{v: new THREE.Vector3(range,0,0), c:0}, {v: new THREE.Vector3(-range,0,0), c:1},
{v: new THREE.Vector3(0,range,0), c:2}, {v: new THREE.Vector3(0,-range,0), c:3},
{v: new THREE.Vector3(0,0,range), c:4}, {v: new THREE.Vector3(0,0,-range), c:5}
];
dirs.forEach(d => {
const sID = this.containerData.sockets[d.c];
const sType = this.socketTypes.find(t=>t.id===sID);
if(sType) {
const geo = new THREE.SphereGeometry(0.3, 16, 16);
const mat = new THREE.MeshBasicMaterial({ color: sType.color, wireframe:true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(d.v);
mesh.userData.isSocketHelper = true;
this.scene.add(mesh);
// Line to center
const pts = [new THREE.Vector3(0,0,0), d.v];
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(pts),
new THREE.LineBasicMaterial({ color: sType.color, transparent:true, opacity:0.3 })
);
line.userData.isSocketHelper = true;
this.scene.add(line);
}
});
}
updateModeUI() {
// Done in setMode
}
onResize() {
const vp = document.getElementById('viewport');
this.camera.aspect = vp.clientWidth / vp.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(vp.clientWidth, vp.clientHeight);
}
onKeyDown(e) {
// Shortcuts
if(e.key === 'Delete') this.deleteSelection();
if(e.ctrlKey) {
if(e.key === 'x') this.cut();
if(e.key === 'c') this.copy();
if(e.key === 'v') this.paste();
if(e.key === 's') { e.preventDefault(); e.shiftKey ? this.saveAsFile() : this.saveFile(); }
if(e.key === 'n') { e.preventDefault(); this.newFile(); }
if(e.key === 'o') { e.preventDefault(); this.openFile(); }
}
if(document.activeElement.tagName !== 'INPUT') {
if(e.key === 'q') this.setMode('camera');
if(e.key === 'w') this.setMode('select');
if(e.key === 't') this.setMode('translate');
if(e.key === 'r') this.setMode('rotate');
if(e.key === 's') this.setMode('scale');
}
}
animate() {
requestAnimationFrame(() => this.animate());
this.orbit.update();
this.renderer.render(this.scene, this.camera);
}
}
window.app = new EditorApp();
</script>
</body>
</html><!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>WFC Map Editor Ultimate</title>
<style>
/* --- Common UI Styles --- */
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #ddd; display: flex; flex-direction: column; height: 100vh; user-select: none; }
/* Menu Bar */
#menubar { height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 5px; border-bottom: 1px solid #3e3e3e; }
.menu-item { padding: 5px 10px; cursor: pointer; font-size: 13px; color: #ccc; position: relative; }
.menu-item:hover { background: #3e3e3e; color: #fff; }
.dropdown { position: relative; display: inline-block; }
.dropdown-content {
display: none; position: absolute; top: 100%; left: 0; background-color: #252526;
min-width: 180px; box-shadow: 0px 8px 16px 0px rgba(0,0,0,0.5); z-index: 1000;
border: 1px solid #454545;
}
.dropdown:hover .dropdown-content { display: block; }
.dd-item { color: #ddd; padding: 8px 16px; display: block; font-size: 12px; cursor: pointer; }
.dd-item:hover { background-color: #094771; color: white; }
.dd-separator { border-top: 1px solid #454545; margin: 4px 0; }
.shortcut { float: right; color: #888; font-size: 10px; }
/* Workspace */
#workspace { display: flex; flex: 1; overflow: hidden; }
.sidebar { width: 280px; background: #252526; display: flex; flex-direction: column; border-right: 1px solid #3e3e3e; border-left: 1px solid #3e3e3e; }
#sidebar-right { border-left: 1px solid #3e3e3e; border-right: none; }
.panel-header { background: #2d2d2d; padding: 8px 10px; font-weight: bold; font-size: 12px; text-transform: uppercase; color: #aaa; }
.panel-content { flex: 1; overflow-y: auto; padding: 10px; }
/* Viewport */
#viewport { flex: 1; position: relative; background: #1e1e1e; outline: none; overflow: hidden; }
#loading-overlay {
position: absolute; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.7);
display:none; align-items:center; justify-content:center; color:white; font-size:20px; z-index:20;
}
/* Palette List */
.palette-item {
padding: 8px; margin-bottom: 4px; background: #333; border-radius: 2px; cursor: pointer; font-size: 12px;
display: flex; align-items: center; border: 1px solid transparent;
}
.palette-item:hover { background: #3e3e3e; }
.palette-item.selected { border-color: #007acc; background: #2a2d2e; }
.color-dot { width: 10px; height: 10px; margin-right: 8px; border-radius: 50%; border:1px solid #555; }
/* Forms */
label { display: block; margin-top: 10px; font-size: 11px; color: #888; }
input, button, select {
width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #555;
color: #eee; padding: 6px; margin-top: 4px; border-radius: 2px; font-size: 12px;
}
input:focus, button:focus { border-color: #007acc; outline: none; }
button { cursor: pointer; background: #0e639c; color: white; border: none; }
button:hover { background: #1177bb; }
button.secondary { background: #444; }
button.secondary:hover { background: #555; }
.row { display: flex; gap: 5px; }
hr { border: 0; border-top: 1px solid #444; margin: 15px 0; }
#cursor-info {
position: absolute; bottom: 10px; left: 10px;
background: rgba(0,0,0,0.6); padding: 5px 10px;
border-radius: 4px; pointer-events: none; font-size: 12px; color: #fff;
}
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="menubar">
<div class="dropdown">
<div class="menu-item">File</div>
<div class="dropdown-content">
<div class="dd-item" onclick="app.newMap()">New Map</div>
<div class="dd-item" onclick="document.getElementById('file-map-json').click()">Open Map JSON...</div>
<div class="dd-item" onclick="app.saveMap()">Save Map JSON</div>
<div class="dd-separator"></div>
<div class="dd-item" onclick="document.getElementById('file-assets').click()">1. Import Assets (GLB/FBX)</div>
<div class="dd-item" onclick="document.getElementById('file-config').click()">2. Import Container Config</div>
</div>
</div>
<div class="dropdown">
<div class="menu-item">Edit</div>
<div class="dropdown-content">
<div class="dd-item" onclick="app.wfc.reset()">Clear Map</div>
</div>
</div>
<div style="flex:1"></div>
<div style="font-size:11px; color:#666; margin-right:10px;">WFC Map Editor Ultimate</div>
</div>
<div id="workspace">
<div class="sidebar">
<div class="panel-header">Container Palette</div>
<div class="panel-content" id="palette-list">
<div style="text-align:center; color:#666; font-size:11px; margin-top:20px;">
Please import<br>Container Config JSON
</div>
</div>
</div>
<div id="viewport">
<div id="loading-overlay">Processing...</div>
<div id="cursor-info">Ready</div>
</div>
<div class="sidebar" id="sidebar-right">
<div class="panel-header">Map Settings</div>
<div class="panel-content">
<label>Grid Size</label>
<div class="row">
<input type="number" id="grid-x" value="8" min="2">
<input type="number" id="grid-y" value="4" min="1">
<input type="number" id="grid-z" value="8" min="2">
</div>
<button class="secondary" onclick="app.resizeMap()">Resize Map</button>
<hr>
<label>Wave Function Collapse</label>
<button onclick="app.runAutoFill()">Auto Fill (Solve)</button>
<button class="secondary" onclick="app.stepWFC()" style="margin-top:5px;">1 Step</button>
<hr>
<label>Cell Properties</label>
<div id="cell-info" style="font-size:11px; color:#aaa; margin-top:5px;">
Select a cell...
</div>
</div>
</div>
</div>
<input type="file" id="file-assets" multiple accept=".glb,.gltf,.fbx,.obj" style="display:none">
<input type="file" id="file-config" accept=".json" style="display:none">
<input type="file" id="file-map-json" accept=".json" style="display:none">
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { FBXLoader } from 'three/addons/loaders/FBXLoader.js';
import { OBJLoader } from 'three/addons/loaders/OBJLoader.js';
// --- WFC Logic ---
class WFCModule {
constructor(config, rotation = 0) {
this.id = config.container.name; // Unique identifier base
this.uid = `${config.container.name}_r${rotation}`; // Variant ID
this.config = config;
this.rotation = rotation; // 0, 1, 2, 3 (x90 deg)
// Calculate rotated sockets
// Original: [px, nx, py, ny, pz, nz]
// Rot 1 (90): px->pz, nx->nz, pz->nx, nz->px (simplified Y-rotation)
this.sockets = this.rotateSockets(config.container.sockets, rotation);
}
rotateSockets(sockets, rot) {
// sockets: 0:px, 1:nx, 2:py, 3:ny, 4:pz, 5:nz
let s = [...sockets];
for(let i=0; i<rot; i++) {
const next = [...s];
next[0] = s[5]; // px <- nz
next[1] = s[4]; // nx <- pz
next[4] = s[0]; // pz <- px
next[5] = s[1]; // nz <- nx
// py(2), ny(3) rotate in place? Assuming sockets have rotational symmetry IDs or just match strings.
// For simple string matching "wall"=="wall", no change needed for py/ny unless socket itself has direction.
// We assume sockets are isotropic for now.
s = next;
}
return s;
}
}
class WFCCell {
constructor(x, y, z, modules) {
this.x = x; this.y = y; this.z = z;
this.collapsed = false;
this.options = [...modules]; // Available modules
this.module = null; // Final choice
}
}
class WFCManager {
constructor(sx, sy, sz, configs) {
this.size = {x:sx, y:sy, z:sz};
this.configs = configs;
this.modules = this.generateModules(configs);
this.cells = [];
this.initGrid();
}
generateModules(configs) {
let mods = [];
// Always add Empty module
const emptyConf = { container: { name: 'Empty', sockets: ['empty','empty','empty','empty','empty','empty'], symmetry:'X' }, parts:[] };
mods.push(new WFCModule(emptyConf, 0));
configs.forEach(c => {
// Generate variants based on symmetry
const sym = c.container.symmetry || 'X';
const rots = (sym === 'X') ? [0] :
(sym === 'I') ? [0, 1] :
[0, 1, 2, 3]; // T, L, D usually imply 4 rotations in grid
rots.forEach(r => {
mods.push(new WFCModule(c, r));
});
});
return mods;
}
initGrid() {
this.cells = [];
for(let x=0; x<this.size.x; x++) {
this.cells[x] = [];
for(let y=0; y<this.size.y; y++) {
this.cells[x][y] = [];
for(let z=0; z<this.size.z; z++) {
this.cells[x][y][z] = new WFCCell(x, y, z, this.modules);
}
}
}
}
reset() { this.initGrid(); }
// Collapse specific cell manually
forceCollapse(x, y, z, moduleBaseName) {
const cell = this.cells[x][y][z];
// Find a variant that matches this name (default to rot 0)
const target = this.modules.find(m => m.config.container.name === moduleBaseName);
if(target) {
cell.collapsed = true;
cell.module = target;
cell.options = [target];
this.propagate(cell);
return true;
}
return false;
}
clearCell(x, y, z) {
// Hard reset... logic needs full rebuild usually.
// Simplified: Just reset this cell to Empty or Reset Whole Grid options (expensive)
// For this editor, we'll re-init grid but keep collapsed cells
const oldCells = this.cells;
this.initGrid();
// Restore others
for(let ix=0; ix<this.size.x; ix++) for(let iy=0; iy<this.size.y; iy++) for(let iz=0; iz<this.size.z; iz++) {
if(ix===x && iy===y && iz===z) continue; // Skip target
const old = oldCells[ix][iy][iz];
if(old.collapsed) {
const newC = this.cells[ix][iy][iz];
newC.collapsed = true;
newC.module = old.module;
newC.options = [old.module];
this.propagate(newC);
}
}
}
solveStep() {
// Find min entropy
let minEnt = Infinity;
let candidates = [];
this.loopCells(c => {
if(!c.collapsed && c.options.length > 0) {
if(c.options.length < minEnt) {
minEnt = c.options.length;
candidates = [c];
} else if(c.options.length === minEnt) {
candidates.push(c);
}
}
});
if(candidates.length === 0) return false; // Done or failed
const target = candidates[Math.floor(Math.random() * candidates.length)];
this.collapse(target);
this.propagate(target);
return true;
}
collapse(cell) {
cell.collapsed = true;
if(cell.options.length === 0) return; // Contradiction
cell.module = cell.options[Math.floor(Math.random() * cell.options.length)];
cell.options = [cell.module];
}
propagate(startCell) {
const stack = [startCell];
// 0:px, 1:nx, 2:py, 3:ny, 4:pz, 5:nz
// Opposites: 0<->1, 2<->3, 4<->5
const dirs = [
{x:1, y:0, z:0, d:0, o:1}, {x:-1, y:0, z:0, d:1, o:0},
{x:0, y:1, z:0, d:2, o:3}, {x:0, y:-1, z:0, d:3, o:2},
{x:0, y:0, z:1, d:4, o:5}, {x:0, y:0, z:-1, d:5, o:4}
];
while(stack.length > 0) {
const cur = stack.pop();
const curOpts = cur.options;
if(curOpts.length === 0) continue; // Contradiction state
for(let dir of dirs) {
const nx = cur.x + dir.x, ny = cur.y + dir.y, nz = cur.z + dir.z;
if(nx<0||nx>=this.size.x || ny<0||ny>=this.size.y || nz<0||nz>=this.size.z) continue;
const neighbor = this.cells[nx][ny][nz];
if(neighbor.collapsed) continue;
const origCount = neighbor.options.length;
neighbor.options = neighbor.options.filter(nOpt => {
// Can nOpt connect to ANY of curOpts?
return curOpts.some(cOpt => {
return cOpt.sockets[dir.d] === nOpt.sockets[dir.o];
});
});
if(neighbor.options.length === 0) {
console.warn("Contradiction at", nx, ny, nz);
}
if(neighbor.options.length < origCount) {
stack.push(neighbor);
}
}
}
}
loopCells(cb) {
for(let x=0; x<this.size.x; x++) for(let y=0; y<this.size.y; y++) for(let z=0; z<this.size.z; z++) cb(this.cells[x][y][z]);
}
}
// --- App Logic ---
class MapEditor {
constructor() {
this.scene = null;
this.gridSize = {x:8, y:4, z:8};
this.configs = []; // Loaded Container Configs
this.assets = {}; // Name -> Three.Object3D
this.wfc = null;
this.selectedModuleId = null; // From Palette
this.cellMeshes = []; // Visualization
this.initThree();
this.initUI();
this.animate();
}
initThree() {
const vp = document.getElementById('viewport');
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x222);
this.camera = new THREE.PerspectiveCamera(50, vp.clientWidth/vp.clientHeight, 0.1, 1000);
this.camera.position.set(10, 10, 10);
this.renderer = new THREE.WebGLRenderer({antialias:true});
this.renderer.setSize(vp.clientWidth, vp.clientHeight);
this.renderer.shadowMap.enabled = true;
vp.appendChild(this.renderer.domElement);
this.orbit = new OrbitControls(this.camera, this.renderer.domElement);
// Lights
this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const dl = new THREE.DirectionalLight(0xffffff, 0.8);
dl.position.set(10, 20, 10);
dl.castShadow = true;
this.scene.add(dl);
// Grid Helper
this.gridHelper = new THREE.Group();
this.scene.add(this.gridHelper);
// Raycaster
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.renderer.domElement.addEventListener('pointerdown', (e) => this.onMouseDown(e));
this.renderer.domElement.addEventListener('mousemove', (e) => this.onMouseMove(e));
// Hover Box
this.hoverBox = new THREE.Mesh(
new THREE.BoxGeometry(1.05, 1.05, 1.05),
new THREE.MeshBasicMaterial({color:0x00ff00, wireframe:true, opacity:0.5, transparent:true})
);
this.scene.add(this.hoverBox);
this.hoverBox.visible = false;
}
initUI() {
// Inputs
document.getElementById('file-assets').addEventListener('change', (e) => this.loadAssets(e.target.files));
document.getElementById('file-config').addEventListener('change', (e) => this.loadConfig(e.target.files[0]));
document.getElementById('file-map-json').addEventListener('change', (e) => this.loadMapJSON(e.target.files[0]));
window.addEventListener('resize', () => {
const vp = document.getElementById('viewport');
this.camera.aspect = vp.clientWidth/vp.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(vp.clientWidth, vp.clientHeight);
});
// Init empty map
this.resizeMap();
}
// --- Logic ---
async loadAssets(files) {
const loaders = { 'glb':new GLTFLoader(), 'fbx':new FBXLoader(), 'obj':new OBJLoader() };
document.getElementById('loading-overlay').style.display = 'flex';
for(let file of files) {
const ext = file.name.split('.').pop().toLowerCase();
const loader = loaders[ext] || loaders['glb'];
const url = URL.createObjectURL(file);
try {
const gltf = await loader.loadAsync(url);
this.assets[file.name] = (gltf.scene || gltf); // Handle GLTF vs FBX
console.log("Loaded Asset:", file.name);
} catch(e) { console.error(e); }
}
document.getElementById('loading-overlay').style.display = 'none';
this.updateVisuals(); // Refresh if meshes were missing
}
loadConfig(file) {
const reader = new FileReader();
reader.onload = (e) => {
const data = JSON.parse(e.target.result);
// data is array of objects from Container Editor
// Wrap each in a structure easy to use
// The Container Editor exported: [{id, type, sockets, symmetry, transform...}] which is actually Parts?
// Ah, the Container Editor exported the SCENE objects list.
// We need to group them. But wait, the previous Container Editor exported a LIST of parts, but didn't explicitly group them into "Modules".
// **Correction**: The Container Editor logic I wrote previously exports a FLAT list of parts in the scene.
// This implies the scene represents *ONE* container.
// Let's assume the user imports MULTIPLE json files, each representing ONE container module.
// OR, let's assume the user constructed the scene as multiple containers?
// No, usually Container Editor edits ONE module.
// Let's treat the imported JSON as ONE module definition.
// Prompt for name? Or use filename.
const name = file.name.replace('.json', '');
const moduleConfig = {
container: {
name: name,
// Extract container props from the first object that has them, or defaults
sockets: data[0]?.sockets || ['empty','empty','empty','empty','empty','empty'],
symmetry: data[0]?.symmetry || 'X'
},
parts: data // The whole list is the parts
};
// Remove existing if overwrite
this.configs = this.configs.filter(c => c.container.name !== name);
this.configs.push(moduleConfig);
this.renderPalette();
// Re-init WFC with new configs
this.wfc = new WFCManager(this.gridSize.x, this.gridSize.y, this.gridSize.z, this.configs);
};
reader.readAsText(file);
}
resizeMap() {
const x = parseInt(document.getElementById('grid-x').value);
const y = parseInt(document.getElementById('grid-y').value);
const z = parseInt(document.getElementById('grid-z').value);
this.gridSize = {x, y, z};
this.wfc = new WFCManager(x, y, z, this.configs);
this.initVisualGrid();
}
renderPalette() {
const p = document.getElementById('palette-list');
p.innerHTML = '';
// Add Empty
const empty = document.createElement('div');
empty.className = 'palette-item';
empty.innerHTML = `<div class="color-dot" style="background:#000"></div>Empty (Clear)`;
empty.onclick = () => {
document.querySelectorAll('.palette-item').forEach(e=>e.classList.remove('selected'));
empty.classList.add('selected');
this.selectedModuleId = 'Empty';
};
p.appendChild(empty);
this.configs.forEach(c => {
const el = document.createElement('div');
el.className = 'palette-item';
el.innerHTML = `<div class="color-dot" style="background:#007acc"></div>${c.container.name}`;
el.onclick = () => {
document.querySelectorAll('.palette-item').forEach(e=>e.classList.remove('selected'));
el.classList.add('selected');
this.selectedModuleId = c.container.name;
};
p.appendChild(el);
});
}
// --- Visualization ---
initVisualGrid() {
// Clear old
this.cellMeshes.forEach(m => this.scene.remove(m));
this.cellMeshes = [];
// Center camera
this.orbit.target.set(this.gridSize.x/2, this.gridSize.y/2, this.gridSize.z/2);
// Create placeholder meshes for each cell
const geo = new THREE.BoxGeometry(0.95, 0.95, 0.95);
for(let x=0; x<this.gridSize.x; x++) {
for(let y=0; y<this.gridSize.y; y++) {
for(let z=0; z<this.gridSize.z; z++) {
// Wireframe placeholder
const mat = new THREE.MeshBasicMaterial({color:0x333333, wireframe:true, transparent:true, opacity:0.1});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(x + 0.5, y + 0.5, z + 0.5);
mesh.userData = {x, y, z, isCell:true};
this.scene.add(mesh);
this.cellMeshes.push(mesh);
}
}
}
}
updateVisuals() {
if(!this.wfc) return;
// Loop all cells and update representation
this.wfc.loopCells(cell => {
const idx = cell.x * (this.gridSize.y * this.gridSize.z) + cell.y * this.gridSize.z + cell.z;
// Find the mesh corresponding to this cell (Naive linear mapping works if order preserved)
const mesh = this.cellMeshes.find(m => m.userData.x===cell.x && m.userData.y===cell.y && m.userData.z===cell.z);
if(!mesh) return;
if(cell.collapsed && cell.module) {
// Check if we already have the complex model attached
if(mesh.userData.currentModuleUid !== cell.module.uid) {
// Clear children
while(mesh.children.length > 0) mesh.remove(mesh.children[0]);
if(cell.module.config.container.name === 'Empty') {
mesh.material.opacity = 0.05;
mesh.material.wireframe = true;
} else {
mesh.material.opacity = 0; // Hide box
mesh.material.wireframe = false;
// Instantiate Composite Model
const group = new THREE.Group();
cell.module.config.parts.forEach(part => {
// part.assetName comes from Container Editor (or mapped from file)
// The file import might have extensions, the ID might not.
// Try fuzzy match
let modelKey = Object.keys(this.assets).find(k => k.includes(part.assetName) || part.assetName.includes(k));
if(!modelKey) modelKey = part.assetName; // Try direct
if(this.assets[modelKey]) {
const clone = this.assets[modelKey].clone();
// Apply Part Transform
if(part.transform) {
clone.position.fromArray(part.transform.position);
clone.rotation.fromArray(part.transform.rotation);
clone.scale.fromArray(part.transform.scale);
}
group.add(clone);
} else {
// Placeholder for missing asset
const box = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.5,0.5), new THREE.MeshBasicMaterial({color:0xff0000}));
if(part.transform) box.position.fromArray(part.transform.position);
group.add(box);
}
});
// Apply Module Rotation (Symmetry)
// cell.module.rotation is 0,1,2,3 (x90 deg around Y)
group.rotation.y = -cell.module.rotation * (Math.PI / 2); // Minus for standard clockwise
mesh.add(group);
}
mesh.userData.currentModuleUid = cell.module.uid;
}
} else {
// Uncollapsed
while(mesh.children.length > 0) mesh.remove(mesh.children[0]);
mesh.material.opacity = 0.1;
mesh.material.wireframe = true;
mesh.userData.currentModuleUid = null;
}
});
}
// --- Interaction ---
onMouseMove(e) {
const rect = this.renderer.domElement.getBoundingClientRect();
this.mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
this.mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.cellMeshes);
if(intersects.length > 0) {
const hit = intersects[0].object;
this.hoverBox.position.copy(hit.position);
this.hoverBox.visible = true;
const c = this.wfc.cells[hit.userData.x][hit.userData.y][hit.userData.z];
const status = c.collapsed ? (c.module ? c.module.config.container.name : "Error") : `Candidates: ${c.options.length}`;
document.getElementById('cursor-info').innerText = `[${c.x},${c.y},${c.z}] ${status}`;
} else {
this.hoverBox.visible = false;
document.getElementById('cursor-info').innerText = "Ready";
}
}
onMouseDown(e) {
if(!this.hoverBox.visible) return;
const h = this.hoverBox.position;
// Coords correspond to floor(position) usually, but we centered at +0.5.
const x = Math.floor(h.x), y = Math.floor(h.y), z = Math.floor(h.z);
if(e.button === 0) { // Left Click: Place
if(this.selectedModuleId) {
if(this.selectedModuleId === 'Empty') {
this.wfc.forceCollapse(x, y, z, 'Empty');
} else {
this.wfc.forceCollapse(x, y, z, this.selectedModuleId);
}
this.updateVisuals();
}
} else if(e.button === 2) { // Right Click: Clear
this.wfc.clearCell(x, y, z);
this.updateVisuals();
}
}
// --- Actions ---
runAutoFill() {
// Run steps until done or max iter
let limit = 1000;
const run = () => {
if(limit-- <= 0) return;
if(this.wfc.solveStep()) {
this.updateVisuals();
requestAnimationFrame(run);
} else {
alert("Finished!");
}
};
run();
}
stepWFC() {
if(this.wfc.solveStep()) this.updateVisuals();
else alert("No more moves or done.");
}
newMap() {
if(confirm("Create new map?")) {
this.wfc.reset();
this.updateVisuals();
}
}
saveMap() {
// Export cells: {x,y,z, moduleId, rotation}
const data = [];
this.wfc.loopCells(c => {
if(c.collapsed && c.module) {
data.push({
x:c.x, y:c.y, z:c.z,
module: c.module.config.container.name,
rotation: c.module.rotation
});
}
});
const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'map.json';
a.click();
}
loadMapJSON(file) {
const reader = new FileReader();
reader.onload = (e) => {
const data = JSON.parse(e.target.result);
this.wfc.reset();
data.forEach(d => {
const cell = this.wfc.cells[d.x][d.y][d.z];
// Find specific module variant
const mod = this.wfc.modules.find(m => m.config.container.name === d.module && m.rotation === (d.rotation||0));
if(mod) {
cell.collapsed = true;
cell.module = mod;
cell.options = [mod];
this.wfc.propagate(cell);
}
});
this.updateVisuals();
};
reader.readAsText(file);
}
animate() {
requestAnimationFrame(()=>this.animate());
this.orbit.update();
this.renderer.render(this.scene, this.camera);
}
}
window.app = new MapEditor();
</script>
</body>
</html><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Voxel Editor Extended</title>
<style>
body { margin: 0; overflow: hidden; font-family: 'Segoe UI', sans-serif; background: #1e1e1e; color: #eee; user-select: none; }
/* Menu Bar */
#menubar {
height: 30px; background: #2d2d2d; display: flex; align-items: center; padding: 0 10px; border-bottom: 1px solid #3e3e3e;
}
.menu-item {
padding: 0 15px; height: 100%; display: flex; align-items: center; cursor: pointer; position: relative; font-size: 13px; color: #ccc;
}
.menu-item:hover { background: #3e3e3e; color: #fff; }
.dropdown {
display: none; position: absolute; top: 30px; left: 0; background: #252526; border: 1px solid #3e3e3e; min-width: 180px; z-index: 1000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.menu-item:hover .dropdown { display: block; }
.dropdown-item {
padding: 8px 15px; cursor: pointer; display: block; color: #ccc; text-decoration: none; font-size: 13px;
}
.dropdown-item:hover { background: #094771; color: #fff; }
.separator { border-top: 1px solid #3e3e3e; margin: 4px 0; }
/* Layout */
#container { display: flex; height: calc(100vh - 30px); }
#canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #1e1e1e; }
/* Right Panel */
#properties-panel {
width: 260px; background: #252526; border-left: 1px solid #3e3e3e; padding: 15px; box-sizing: border-box; overflow-y: auto;
}
.prop-group { margin-bottom: 20px; }
.prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
input[type="text"], input[type="number"], input[type="color"] {
width: 100%; box-sizing: border-box; background: #3c3c3c; border: 1px solid #3e3e3e; color: #fff; padding: 6px; margin-bottom: 8px; font-size: 12px; border-radius: 2px;
text-align: right; /* Right Align Numbers */
}
input:focus { border-color: #007fd4; outline: none; }
/* Status / UI Overlays */
#status-bar {
position: absolute; bottom: 10px; left: 10px; color: #4fc1ff; font-family: monospace; font-size: 12px; pointer-events: none; text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
}
#info-overlay {
position: absolute; top: 10px; left: 10px; pointer-events: none;
}
#mode-display {
background: rgba(0,0,0,0.7); color: #fff; padding: 6px 10px; border-radius: 4px; font-weight: 600; font-size: 14px; border-left: 3px solid #007fd4; margin-bottom: 5px; display: inline-block;
}
#height-display {
background: rgba(0,0,0,0.5); color: #eee; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table;
}
/* Arrow Labels & Axis Labels */
.arrow-label {
position: absolute; color: #aaa; font-family: sans-serif; font-size: 12px; pointer-events: none; font-weight: bold; background: rgba(0,0,0,0.5); padding: 2px 4px; border-radius: 3px;
}
.axis-label {
position: absolute; font-family: 'Consolas', monospace; font-weight: bold; font-size: 14px; pointer-events: none; text-shadow: 1px 1px 0 #000; padding: 2px 5px; border-radius: 3px; color: #fff;
}
/* Context Menu */
#context-menu {
display: none; position: absolute; background: #252526; border: 1px solid #454545; z-index: 2000; box-shadow: 2px 4px 10px rgba(0,0,0,0.5); min-width: 120px;
}
#context-menu div { padding: 8px 15px; cursor: pointer; color: #eee; font-size: 13px; }
#context-menu div:hover { background: #094771; }
/* Dialog */
#modal-overlay {
display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); z-index: 3000; justify-content: center; align-items: center;
}
#settings-dialog { background: #252526; padding: 20px; border: 1px solid #454545; width: 300px; box-shadow: 0 10px 20px rgba(0,0,0,0.5); }
#settings-dialog h3 { margin-top: 0; color: #fff; border-bottom: 1px solid #3e3e3e; padding-bottom: 10px; }
.dialog-btn { margin-top: 15px; padding: 6px 20px; cursor: pointer; background: #0e639c; color: white; border: none; font-size: 13px; border-radius: 2px; }
.dialog-btn:hover { background: #1177bb; }
.dialog-btn.cancel { background: #3e3e3e; margin-left: 10px; }
.dialog-btn.cancel:hover { background: #4e4e4e; }
</style>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
</head>
<body>
<div id="menubar">
<div class="menu-item">File
<div class="dropdown">
<div class="dropdown-item" id="menu-new">New</div>
<div class="dropdown-item" id="menu-open">Open</div>
<div class="dropdown-item" id="menu-save">Save</div>
<div class="dropdown-item" id="menu-saveas">Save As</div>
<div class="separator"></div>
<div class="dropdown-item" id="menu-export-obj">Export OBJ</div>
<div class="dropdown-item" id="menu-export-glb">Export GLB</div>
<div class="dropdown-item" id="menu-export-fbx">Export FBX</div>
</div>
</div>
<div class="menu-item">Edit
<div class="dropdown">
<div class="dropdown-item" id="tool-select">Select</div>
<div class="dropdown-item" id="tool-add">Add</div>
<div class="dropdown-item" id="tool-paint">Paint</div>
<div class="dropdown-item" id="tool-erase">Erase</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-move">Move</div>
<div class="dropdown-item" id="tool-rotate">Rotate</div>
<div class="dropdown-item" id="tool-scale">Scaling</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-cam-move">Camera Move</div>
<div class="dropdown-item" id="tool-cam-rotate">Camera Rotate</div>
<div class="dropdown-item" id="tool-cam-zoom">Camera Zoom</div>
<div class="dropdown-item" id="tool-cam-laps">Camera Laps</div>
</div>
</div>
<div class="menu-item">View
<div class="dropdown">
<div class="dropdown-item" onclick="viewCam('default')">Default (F1)</div>
<div class="separator"></div>
<div class="dropdown-item" onclick="viewCam('top')">Top</div>
<div class="dropdown-item" onclick="viewCam('bottom')">Bottom</div>
<div class="separator"></div>
<div class="dropdown-item" onclick="viewCam('front')">Front</div>
<div class="dropdown-item" onclick="viewCam('back')">Back</div>
<div class="dropdown-item" onclick="viewCam('left')">Left</div>
<div class="dropdown-item" onclick="viewCam('right')">Right</div>
</div>
</div>
<div class="menu-item" id="menu-setting">Setting</div>
</div>
<div id="container">
<div id="canvas-container" tabindex="0">
<div id="info-overlay">
<div id="mode-display">Select</div>
<div id="height-display">Height: 0 Y</div>
</div>
<div id="status-bar">Ready</div>
<div id="labels-container"></div>
</div>
<div id="properties-panel">
<h3 id="prop-header">Properties</h3>
<div id="prop-content" style="display:none;">
<div class="prop-group">
<span class="prop-label">Name</span>
<input type="text" id="prop-name" style="text-align: left;">
</div>
<div id="prop-transforms">
<div class="prop-group">
<span class="prop-label">Position (X, Y, Z)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-x" step="1">
<input type="number" id="prop-y" step="1">
<input type="number" id="prop-z" step="1">
</div>
</div>
<div class="prop-group">
<span class="prop-label">Rotation (Deg)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-rx" step="45">
<input type="number" id="prop-ry" step="45">
<input type="number" id="prop-rz" step="45">
</div>
</div>
<div class="prop-group">
<span class="prop-label">Scale</span>
<div style="display:flex; gap:5px;">
<input type="number" id="prop-sx" step="1">
<input type="number" id="prop-sy" step="1">
<input type="number" id="prop-sz" step="1">
</div>
</div>
</div>
<div class="prop-group">
<span class="prop-label">Color</span>
<input type="color" id="prop-color">
</div>
<div class="prop-group">
<span class="prop-label">Texture</span>
<input type="file" id="prop-tex-file" accept="image/*" style="width:100%; font-size:11px; color:#aaa;">
<button id="btn-clear-tex" style="width:100%; margin-top:5px; padding:4px; background:#333; color:#eee; border:1px solid #444; cursor:pointer;">Clear Texture</button>
<img id="prop-tex-preview" style="width: 100%; margin-top: 5px; border: 1px solid #444; display: none;">
</div>
</div>
<div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">
No voxel selected.
</div>
</div>
</div>
<div id="context-menu">
<div id="ctx-copy">Copy</div>
<div id="ctx-paste">Paste</div>
<div id="ctx-delete">Delete</div>
</div>
<div id="modal-overlay">
<div id="settings-dialog">
<h3>Settings</h3>
<div class="prop-group">
<span class="prop-label">Voxel Size</span>
<input type="number" id="set-voxel-size" value="1" step="0.1">
</div>
<div class="prop-group">
<span class="prop-label">Grid Size (X, Y, Z)</span>
<div style="display:flex; gap:5px;">
<input type="number" id="set-grid-x" value="20">
<input type="number" id="set-grid-y" value="20">
<input type="number" id="set-grid-z" value="20">
</div>
</div>
<div style="text-align:right;">
<button class="dialog-btn cancel" id="btn-setting-cancel">Cancel</button>
<button class="dialog-btn" id="btn-setting-ok">Apply</button>
</div>
</div>
</div>
<input type="file" id="file-input" style="display: none;" accept=".json">
<script type="module">
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
// --- State Variables ---
let scene, camera, renderer, controls;
let voxels = [];
let raycaster, mouse;
let currentTool = 'select';
// Brush State (for Add and Paint)
let currentBrush = {
color: '#4fc1ff',
textureSrc: null
};
// Laps
let isLapping = false;
let lapAngle = 0;
let lapDist = 30;
let currentSideViewIndex = 0;
let isTopView = true;
// Grid & Dimensions
let gridSize = { x: 20, y: 20, z: 20 };
let voxelSize = 1;
// Visual Helpers
let gridGroup = new THREE.Group();
let guideLineGroup = new THREE.Group();
let axisGuideGroup = new THREE.Group();
let labelsContainer;
// Cursors
let cursorTarget; // Red
let cursorPoint; // White
let cursorSelect; // Yellow
let shadowPool = [];
let targetPos = new THREE.Vector3(0, 0, 0);
let pointPos = new THREE.Vector3(0, 0, 0);
let selectedVoxel = null;
let clipboard = null;
// Interaction State
let isDragging = false;
let dragStartMouse = new THREE.Vector2();
let dragStartVal = new THREE.Vector3();
let startCursorPos = new THREE.Vector3();
// Camera Constraint State
let dragStartCamPos = new THREE.Vector3();
let dragStartCamTarget = new THREE.Vector3();
let dragStartPolar = 0;
let dragStartAzimuth = 0;
let planeGround;
// Axis Lock State
let axisLock = { x: false, y: false, z: false };
let axisArrows = { x: null, y: null, z: null };
let walls = {};
// --- Init ---
function init() {
const container = document.getElementById('canvas-container');
labelsContainer = document.getElementById('labels-container');
scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e1e1e);
camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000);
camera.position.set(25, 25, 25);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(container.clientWidth, container.clientHeight);
renderer.setPixelRatio(window.devicePixelRatio);
container.appendChild(renderer.domElement);
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(10, 30, 20);
scene.add(sun);
scene.add(gridGroup);
scene.add(guideLineGroup);
scene.add(axisGuideGroup);
initCursors();
initShadowPool();
initAxisGuides();
updateEnvironment();
const pgGeo = new THREE.PlaneGeometry(2000, 2000);
const pgMat = new THREE.MeshBasicMaterial({ visible: false });
planeGround = new THREE.Mesh(pgGeo, pgMat);
planeGround.rotation.x = -Math.PI / 2;
scene.add(planeGround);
controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.1;
controls.enabled = true;
raycaster = new THREE.Raycaster();
mouse = new THREE.Vector2();
window.addEventListener('resize', onResize);
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
const cvs = renderer.domElement;
cvs.addEventListener('pointermove', onPointerMove);
cvs.addEventListener('pointerdown', onPointerDown);
cvs.addEventListener('pointerup', onPointerUp);
cvs.addEventListener('contextmenu', (e) => { e.preventDefault(); });
setupUI();
setTool('select');
animate();
}
function initCursors() {
const tGeo = new THREE.PlaneGeometry(1, 1);
const tMat = new THREE.MeshBasicMaterial({ color: 0xff3333, side: THREE.DoubleSide, transparent: true, opacity: 0.5 });
cursorTarget = new THREE.Mesh(tGeo, tMat);
cursorTarget.rotation.x = -Math.PI / 2;
scene.add(cursorTarget);
const pGeo = new THREE.BoxGeometry(1, 1, 1);
const pEdges = new THREE.EdgesGeometry(pGeo);
cursorPoint = new THREE.LineSegments(pEdges, new THREE.LineBasicMaterial({ color: 0xffffff, opacity: 0.8, transparent: true }));
scene.add(cursorPoint);
const sGeo = new THREE.BoxGeometry(1, 1, 1);
const sEdges = new THREE.EdgesGeometry(sGeo);
cursorSelect = new THREE.LineSegments(sEdges, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false }));
cursorSelect.visible = false;
scene.add(cursorSelect);
}
function initAxisGuides() {
const origin = new THREE.Vector3(0,0,0);
const len = 5;
axisArrows.x = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), origin, len, 0xff0000, 1, 0.5);
axisArrows.y = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), origin, len, 0x00ff00, 1, 0.5);
axisArrows.z = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), origin, len, 0x0088ff, 1, 0.5);
axisGuideGroup.add(axisArrows.x);
axisGuideGroup.add(axisArrows.y);
axisGuideGroup.add(axisArrows.z);
axisGuideGroup.visible = false;
}
function initShadowPool() {
const makeShadow = (color) => {
const geo = new THREE.PlaneGeometry(1, 1);
const edges = new THREE.EdgesGeometry(geo);
return new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: color }));
};
for(let i=0; i<6; i++) shadowPool.push(makeShadow(0xffffff));
shadowPool.forEach(m => guideLineGroup.add(m));
}
function updateEnvironment() {
while(gridGroup.children.length > 0) gridGroup.remove(gridGroup.children[0]);
labelsContainer.innerHTML = '';
const sizeX = gridSize.x * voxelSize;
const sizeZ = gridSize.z * voxelSize;
const halfX = sizeX / 2;
const halfZ = sizeZ / 2;
const floorGrid = new THREE.GridHelper(Math.max(sizeX, sizeZ), Math.max(gridSize.x, gridSize.z), 0x555555, 0x2a2a2a);
gridGroup.add(floorGrid);
walls.zNeg = new THREE.GridHelper(sizeX, gridSize.x, 0x444444, 0x222222);
walls.zNeg.rotation.x = -Math.PI / 2;
walls.zNeg.position.set(0, sizeX/2, -halfZ);
walls.zPos = new THREE.GridHelper(sizeX, gridSize.x, 0x444444, 0x222222);
walls.zPos.rotation.x = -Math.PI / 2;
walls.zPos.position.set(0, sizeX/2, halfZ);
walls.xNeg = new THREE.GridHelper(sizeZ, gridSize.z, 0x444444, 0x222222);
walls.xNeg.rotation.z = -Math.PI / 2;
walls.xNeg.position.set(-halfX, sizeZ/2, 0);
walls.xPos = new THREE.GridHelper(sizeZ, gridSize.z, 0x444444, 0x222222);
walls.xPos.rotation.z = -Math.PI / 2;
walls.xPos.position.set(halfX, sizeZ/2, 0);
gridGroup.add(walls.zNeg);
gridGroup.add(walls.zPos);
gridGroup.add(walls.xNeg);
gridGroup.add(walls.xPos);
addArrowLabel(new THREE.Vector3(0, 0, halfZ + 2), "Z+", "#0088ff");
addArrowLabel(new THREE.Vector3(0, 0, -halfZ - 2), "Z-", "#0088ff");
addArrowLabel(new THREE.Vector3(halfX + 2, 0, 0), "X+", "#ff4444");
addArrowLabel(new THREE.Vector3(-halfX - 2, 0, 0), "X-", "#ff4444");
addArrowLabel(new THREE.Vector3(0, sizeX + 1, -halfZ), "Y+", "#44ff44");
if(cursorTarget) cursorTarget.scale.set(voxelSize, voxelSize, voxelSize);
if(cursorPoint) cursorPoint.scale.set(voxelSize, voxelSize, voxelSize);
}
function addArrowLabel(pos, text, color) {
const div = document.createElement('div');
div.className = 'arrow-label';
div.textContent = text;
div.style.color = color;
div.dataset.pos = JSON.stringify(pos);
div.dataset.type = 'static';
labelsContainer.appendChild(div);
}
function addAxisLabel(pos, text, color) {
const div = document.createElement('div');
div.className = 'axis-label';
div.textContent = text;
div.style.backgroundColor = color;
div.dataset.pos = JSON.stringify(pos);
div.dataset.type = 'dynamic';
labelsContainer.appendChild(div);
}
function animate() {
requestAnimationFrame(animate);
// Camera Laps
if(isLapping) {
const r = lapDist;
camera.position.x = Math.sin(lapAngle) * r;
camera.position.z = Math.cos(lapAngle) * r;
camera.position.y = r * 0.5;
camera.lookAt(0,0,0);
controls.update();
} else {
controls.update();
// Enforce Camera Constraints during animation/update if dragging
if(isDragging) {
if(currentTool === 'cam-move') {
// Restore locked axes
if(axisLock.x) {
camera.position.y = dragStartCamPos.y;
camera.position.z = dragStartCamPos.z;
controls.target.y = dragStartCamTarget.y;
controls.target.z = dragStartCamTarget.z;
}
if(axisLock.y) {
camera.position.x = dragStartCamPos.x;
camera.position.z = dragStartCamPos.z;
controls.target.x = dragStartCamTarget.x;
controls.target.z = dragStartCamTarget.z;
}
if(axisLock.z) {
camera.position.x = dragStartCamPos.x;
camera.position.y = dragStartCamPos.y;
controls.target.x = dragStartCamTarget.x;
controls.target.y = dragStartCamTarget.y;
}
}
else if(currentTool === 'cam-rotate') {
// Limit Polar/Azimuth
if(axisLock.x) { // Horizontal only
controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar;
} else if (axisLock.y) { // Vertical only
controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth;
} else {
// unlock
controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI;
controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity;
}
}
}
}
if(cursorTarget) {
const time = Date.now() * 0.005;
cursorTarget.material.opacity = 0.5 + 0.2 * Math.sin(time * 8);
}
updateWallVisibility();
updateGuides();
updateLabels();
updateAxisGuides();
renderer.render(scene, camera);
}
function updateWallVisibility() {
if (!walls.xNeg) return;
walls.xNeg.visible = (camera.position.x > 0);
walls.xPos.visible = (camera.position.x < 0);
walls.zNeg.visible = (camera.position.z > 0);
walls.zPos.visible = (camera.position.z < 0);
}
function updateGuides() {
shadowPool.forEach(s => s.visible = false);
const limX = (camera.position.x > 0) ? -(gridSize.x * voxelSize)/2 : (gridSize.x * voxelSize)/2;
const limZ = (camera.position.z > 0) ? -(gridSize.z * voxelSize)/2 : (gridSize.z * voxelSize)/2;
const placeShadow = (idxStart, pos3d, scale) => {
const s1 = shadowPool[idxStart];
const s2 = shadowPool[idxStart+1];
s1.visible = true;
s1.position.set(pos3d.x, pos3d.y, limZ);
s1.scale.set(scale.x, scale.y, 1);
s2.visible = true;
s2.position.set(limX, pos3d.y, pos3d.z);
s2.scale.set(1, scale.y, scale.z);
s2.rotation.y = Math.PI / 2;
};
if(currentTool === 'add' && cursorTarget.visible) {
placeShadow(0, cursorTarget.position, cursorTarget.scale);
}
if(cursorPoint.visible) {
placeShadow(2, cursorPoint.position, cursorPoint.scale);
}
if(selectedVoxel && selectedVoxel.visible && cursorSelect.visible) {
placeShadow(4, selectedVoxel.position, selectedVoxel.scale);
}
}
function updateLabels() {
const width = renderer.domElement.clientWidth;
const height = renderer.domElement.clientHeight;
const dynamics = Array.from(labelsContainer.querySelectorAll('[data-type="dynamic"]'));
dynamics.forEach(el => el.remove());
if(axisLock.x || axisLock.y || axisLock.z) {
const active = getActiveCursorPos();
if(active) {
if(axisLock.x) {
addAxisLabel(active.clone().add(new THREE.Vector3(3,0,0)), "+X", "#aa0000");
addAxisLabel(active.clone().add(new THREE.Vector3(-3,0,0)), "-X", "#aa0000");
}
if(axisLock.y) {
addAxisLabel(active.clone().add(new THREE.Vector3(0,3,0)), "+Y", "#00aa00");
addAxisLabel(active.clone().add(new THREE.Vector3(0,-3,0)), "-Y", "#00aa00");
}
if(axisLock.z) {
addAxisLabel(active.clone().add(new THREE.Vector3(0,0,3)), "+Z", "#0044aa");
addAxisLabel(active.clone().add(new THREE.Vector3(0,0,-3)), "-Z", "#0044aa");
}
}
}
Array.from(labelsContainer.children).forEach(div => {
if(!div.dataset.pos) return;
const pos = JSON.parse(div.dataset.pos);
const vec = new THREE.Vector3(pos.x, pos.y, pos.z);
vec.project(camera);
if (vec.z > 1) {
div.style.display = 'none';
} else {
div.style.display = 'block';
const x = (vec.x * 0.5 + 0.5) * width;
const y = -(vec.y * 0.5 - 0.5) * height;
div.style.left = x + 'px';
div.style.top = y + 'px';
}
});
}
function getActiveCursorPos() {
if(selectedVoxel && currentTool !== 'add' && currentTool !== 'paint') return selectedVoxel.position;
if(currentTool === 'add') return cursorTarget.position;
if(cursorPoint.visible) return cursorPoint.position;
return null;
}
function updateAxisGuides() {
const pos = getActiveCursorPos();
if(!pos) {
axisGuideGroup.visible = false;
return;
}
axisGuideGroup.visible = true;
axisGuideGroup.position.copy(pos);
axisArrows.x.visible = axisLock.x;
axisArrows.y.visible = axisLock.y;
axisArrows.z.visible = axisLock.z;
}
function snapToGrid(val) {
return Math.round(val / voxelSize) * voxelSize;
}
// --- Interaction Logic ---
function onPointerMove(e) {
const rect = renderer.domElement.getBoundingClientRect();
mouse.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
if (isDragging) {
const deltaX = e.clientX - dragStartMouse.x;
const deltaY = e.clientY - dragStartMouse.y;
const isLocked = axisLock.x || axisLock.y || axisLock.z;
if(isLapping) {
lapAngle = dragStartVal.x - deltaX * 0.01;
return;
}
if (currentTool === 'move' && selectedVoxel) {
if(isLocked) {
const mag = (deltaX - deltaY) * 0.05;
if(axisLock.x) selectedVoxel.position.x = snapToGrid(dragStartVal.x + mag);
if(axisLock.y) selectedVoxel.position.y = snapToGrid(dragStartVal.y + mag);
if(axisLock.z) selectedVoxel.position.z = snapToGrid(dragStartVal.z + mag);
} else {
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObject(planeGround);
if(intersects.length > 0) {
const p = intersects[0].point;
selectedVoxel.position.x = snapToGrid(p.x);
selectedVoxel.position.z = snapToGrid(p.z);
}
}
}
else if (currentTool === 'rotate' && selectedVoxel) {
const mag = deltaX * 0.02;
const snap = Math.PI / 4;
if(isLocked) {
if(axisLock.x) selectedVoxel.rotation.x = Math.round((dragStartVal.x + mag) / snap) * snap;
if(axisLock.y) selectedVoxel.rotation.y = Math.round((dragStartVal.y + mag) / snap) * snap;
if(axisLock.z) selectedVoxel.rotation.z = Math.round((dragStartVal.z + mag) / snap) * snap;
} else {
selectedVoxel.rotation.y = Math.round((dragStartVal.y + mag) / snap) * snap;
}
}
else if (currentTool === 'scale' && selectedVoxel) {
const mag = -deltaY * 0.02; // Up increases scale
let sVec = dragStartVal.clone();
if(isLocked) {
if(axisLock.x) sVec.x = Math.max(1, Math.round(dragStartVal.x + mag));
if(axisLock.y) sVec.y = Math.max(1, Math.round(dragStartVal.y + mag));
if(axisLock.z) sVec.z = Math.max(1, Math.round(dragStartVal.z + mag));
} else {
const s = Math.max(1, Math.round(dragStartVal.x + mag));
sVec.set(s,s,s);
}
selectedVoxel.scale.copy(sVec);
// Re-center voxel to align with grid
// Center = GridOrigin + (Size*VoxelSize)/2
// We assume GridOrigin is the nearest integer coordinate of the 'min' face.
// But simpler: just round the current position to nearest valid center for this size.
// Valid center for size S on axis is: k * VS + (S * VS)/2.
// Let's normalize position based on old center? No, just snap.
const fixPos = (pos, scale) => {
const halfS = (scale * voxelSize) / 2;
// Determine "base" integer coord
const base = Math.round((pos - halfS) / voxelSize) * voxelSize;
return base + halfS;
};
selectedVoxel.position.x = fixPos(selectedVoxel.position.x, sVec.x);
selectedVoxel.position.y = fixPos(selectedVoxel.position.y, sVec.y);
selectedVoxel.position.z = fixPos(selectedVoxel.position.z, sVec.z);
}
updateSelectionBox();
updateUIFromSelection();
return;
}
const isLocked = axisLock.x || axisLock.y || axisLock.z;
// Cursor Drag (Key held + Drag)
if (isDragging && !selectedVoxel && (currentTool === 'select' || currentTool === 'add' || currentTool === 'erase' || currentTool === 'paint')) {
if(isLocked) {
const deltaX = e.clientX - dragStartMouse.x;
const deltaY = e.clientY - dragStartMouse.y;
const mag = (deltaX - deltaY) * 0.05;
let targetCursor = (currentTool === 'add') ? cursorTarget : cursorPoint;
if(axisLock.x) targetCursor.position.x = snapToGrid(startCursorPos.x + mag);
if(axisLock.y) targetCursor.position.y = snapToGrid(startCursorPos.y + mag);
if(axisLock.z) targetCursor.position.z = snapToGrid(startCursorPos.z + mag);
const gy = Math.round((targetCursor.position.y - voxelSize/2) / voxelSize);
document.getElementById('height-display').textContent = `Height: ${gy} Y`;
}
return;
}
// Normal Hover
if(!isDragging) {
raycaster.setFromCamera(mouse, camera);
const intersectsPlane = raycaster.intersectObject(planeGround);
if (intersectsPlane.length > 0) {
const p = intersectsPlane[0].point;
const ix = Math.round(p.x / voxelSize);
const iz = Math.round(p.z / voxelSize);
// Logic to find highest Y
let maxY = 0;
for(let v of voxels) {
// Check if voxel covers this x/z column
// Since voxels can be scaled, we check bounds.
const vMinX = Math.round((v.position.x - v.scale.x*voxelSize/2)/voxelSize);
const vMaxX = Math.round((v.position.x + v.scale.x*voxelSize/2)/voxelSize);
const vMinZ = Math.round((v.position.z - v.scale.z*voxelSize/2)/voxelSize);
const vMaxZ = Math.round((v.position.z + v.scale.z*voxelSize/2)/voxelSize);
// Bounds are [min, max). Integers.
if (ix >= vMinX && ix < vMaxX && iz >= vMinZ && iz < vMaxZ) {
const top = v.position.y + (v.scale.y * voxelSize)/2;
if(top > maxY) maxY = top;
}
}
if (currentTool === 'add') {
targetPos.set(ix, 0, iz);
const nextCenterY = (maxY === 0) ? (voxelSize/2) : (maxY + voxelSize/2);
cursorTarget.position.set(ix * voxelSize, nextCenterY, iz * voxelSize);
const gridY = Math.round((nextCenterY - voxelSize/2)/voxelSize);
document.getElementById('height-display').textContent = `Height: ${gridY} Y`;
} else if (currentTool === 'erase') {
if (maxY > 0) {
cursorPoint.visible = true;
// Need to find the specific voxel top center to snap correctly?
// Just snap to grid stack top
cursorPoint.position.set(ix * voxelSize, maxY - voxelSize/2, iz * voxelSize);
} else {
cursorPoint.visible = false;
}
document.getElementById('height-display').textContent = `Height: -`;
} else {
// Select / Paint
const intersectVoxels = raycaster.intersectObjects(voxels);
if (intersectVoxels.length > 0) {
const hit = intersectVoxels[0];
const vPos = hit.object.position;
// For scaled voxels, cursor should probably match their size or stay unit?
// Requirement: "Scaled voxels cannot be selected".
// If we just position cursor at center, it's fine.
cursorPoint.position.copy(vPos);
// Scale cursor to match?
cursorPoint.scale.copy(hit.object.scale);
cursorPoint.visible = true;
const gy = Math.round((vPos.y - voxelSize/2) / voxelSize);
document.getElementById('height-display').textContent = `Height: ${gy} Y`;
} else {
cursorPoint.scale.set(1,1,1);
cursorPoint.position.set(ix*voxelSize, voxelSize/2, iz*voxelSize);
cursorPoint.visible = true;
document.getElementById('height-display').textContent = `Height: 0 Y`;
}
}
}
}
}
function onPointerDown(e) {
if (e.button !== 0) return;
const isLocked = axisLock.x || axisLock.y || axisLock.z;
// Cam Laps
if(isLapping) {
isDragging = true;
dragStartMouse.set(e.clientX, e.clientY);
dragStartVal.set(lapAngle, 0, 0);
controls.enabled = false;
return;
}
// Capture Camera Start State
dragStartCamPos.copy(camera.position);
dragStartCamTarget.copy(controls.target);
dragStartPolar = controls.getPolarAngle();
dragStartAzimuth = controls.getAzimuthalAngle();
if (isLocked) {
isDragging = true;
dragStartMouse.set(e.clientX, e.clientY);
controls.enabled = false; // Disable orbit if locking axis (we handle it manually or limit it)
// If it's a camera tool, we might need to allow OrbitControls but constrain it?
// Actually, if we disable controls, we can't orbit.
// So for Camera tools, we MUST keep controls enabled but reset values.
if(currentTool.startsWith('cam-')) {
controls.enabled = true;
}
if(selectedVoxel && ['move', 'rotate', 'scale'].includes(currentTool)) {
if (currentTool === 'move') dragStartVal.copy(selectedVoxel.position);
if (currentTool === 'rotate') dragStartVal.set(selectedVoxel.rotation.x, selectedVoxel.rotation.y, selectedVoxel.rotation.z);
if (currentTool === 'scale') dragStartVal.set(selectedVoxel.scale.x, selectedVoxel.scale.y, selectedVoxel.scale.z);
} else if (!currentTool.startsWith('cam-')) {
let targetCursor = (currentTool === 'add') ? cursorTarget : cursorPoint;
startCursorPos.copy(targetCursor.position);
}
if (!currentTool.startsWith('cam-')) return;
}
if (currentTool.startsWith('cam-')) {
isDragging = true; // Track drag for camera to apply constraint logic
return;
}
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(voxels);
const hitVoxel = intersects.length > 0 ? intersects[0].object : null;
if (currentTool === 'select') {
selectVoxel(hitVoxel);
}
else if (currentTool === 'add') {
const pos = cursorTarget.position;
createVoxel(null, {
x: pos.x, y: pos.y, z: pos.z,
rx: 0, ry: 0, rz: 0,
sx: 1, sy: 1, sz: 1,
color: new THREE.Color(currentBrush.color).getHex(),
texture: currentBrush.textureSrc,
name: "Voxel_" + voxels.length
});
}
else if (currentTool === 'paint') {
if(hitVoxel) {
floodFill(hitVoxel);
}
}
else if (currentTool === 'erase') {
if(hitVoxel) deleteVoxel(hitVoxel);
}
else if (['move', 'rotate', 'scale'].includes(currentTool)) {
if (hitVoxel) {
selectVoxel(hitVoxel);
isDragging = true;
controls.enabled = false;
dragStartMouse.set(e.clientX, e.clientY);
if (currentTool === 'move') dragStartVal.copy(hitVoxel.position);
if (currentTool === 'rotate') dragStartVal.set(hitVoxel.rotation.x, hitVoxel.rotation.y, hitVoxel.rotation.z);
if (currentTool === 'scale') dragStartVal.set(hitVoxel.scale.x, hitVoxel.scale.y, hitVoxel.scale.z);
} else {
selectVoxel(null);
}
}
}
function onPointerUp() {
isDragging = false;
controls.enabled = true;
// Reset Constraints
controls.minPolarAngle = 0; controls.maxPolarAngle = Math.PI;
controls.minAzimuthAngle = -Infinity; controls.maxAzimuthAngle = Infinity;
}
function floodFill(startVoxel) {
const targetColor = startVoxel.material.color.getHex();
const targetTex = startVoxel.material.userData.textureSrc;
const brushColorInt = new THREE.Color(currentBrush.color).getHex();
// Avoid infinite loop if same
if (targetColor === brushColorInt && targetTex === currentBrush.textureSrc) return;
const queue = [startVoxel];
const processed = new Set();
processed.add(startVoxel.uuid);
// Helper to get grid coord (rounded)
const getCoord = (v) => {
return {
x: Math.round(v.position.x/voxelSize),
y: Math.round(v.position.y/voxelSize),
z: Math.round(v.position.z/voxelSize)
};
};
while(queue.length > 0) {
const v = queue.shift();
// Apply Paint
v.material.color.setHex(brushColorInt);
if(currentBrush.textureSrc) {
// Set texture
loadTexture(v.material, currentBrush.textureSrc);
} else {
// Clear texture
v.material.map = null;
v.material.userData.textureSrc = null;
v.material.needsUpdate = true;
}
// Check Neighbors
const c = getCoord(v);
// 6 directions
const neighbors = [
{x:c.x+1, y:c.y, z:c.z}, {x:c.x-1, y:c.y, z:c.z},
{x:c.x, y:c.y+1, z:c.z}, {x:c.x, y:c.y-1, z:c.z},
{x:c.x, y:c.y, z:c.z+1}, {x:c.x, y:c.y, z:c.z-1}
];
for(let nPos of neighbors) {
// Find voxel at this coord
// Optimization: Build a spatial map instead of O(N) search?
// For small scenes O(N) is ok.
const neighbor = voxels.find(nv => {
const nc = getCoord(nv);
return nc.x === nPos.x && nc.y === nPos.y && nc.z === nPos.z;
});
if(neighbor && !processed.has(neighbor.uuid)) {
// Check match criteria
const nColor = neighbor.material.color.getHex();
const nTex = neighbor.material.userData.textureSrc;
// Strict match?
if(nColor === targetColor && nTex === targetTex) {
processed.add(neighbor.uuid);
queue.push(neighbor);
}
}
}
}
}
function onKeyDown(e) {
if (e.target.tagName === 'INPUT') return;
if(e.key.toLowerCase() === 'x') axisLock.x = true;
if(e.key.toLowerCase() === 'y') axisLock.y = true;
if(e.key.toLowerCase() === 'z') axisLock.z = true;
if (e.key === 'F1') { e.preventDefault(); viewCam('default'); }
if (e.key === 'F2') { e.preventDefault(); viewCam(isTopView ? 'top' : 'bottom'); isTopView = !isTopView; }
if (e.key === 'F3') { e.preventDefault(); const sides = ['front', 'right', 'back', 'left']; viewCam(sides[currentSideViewIndex]); currentSideViewIndex = (currentSideViewIndex + 1) % 4; }
if (e.key.toLowerCase() === 'c') { if (selectedVoxel) copyVoxel(selectedVoxel); }
if (e.key.toLowerCase() === 'v') { if (clipboard) pasteVoxel(); }
if (e.key === 'ArrowUp') { e.preventDefault(); moveCursorY(1); }
if (e.key === 'ArrowDown') { e.preventDefault(); moveCursorY(-1); }
if (e.key === 'Delete') { if (selectedVoxel) deleteVoxel(selectedVoxel); }
}
function onKeyUp(e) {
if(e.key.toLowerCase() === 'x') axisLock.x = false;
if(e.key.toLowerCase() === 'y') axisLock.y = false;
if(e.key.toLowerCase() === 'z') axisLock.z = false;
}
function moveCursorY(dir) {
const target = (currentTool === 'add') ? cursorTarget : cursorPoint;
target.position.y += dir * voxelSize;
const gy = Math.round((target.position.y - voxelSize/2) / voxelSize);
document.getElementById('height-display').textContent = `Height: ${gy} Y`;
}
function createVoxel(gridPos, data=null) {
const geo = new THREE.BoxGeometry(voxelSize, voxelSize, voxelSize);
let colorHex = data ? data.color : Math.random() * 0xffffff;
if (data && data.texture) colorHex = 0xffffff;
const mat = new THREE.MeshLambertMaterial({ color: colorHex });
if (data && data.texture) loadTexture(mat, data.texture);
const mesh = new THREE.Mesh(geo, mat);
if (data) {
mesh.position.set(data.x, data.y, data.z);
mesh.rotation.set(data.rx, data.ry, data.rz);
mesh.scale.set(data.sx, data.sy, data.sz);
mesh.userData.name = data.name;
} else {
mesh.position.set(gridPos.x * voxelSize, gridPos.y * voxelSize + voxelSize/2, gridPos.z * voxelSize);
mesh.userData.name = "Voxel_" + voxels.length;
}
scene.add(mesh);
voxels.push(mesh);
return mesh;
}
function deleteVoxel(mesh) {
scene.remove(mesh);
voxels = voxels.filter(v => v !== mesh);
if (selectedVoxel === mesh) selectVoxel(null);
}
function selectVoxel(mesh) {
selectedVoxel = mesh;
if (mesh) {
if(currentTool !== 'add' && currentTool !== 'paint') cursorSelect.visible = true;
updateSelectionBox();
updateUIFromSelection();
} else {
cursorSelect.visible = false;
if (currentTool !== 'add' && currentTool !== 'paint') {
document.getElementById('prop-content').style.display = 'none';
document.getElementById('prop-empty').style.display = 'block';
}
}
}
function updateSelectionBox() {
if(!selectedVoxel) return;
cursorSelect.position.copy(selectedVoxel.position);
cursorSelect.rotation.copy(selectedVoxel.rotation);
cursorSelect.scale.copy(selectedVoxel.scale).multiplyScalar(1.05);
}
function copyVoxel(mesh) {
clipboard = {
color: mesh.material.color.getHex(),
texture: mesh.material.userData.textureSrc,
rx: mesh.rotation.x, ry: mesh.rotation.y, rz: mesh.rotation.z,
sx: mesh.scale.x, sy: mesh.scale.y, sz: mesh.scale.z,
name: mesh.userData.name + "_copy"
};
}
function pasteVoxel() {
if(!clipboard) return;
if(!cursorPoint.visible) return;
const data = {
...clipboard,
x: cursorPoint.position.x,
y: cursorPoint.position.y,
z: cursorPoint.position.z
};
createVoxel(null, data);
}
function loadTexture(mat, src) {
new THREE.TextureLoader().load(src, (tex) => {
mat.map = tex;
mat.color.setHex(0xffffff);
mat.needsUpdate = true;
});
mat.userData.textureSrc = src;
}
// --- UI Binding ---
window.setTool = (tool) => {
currentTool = tool;
const names = {
'select': 'Select', 'add': 'Add', 'erase': 'Erase', 'paint': 'Paint',
'move': 'Move', 'rotate': 'Rotate', 'scale': 'Scale',
'cam-move': 'Camera Pan', 'cam-rotate': 'Camera Orbit', 'cam-zoom': 'Camera Zoom', 'cam-laps': 'Camera Laps'
};
document.getElementById('mode-display').innerText = (names[tool] || tool);
// Visibility
if(tool === 'add' || tool === 'paint') {
cursorTarget.visible = (tool === 'add');
cursorSelect.visible = false;
cursorPoint.visible = false;
document.getElementById('prop-header').innerText = "Brush Settings";
document.getElementById('prop-content').style.display = 'block';
document.getElementById('prop-empty').style.display = 'none';
document.getElementById('prop-transforms').style.display = 'none';
document.getElementById('prop-name').parentElement.style.display = 'none';
document.getElementById('prop-color').value = currentBrush.color;
if(currentBrush.textureSrc) {
document.getElementById('prop-tex-preview').src = currentBrush.textureSrc;
document.getElementById('prop-tex-preview').style.display = 'block';
} else {
document.getElementById('prop-tex-preview').style.display = 'none';
}
} else {
cursorTarget.visible = false;
cursorPoint.visible = true;
if(selectedVoxel) cursorSelect.visible = true;
document.getElementById('prop-header').innerText = "Properties";
document.getElementById('prop-transforms').style.display = 'block';
document.getElementById('prop-name').parentElement.style.display = 'block';
if(selectedVoxel) updateUIFromSelection();
else {
document.getElementById('prop-content').style.display = 'none';
document.getElementById('prop-empty').style.display = 'block';
}
}
isLapping = (tool === 'cam-laps');
if(controls) {
controls.enabled = true;
controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, MIDDLE: THREE.MOUSE.DOLLY, RIGHT: THREE.MOUSE.PAN };
if (tool === 'cam-move') controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
else if (tool === 'cam-rotate') controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
else if (tool === 'cam-zoom') controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
}
};
// Events
document.getElementById('tool-select').onclick = () => setTool('select');
document.getElementById('tool-add').onclick = () => setTool('add');
document.getElementById('tool-paint').onclick = () => setTool('paint');
document.getElementById('tool-erase').onclick = () => setTool('erase');
document.getElementById('tool-move').onclick = () => setTool('move');
document.getElementById('tool-rotate').onclick = () => setTool('rotate');
document.getElementById('tool-scale').onclick = () => setTool('scale');
document.getElementById('tool-cam-move').onclick = () => setTool('cam-move');
document.getElementById('tool-cam-rotate').onclick = () => setTool('cam-rotate');
document.getElementById('tool-cam-zoom').onclick = () => setTool('cam-zoom');
document.getElementById('tool-cam-laps').onclick = () => setTool('cam-laps');
window.viewCam = (dir) => {
const dist = 30 * voxelSize;
const p = camera.position;
if(isLapping) { isLapping = false; setTool('select'); }
switch(dir) {
case 'default': p.set(dist, dist, dist); break;
case 'front': p.set(0, 0, dist); break;
case 'back': p.set(0, 0, -dist); break;
case 'left': p.set(-dist, 0, 0); break;
case 'right': p.set(dist, 0, 0); break;
case 'top': p.set(0, dist, 0); break;
case 'bottom': p.set(0, -dist, 0); break;
}
camera.lookAt(0,0,0);
controls.target.set(0,0,0);
controls.update();
};
function setupUI() {
const bind = (id, prop, axis, isRot=false) => {
const el = document.getElementById(id);
el.addEventListener('input', () => {
if(currentTool === 'add' || currentTool === 'paint') return;
if(!selectedVoxel) return;
let val = parseFloat(el.value);
if(isRot) val = THREE.MathUtils.degToRad(val);
if(axis) selectedVoxel[prop][axis] = val;
else selectedVoxel[prop] = val;
if(prop === 'scale') {
// Real-time grid snapping for manual input
const fixPos = (pos, scale) => {
const halfS = (scale * voxelSize) / 2;
const base = Math.round((pos - halfS) / voxelSize) * voxelSize;
return base + halfS;
};
selectedVoxel.position.x = fixPos(selectedVoxel.position.x, selectedVoxel.scale.x);
selectedVoxel.position.y = fixPos(selectedVoxel.position.y, selectedVoxel.scale.y);
selectedVoxel.position.z = fixPos(selectedVoxel.position.z, selectedVoxel.scale.z);
updateUIFromSelection();
}
updateSelectionBox();
});
};
bind('prop-x', 'position', 'x'); bind('prop-y', 'position', 'y'); bind('prop-z', 'position', 'z');
bind('prop-rx', 'rotation', 'x', true); bind('prop-ry', 'rotation', 'y', true); bind('prop-rz', 'rotation', 'z', true);
bind('prop-sx', 'scale', 'x'); bind('prop-sy', 'scale', 'y'); bind('prop-sz', 'scale', 'z');
document.getElementById('prop-name').addEventListener('input', (e) => {
if(selectedVoxel) selectedVoxel.userData.name = e.target.value;
});
document.getElementById('prop-color').addEventListener('input', (e) => {
if(currentTool === 'add' || currentTool === 'paint') {
currentBrush.color = e.target.value;
} else if(selectedVoxel) {
selectedVoxel.material.color.set(e.target.value);
if(!selectedVoxel.material.map) selectedVoxel.material.color.set(e.target.value);
}
});
document.getElementById('prop-tex-file').addEventListener('change', (e) => {
const f = e.target.files[0];
if(!f) return;
const reader = new FileReader();
reader.onload = (ev) => {
const src = ev.target.result;
if(currentTool === 'add' || currentTool === 'paint') {
currentBrush.textureSrc = src;
document.getElementById('prop-tex-preview').src = src;
document.getElementById('prop-tex-preview').style.display = 'block';
} else if(selectedVoxel) {
loadTexture(selectedVoxel.material, src);
}
};
reader.readAsDataURL(f);
});
document.getElementById('btn-clear-tex').onclick = () => {
if(currentTool === 'add' || currentTool === 'paint') {
currentBrush.textureSrc = null;
document.getElementById('prop-tex-preview').style.display = 'none';
document.getElementById('prop-tex-file').value = "";
} else if(selectedVoxel) {
selectedVoxel.material.map = null;
selectedVoxel.material.userData.textureSrc = null;
selectedVoxel.material.color.set(document.getElementById('prop-color').value);
selectedVoxel.material.needsUpdate = true;
document.getElementById('prop-tex-file').value = "";
}
};
document.getElementById('menu-setting').onclick = () => document.getElementById('modal-overlay').style.display = 'flex';
document.getElementById('btn-setting-cancel').onclick = () => document.getElementById('modal-overlay').style.display = 'none';
document.getElementById('btn-setting-ok').onclick = () => {
gridSize.x = parseInt(document.getElementById('set-grid-x').value);
gridSize.y = parseInt(document.getElementById('set-grid-y').value);
gridSize.z = parseInt(document.getElementById('set-grid-z').value);
voxelSize = parseFloat(document.getElementById('set-voxel-size').value);
updateEnvironment();
document.getElementById('modal-overlay').style.display = 'none';
};
const downloadFile = (blob, defaultName) => {
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
link.download = defaultName;
link.click();
};
document.getElementById('menu-save').onclick = () => {
const data = serializeScene();
const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
downloadFile(blob, 'scene.json');
};
document.getElementById('menu-saveas').onclick = () => {
const name = prompt("Save As (filename):", "scene.json");
if(name) {
const data = serializeScene();
const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
downloadFile(blob, name);
}
};
document.getElementById('menu-open').onclick = () => document.getElementById('file-input').click();
document.getElementById('file-input').onchange = (e) => {
const f = e.target.files[0];
if(!f) return;
const r = new FileReader();
r.onload = (ev) => {
const d = JSON.parse(ev.target.result);
voxels.forEach(v => scene.remove(v));
voxels = [];
d.forEach(i => createVoxel(null, i));
};
r.readAsText(f);
};
const doExport = async (fmt) => {
const group = new THREE.Group();
voxels.forEach(v => group.add(v.clone()));
if(fmt === 'obj') {
const { OBJExporter } = await import('three/addons/exporters/OBJExporter.js');
downloadFile(new Blob([new OBJExporter().parse(group)], {type:'text/plain'}), 'model.obj');
}
if(fmt === 'glb') {
const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
new GLTFExporter().parse(group, (res) => {
downloadFile(new Blob([JSON.stringify(res)], {type:'application/json'}), 'model.glb');
}, (e)=>console.error(e), {binary:true});
}
if(fmt === 'fbx') {
const { FBXExporter } = await import('three/addons/exporters/FBXExporter.js');
downloadFile(new Blob([new FBXExporter().parse(group)], {type:'text/plain'}), 'model.fbx');
}
};
document.getElementById('menu-export-obj').onclick = () => doExport('obj');
document.getElementById('menu-export-glb').onclick = () => doExport('glb');
document.getElementById('menu-export-fbx').onclick = () => doExport('fbx');
}
function serializeScene() {
return voxels.map(v => ({
x: v.position.x, y: v.position.y, z: v.position.z,
rx: v.rotation.x, ry: v.rotation.y, rz: v.rotation.z,
sx: v.scale.x, sy: v.scale.y, sz: v.scale.z,
color: v.material.color.getHex(),
texture: v.material.userData.textureSrc,
name: v.userData.name
}));
}
function updateUIFromSelection() {
if(!selectedVoxel) return;
if(currentTool === 'add' || currentTool === 'paint') return;
document.getElementById('prop-content').style.display = 'block';
document.getElementById('prop-empty').style.display = 'none';
const v = selectedVoxel;
document.getElementById('prop-name').value = v.userData.name || "";
document.getElementById('prop-color').value = '#' + v.material.color.getHexString();
document.getElementById('prop-x').value = v.position.x;
document.getElementById('prop-y').value = v.position.y;
document.getElementById('prop-z').value = v.position.z;
document.getElementById('prop-rx').value = Math.round(THREE.MathUtils.radToDeg(v.rotation.x));
document.getElementById('prop-ry').value = Math.round(THREE.MathUtils.radToDeg(v.rotation.y));
document.getElementById('prop-rz').value = Math.round(THREE.MathUtils.radToDeg(v.rotation.z));
document.getElementById('prop-sx').value = v.scale.x;
document.getElementById('prop-sy').value = v.scale.y;
document.getElementById('prop-sz').value = v.scale.z;
if(v.material.userData.textureSrc) {
document.getElementById('prop-tex-preview').src = v.material.userData.textureSrc;
document.getElementById('prop-tex-preview').style.display = 'block';
} else {
document.getElementById('prop-tex-preview').style.display = 'none';
}
}
function onResize() {
const c = document.getElementById('canvas-container');
camera.aspect = c.clientWidth / c.clientHeight;
camera.updateProjectionMatrix();
renderer.setSize(c.clientWidth, c.clientHeight);
}
init();
</script>
</body>
</html>

コメント