3Dマップエディタ
[ 操作説明 ]
・上キーを押すと、上の段に移動する。
・下キーを押すと、下の段に移動する。
・x,y,zキーのいずれかを押しながらマウスドラッグで操作をすると、
移動方向がx,y,zのいずれかに制約できる。
・cキーを押すと、選択中のボクセルをコピーする。
・vキーを押すと、ポイント・カーソル(白色)の位置に
コピーしたボクセルをペーストする。
・deleteキーを押すと、選択中のボクセルを削除する。
・pageupキーを押すと、ズームイン。
・pagedownキーを押すと、ズームアウト。
[ 操作説明 ]
・モデルエディタと同じ。
[ 操作説明 ]
・他のエディタとほぼ同じ。
・ダウンロードされる方はこちら。↓
・モデル・エディタのソースコードはこちら。↓
<!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;
}
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: #4fc1ff; /* Changed to Light Blue (Cyan) */
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>
</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
let currentBrush = {
color: '#4fc1ff',
textureSrc: null
};
// Laps (Quaternion Slerp Variables)
let isLapping = false;
let lapsQ = new THREE.Quaternion(); // Current Rotation on Sphere
let lapsTargetQ = new THREE.Quaternion(); // Target Rotation
let lapsRadius = 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 (OrbitControls)
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 (Slerp Logic)
if(isLapping) {
// Slerp towards target
lapsQ.slerp(lapsTargetQ, 0.1);
// Apply rotation to radius vector (0,0,radius)
const v = new THREE.Vector3(0, 0, lapsRadius);
v.applyQuaternion(lapsQ);
camera.position.copy(v);
camera.lookAt(0,0,0);
controls.update();
} else {
// Enforce Camera Constraints for OrbitControls
if(currentTool === 'cam-rotate') {
controls.minPolarAngle = 0;
controls.maxPolarAngle = Math.PI;
controls.minAzimuthAngle = -Infinity;
controls.maxAzimuthAngle = Infinity;
if(isDragging) {
if(axisLock.x) {
// X Key -> Horizontal Move -> Lock Polar (Vertical Angle)
controls.minPolarAngle = controls.maxPolarAngle = dragStartPolar;
}
else if(axisLock.y || axisLock.z) {
// Y Key -> Vertical Move -> Lock Azimuth (Horizontal Angle)
controls.minAzimuthAngle = controls.maxAzimuthAngle = dragStartAzimuth;
}
}
}
else if(currentTool === 'cam-move' && isDragging) {
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;
}
}
controls.update();
}
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(currentTool === 'cam-rotate' || currentTool === 'cam-move' || currentTool === 'cam-zoom') return controls.target;
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;
// Camera Laps Drag Logic (Quaternion Slerp based)
if(isLapping) {
const rotSpeed = 0.005;
const deltaQ = new THREE.Quaternion();
if (!axisLock.y && !axisLock.z) {
if (axisLock.x || (!axisLock.y && !axisLock.z)) {
// Horizontal rotation (Around World Y)
const qy = new THREE.Quaternion();
qy.setFromAxisAngle(new THREE.Vector3(0,1,0), -deltaX * rotSpeed);
deltaQ.multiply(qy);
}
}
if (axisLock.y || axisLock.z || (!axisLock.x)) {
let dy = deltaY;
if (axisLock.x) dy = 0;
// Vertical rotation (Around Camera Right)
const camRight = new THREE.Vector3(1,0,0).applyQuaternion(camera.quaternion);
const qx = new THREE.Quaternion();
qx.setFromAxisAngle(camRight, -deltaY * rotSpeed);
deltaQ.premultiply(qx);
}
lapsTargetQ.premultiply(deltaQ);
dragStartMouse.set(e.clientX, e.clientY);
return;
}
if (currentTool.startsWith('cam-')) 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;
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);
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, 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);
// Find highest Y
let maxY = 0;
for(let v of voxels) {
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);
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;
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;
cursorPoint.position.copy(vPos);
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);
controls.enabled = false;
return;
}
// Capture Camera Start State (Always capture for constraints)
dragStartCamPos.copy(camera.position);
dragStartCamTarget.copy(controls.target);
dragStartPolar = controls.getPolarAngle();
dragStartAzimuth = controls.getAzimuthalAngle();
if (currentTool.startsWith('cam-')) {
isDragging = true;
dragStartMouse.set(e.clientX, e.clientY);
controls.enabled = true; // Ensure OrbitControls is ENABLED for camera tools
return;
}
if (isLocked) {
isDragging = true;
dragStartMouse.set(e.clientX, e.clientY);
controls.enabled = false;
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 {
let targetCursor = (currentTool === 'add') ? cursorTarget : cursorPoint;
startCursorPos.copy(targetCursor.position);
}
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;
}
function floodFill(startVoxel) {
const targetColor = startVoxel.material.color.getHex();
const targetTex = startVoxel.material.userData.textureSrc;
const brushColorInt = new THREE.Color(currentBrush.color).getHex();
if (targetColor === brushColorInt && targetTex === currentBrush.textureSrc) return;
const queue = [startVoxel];
const processed = new Set();
processed.add(startVoxel.uuid);
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();
v.material.color.setHex(brushColorInt);
if(currentBrush.textureSrc) {
loadTexture(v.material, currentBrush.textureSrc);
} else {
v.material.map = null;
v.material.userData.textureSrc = null;
v.material.needsUpdate = true;
}
const c = getCoord(v);
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) {
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)) {
const nColor = neighbor.material.color.getHex();
const nTex = neighbor.material.userData.textureSrc;
if(nColor === targetColor && nTex === targetTex) {
processed.add(neighbor.uuid);
queue.push(neighbor);
}
}
}
}
}
function onKeyDown(e) {
if (e.target.tagName === 'INPUT') return;
// PageUp/PageDown Zoom Logic
if (e.key === 'PageUp') {
e.preventDefault();
camera.zoom = Math.min(camera.zoom + 0.1, 5);
camera.updateProjectionMatrix();
}
if (e.key === 'PageDown') {
e.preventDefault();
camera.zoom = Math.max(camera.zoom - 0.1, 0.1);
camera.updateProjectionMatrix();
}
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;
// Use Standard Material for GLTF/FBX Compatibility
const mat = new THREE.MeshStandardMaterial({ 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(isLapping) {
// Initialize Quaternion Laps State
// Store current camera position relative to 0,0,0
const relPos = camera.position.clone();
lapsRadius = relPos.length();
relPos.normalize();
// Set current Quats
const dummy = new THREE.Object3D();
dummy.position.copy(relPos.multiplyScalar(lapsRadius));
dummy.lookAt(0,0,0);
const vBase = new THREE.Vector3(0,0,1);
lapsQ.setFromUnitVectors(vBase, relPos.normalize());
lapsTargetQ.copy(lapsQ);
controls.enabled = false;
} else {
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;
controls.zoomSpeed = 3.0; // Faster Zoom
} else {
controls.zoomSpeed = 1.0; // Default
}
}
}
};
// 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') {
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-new').onclick = () => {
if(confirm("Create new scene? All unsaved voxels will be lost.")) {
voxels.forEach(v => scene.remove(v));
voxels = [];
selectVoxel(null);
setTool('select');
}
};
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) => {
// Prepare a clean export scene
const exportScene = new THREE.Scene();
voxels.forEach(v => {
// Convert to MeshStandardMaterial for best compatibility
let mat = v.material;
// Check if conversion needed (e.g. from Lambert)
if (mat.type !== 'MeshStandardMaterial') {
mat = new THREE.MeshStandardMaterial({
color: v.material.color,
map: v.material.map,
transparent: v.material.transparent,
opacity: v.material.opacity
});
}
const mesh = new THREE.Mesh(v.geometry.clone(), mat);
mesh.position.copy(v.position);
mesh.rotation.copy(v.rotation);
mesh.scale.copy(v.scale);
// Ensure matrix is up to date
mesh.updateMatrix();
exportScene.add(mesh);
});
// Important: Update world matrix of the scene/roots
exportScene.updateMatrixWorld(true);
if(fmt === 'obj') {
const { OBJExporter } = await import('three/addons/exporters/OBJExporter.js');
const res = new OBJExporter().parse(exportScene);
downloadFile(new Blob([res], {type:'text/plain'}), 'model.obj');
}
if(fmt === 'glb') {
const { GLTFExporter } = await import('three/addons/exporters/GLTFExporter.js');
new GLTFExporter().parse(exportScene, (res) => {
downloadFile(new Blob([res], {type:'application/octet-stream'}), 'model.glb');
}, (e)=>console.error(e), { binary: true, embedImages: true });
}
};
document.getElementById('menu-export-obj').onclick = () => doExport('obj');
document.getElementById('menu-export-glb').onclick = () => doExport('glb');
}
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>・コンテナ・エディタのソースコードはこちら。↓
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WFC Container Editor Ultimate v1.8</title>
<style>
/* --- Base Styles --- */
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;
justify-content: space-between;
}
.menu-group { display: flex; height: 100%; }
.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); }
/* Left Sidebar (Asset List) */
#sidebar-left {
width: 220px; background: #252526; border-right: 1px solid #3e3e3e; display: flex; flex-direction: column;
}
.panel-header { background: #2d2d2d; padding: 8px 10px; font-weight: bold; font-size: 12px; text-transform: uppercase; color: #aaa; border-bottom: 1px solid #3e3e3e; }
#asset-list { flex: 1; overflow-y: auto; padding: 5px; outline: none; }
.asset-item {
padding: 6px 10px; margin-bottom: 2px; background: #333; border-radius: 2px; cursor: grab; font-size: 12px;
display: flex; align-items: center; border: 1px solid transparent; color: #ccc;
}
.asset-item:hover { background: #3e3e3e; border-color: #555; color: #fff; }
.asset-item.active { background: #094771; border-color: #007fd4; color: #fff; }
.asset-item:focus { outline: 1px solid #007fd4; background: #3e3e3e; }
.asset-icon { width: 10px; height: 10px; background: #569cd6; margin-right: 8px; border-radius: 2px; }
/* Canvas Area */
#canvas-container { flex-grow: 1; position: relative; overflow: hidden; outline: none; background: #1e1e1e; }
/* Right Panel (Properties) */
#properties-panel {
width: 280px; background: #252526; border-left: 1px solid #3e3e3e; display: flex; flex-direction: column; box-sizing: border-box;
}
.prop-scroll { flex: 1; overflow-y: auto; padding: 15px; }
.prop-section { margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 10px; }
.prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
/* Inputs */
input[type="text"], input[type="number"], select {
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;
}
input[type="text"] { text-align: left; }
input:focus, select:focus { border-color: #007fd4; outline: none; }
.row { display: flex; gap: 4px; }
/* 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: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table;
}
/* Helpers */
.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; display: none;
}
.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; display: none;
}
.hidden { display: none !important; }
.face-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; font-size: 11px; }
.face-tag { width: 70px; font-weight: bold; padding: 2px 5px; border-radius: 2px; background: rgba(0,0,0,0.2); }
.btn { width: 100%; padding: 6px; background: #0e639c; color: white; border: none; cursor: pointer; margin-top: 5px; font-size: 12px; border-radius: 2px;}
.btn:hover { background: #1177bb; }
.btn-group { display: flex; gap: 4px; margin-top: 4px; }
.btn-sm { flex: 1; padding: 4px; background: #333; border: 1px solid #444; color: #ddd; cursor: pointer; font-size: 11px; text-align: center; }
.btn-sm:hover { background: #444; }
/* Dialogs */
#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.cancel { background: #3e3e3e; margin-left: 10px; }
#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: 2000;
}
/* Context Menu */
#context-menu {
display: none; position: absolute; background: #252526; border: 1px solid #454545; min-width: 150px; z-index: 4000; box-shadow: 0 4px 6px rgba(0,0,0,0.3);
}
.ctx-item {
padding: 8px 15px; cursor: pointer; font-size: 13px; color: #ccc;
}
.ctx-item:hover { background: #094771; color: white; }
</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-group">
<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-import-model">Import Models...</div>
<div class="separator"></div>
<div class="dropdown-item" id="menu-export-glb">Export GLB</div>
<div class="dropdown-item" id="menu-export-obj">Export OBJ</div>
</div>
</div>
<div class="menu-item">Edit
<div class="dropdown">
<div class="dropdown-item" id="tool-select">Select</div>
<div class="dropdown-item" id="tool-add">Add</div>
<div class="dropdown-item" id="tool-erase">Erase</div>
<div class="separator"></div>
<div class="dropdown-item" id="tool-move">Move</div>
<div class="dropdown-item" id="tool-rotate">Rotate</div>
<div class="dropdown-item" id="tool-scale">Scale</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="app.viewCam('default')">Default (F1)</div>
<div class="separator"></div>
<div class="dropdown-item" onclick="app.viewCam('top')">Top (F2)</div>
<div class="dropdown-item" onclick="app.viewCam('bottom')">Bottom</div>
<div class="separator"></div>
<div class="dropdown-item" onclick="app.viewCam('front')">Front</div>
<div class="dropdown-item" onclick="app.viewCam('back')">Back</div>
<div class="dropdown-item" onclick="app.viewCam('left')">Left</div>
<div class="dropdown-item" onclick="app.viewCam('right')">Right</div>
</div>
</div>
<div class="menu-item" id="menu-setting">Setting</div>
</div>
<div style="font-size:12px; color:#666; margin-right:10px;">WFC Container Editor v1.8</div>
</div>
<div id="container">
<div id="sidebar-left">
<div class="panel-header">Assets</div>
<div id="asset-list" tabindex="0">
<div style="padding:10px; color:#666; font-size:11px; text-align:center;">
Right Click to Import<br>or Drop Files Here
</div>
</div>
</div>
<div id="canvas-container" tabindex="0">
<div id="drop-zone-overlay"></div>
<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">
<div class="prop-scroll">
<div class="prop-section hidden" id="part-props-container">
<span class="prop-label" style="color:#4fc1ff">Selected Part</span>
<div>
<label class="prop-label">ID / Name</label>
<input type="text" id="p-id">
<label class="prop-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 class="prop-label">Rotation</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 class="prop-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>
</div>
</div>
<div class="prop-section" id="container-props-container">
<span class="prop-label" style="color:#2ecc71">Container Settings</span>
<label class="prop-label">Container Name</label>
<input type="text" id="c-name" value="New Container">
<label class="prop-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 style="border:0; border-top:1px solid #444; margin:15px 0;">
<label class="prop-label">Connection Types (Sockets)</label>
<select id="socket-type-list" size="5" style="height:80px; text-align:left;"></select>
<div class="btn-group">
<div class="btn-sm" onclick="app.addSocketType()">Add</div>
<div class="btn-sm" onclick="app.deleteSocketType()">Delete</div>
</div>
<hr style="border:0; border-top:1px solid #444; margin:15px 0;">
<label class="prop-label">Boundary Rules</label>
<div id="socket-controls">
<div class="face-row"><span class="face-tag" style="background:#440000; color:#ff4444">Right X+</span> <select class="c-socket" data-face="0"></select></div>
<div class="face-row"><span class="face-tag" style="background:#440000; color:#ff4444">Left X-</span> <select class="c-socket" data-face="1"></select></div>
<div class="face-row"><span class="face-tag" style="background:#004400; color:#44ff44">Top Y+</span> <select class="c-socket" data-face="2"></select></div>
<div class="face-row"><span class="face-tag" style="background:#004400; color:#44ff44">Bottom Y-</span> <select class="c-socket" data-face="3"></select></div>
<div class="face-row"><span class="face-tag" style="background:#000044; color:#0088ff">Front Z+</span> <select class="c-socket" data-face="4"></select></div>
<div class="face-row"><span class="face-tag" style="background:#000044; color:#0088ff">Back Z-</span> <select class="c-socket" data-face="5"></select></div>
</div>
</div>
</div>
</div>
</div>
<div id="context-menu">
<div class="ctx-item" id="ctx-import">Import...</div>
<div class="ctx-item" id="ctx-unload">Unload</div>
</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,.png,.jpg,.jpeg,.dds,.tga" style="display:none">
<div id="modal-overlay">
<div id="settings-dialog">
<h3>Settings</h3>
<div class="prop-section">
<span class="prop-label">Voxel / Grid Size</span>
<input type="number" id="set-voxel-size" value="1" step="0.1">
</div>
<div class="prop-section">
<span class="prop-label">Grid Dimensions (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>
<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';
import { DDSLoader } from 'three/addons/loaders/DDSLoader.js';
import { TGALoader } from 'three/addons/loaders/TGALoader.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
class EditorApp {
constructor() {
this.parts = [];
this.assets = [];
this.currentAsset = null;
this.clipboard = null;
this.contextTargetAsset = null;
// Asset Persistence
this.fileMap = {}; // Maps filename -> blobURL (runtime)
this.savedAssets = {}; // Maps filename -> base64 (for saving)
this.emptyTexture = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsMAAA7DAcdvqGQAAAANSURBVBhXY/j///8/AAn7Ag+jUaW0AAAAAElFTkSuQmCC';
this.containerData = {
name: 'New Container',
symmetry: 'X',
sockets: ['empty','empty','empty','empty','empty','empty']
};
this.socketTypes = [
{ id: 'empty', name: 'Empty' },
{ id: 'wall', name: 'Wall' }
];
this.faceColors = [
0xff4444, 0xff4444, 0x44ff44, 0x44ff44, 0x0088ff, 0x0088ff
];
this.currentTool = 'select';
this.selectedPart = null;
this.voxelSize = 1;
this.gridSize = { x: 20, y: 20, z: 20 };
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.isDragging = false;
this.dragStartMouse = new THREE.Vector2();
this.axisLock = { x: false, y: false, z: false };
this.dragStartVal = new THREE.Vector3();
this.isLapping = false;
this.lapsQ = new THREE.Quaternion();
this.lapsTargetQ = new THREE.Quaternion();
this.lapsRadius = 30;
this.axisArrows = { x: null, y: null, z: null };
this.initThree();
this.initUI();
this.updateEnvironment();
this.renderSocketUI();
this.animate();
}
initThree() {
const container = document.getElementById('canvas-container');
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1e1e1e);
this.camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000);
this.camera.position.set(15, 15, 15);
this.camera.lookAt(0,0,0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.shadowMap.enabled = true;
container.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.1;
const ambient = new THREE.AmbientLight(0xffffff, 0.6);
this.scene.add(ambient);
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(10, 30, 20);
sun.castShadow = true;
this.scene.add(sun);
this.gridGroup = new THREE.Group();
this.scene.add(this.gridGroup);
const pgGeo = new THREE.PlaneGeometry(2000, 2000);
const pgMat = new THREE.MeshBasicMaterial({ visible: false });
this.planeGround = new THREE.Mesh(pgGeo, pgMat);
this.planeGround.rotation.x = -Math.PI / 2;
this.scene.add(this.planeGround);
// Axis Guides Group
this.axisGuideGroup = new THREE.Group();
this.scene.add(this.axisGuideGroup);
const origin = new THREE.Vector3(0,0,0);
const len = 5;
this.axisArrows.x = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), origin, len, 0xff0000, 1, 0.5);
this.axisArrows.y = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), origin, len, 0x00ff00, 1, 0.5);
this.axisArrows.z = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), origin, len, 0x0088ff, 1, 0.5);
this.axisGuideGroup.add(this.axisArrows.x);
this.axisGuideGroup.add(this.axisArrows.y);
this.axisGuideGroup.add(this.axisArrows.z);
this.axisGuideGroup.visible = false;
this.initCursors();
const cvs = this.renderer.domElement;
cvs.addEventListener('pointermove', (e) => this.onPointerMove(e));
cvs.addEventListener('pointerdown', (e) => this.onPointerDown(e));
cvs.addEventListener('pointerup', (e) => this.onPointerUp(e));
window.addEventListener('resize', () => this.onResize());
window.addEventListener('keydown', (e) => this.onKeyDown(e));
window.addEventListener('keyup', (e) => this.onKeyUp(e));
cvs.addEventListener('dragover', (e) => { e.preventDefault(); document.getElementById('drop-zone-overlay').style.display = 'block'; });
cvs.addEventListener('dragleave', () => document.getElementById('drop-zone-overlay').style.display = 'none');
cvs.addEventListener('drop', (e) => this.handleViewportDrop(e));
window.addEventListener('click', () => document.getElementById('context-menu').style.display = 'none');
}
initCursors() {
const geometry = new THREE.BoxGeometry(1, 1, 1);
const edges = new THREE.EdgesGeometry(geometry);
const material = new THREE.LineBasicMaterial({ color: 0xffffff, depthTest: false, opacity: 0.8, transparent: true });
this.cursorPoint = new THREE.LineSegments(edges, material);
this.scene.add(this.cursorPoint);
this.cursorPoint.visible = true;
const selMat = new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false, linewidth: 2 });
this.cursorSelect = new THREE.LineSegments(edges, selMat);
this.cursorSelect.visible = false;
this.scene.add(this.cursorSelect);
}
initUI() {
document.getElementById('menu-new').onclick = () => this.newFile();
document.getElementById('menu-open').onclick = () => document.getElementById('file-open-json').click();
document.getElementById('menu-save').onclick = () => this.saveJSON(false);
document.getElementById('menu-saveas').onclick = () => this.saveJSON(true);
document.getElementById('menu-import-model').onclick = () => document.getElementById('file-import-asset').click();
document.getElementById('menu-export-glb').onclick = () => this.exportModel('glb');
document.getElementById('menu-export-obj').onclick = () => this.exportModel('obj');
const tools = ['select','add','erase','move','rotate','scale','cam-move','cam-rotate','cam-zoom','cam-laps'];
tools.forEach(t => {
const el = document.getElementById('tool-'+t);
if(el) el.onclick = () => this.setTool(t);
});
document.getElementById('file-import-asset').addEventListener('change', (e) => this.handleImportAssets(e.target.files));
document.getElementById('file-open-json').addEventListener('change', (e) => this.handleOpenJSON(e));
['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.applyPartProps());
});
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) => {
this.containerData.sockets[parseInt(e.target.dataset.face)] = e.target.value;
this.updateSocketVisuals();
});
});
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 = () => {
this.gridSize.x = parseInt(document.getElementById('set-grid-x').value);
this.gridSize.y = parseInt(document.getElementById('set-grid-y').value);
this.gridSize.z = parseInt(document.getElementById('set-grid-z').value);
this.voxelSize = parseFloat(document.getElementById('set-voxel-size').value);
this.updateEnvironment();
document.getElementById('modal-overlay').style.display = 'none';
};
const assetList = document.getElementById('asset-list');
assetList.addEventListener('contextmenu', (e) => {
e.preventDefault();
const ctxMenu = document.getElementById('context-menu');
ctxMenu.style.display = 'block';
ctxMenu.style.left = e.pageX + 'px';
ctxMenu.style.top = e.pageY + 'px';
const item = e.target.closest('.asset-item');
if (item) {
const idx = parseInt(item.dataset.index);
this.contextTargetAsset = this.assets[idx];
document.getElementById('ctx-unload').style.display = 'block';
document.getElementById('ctx-import').style.display = 'none';
} else {
this.contextTargetAsset = null;
document.getElementById('ctx-unload').style.display = 'none';
document.getElementById('ctx-import').style.display = 'block';
}
});
document.getElementById('ctx-import').onclick = () => document.getElementById('file-import-asset').click();
document.getElementById('ctx-unload').onclick = () => {
if (this.contextTargetAsset) this.unloadAsset(this.contextTargetAsset);
};
}
unloadAsset(asset) {
if(!asset) return;
for(let i=this.parts.length-1; i>=0; i--) {
if(this.parts[i].userData.assetName === asset.name) {
this.deletePart(this.parts[i]);
}
}
this.assets = this.assets.filter(a => a !== asset);
delete this.savedAssets[asset.name];
if(this.currentAsset === asset) this.currentAsset = null;
this.renderAssetList();
}
updateEnvironment() {
while(this.gridGroup.children.length > 0) this.gridGroup.remove(this.gridGroup.children[0]);
document.getElementById('labels-container').innerHTML = '';
const sizeX = this.gridSize.x * this.voxelSize;
const sizeZ = this.gridSize.z * this.voxelSize;
const floorGrid = new THREE.GridHelper(Math.max(sizeX, sizeZ), Math.max(this.gridSize.x, this.gridSize.z), 0x555555, 0x2a2a2a);
this.gridGroup.add(floorGrid);
const halfX = sizeX / 2;
const halfZ = sizeZ / 2;
const addWall = (w, h, rot, pos) => {
const g = new THREE.GridHelper(w, Math.round(w/this.voxelSize), 0x444444, 0x222222);
g.rotation.copy(rot);
g.position.copy(pos);
this.gridGroup.add(g);
return g;
};
const rotX = new THREE.Euler(-Math.PI/2, 0, 0);
const rotZ = new THREE.Euler(0, 0, -Math.PI/2);
addWall(sizeX, this.gridSize.y, rotX, new THREE.Vector3(0, sizeX/2, -halfZ));
addWall(sizeX, this.gridSize.y, rotX, new THREE.Vector3(0, sizeX/2, halfZ));
addWall(sizeZ, this.gridSize.z, rotZ, new THREE.Vector3(-halfX, sizeZ/2, 0));
addWall(sizeZ, this.gridSize.z, rotZ, new THREE.Vector3(halfX, sizeZ/2, 0));
const addLabel = (pos, txt, col) => {
const el = document.createElement('div');
el.className = 'arrow-label';
el.style.color = col; el.innerText = txt;
el.dataset.pos = JSON.stringify(pos);
document.getElementById('labels-container').appendChild(el);
};
addLabel({x: halfX+2, y:0, z:0}, "X+", "#ff4444");
addLabel({x: 0, y:0, z:halfZ+2}, "Z+", "#0088ff");
addLabel({x: 0, y:sizeX+1, z:-halfZ}, "Y+", "#44ff44");
this.cursorPoint.scale.set(this.voxelSize, this.voxelSize, this.voxelSize);
this.cursorSelect.scale.set(this.voxelSize, this.voxelSize, this.voxelSize);
this.updateSocketVisuals();
}
// --- Asset Import & Saving Logic ---
// Helper to load model into 3D scene
loadModel(name, url, ext) {
return new Promise((resolve, reject) => {
// Setup Manager for this load to handle textures
const manager = new THREE.LoadingManager();
manager.addHandler( /\.dds$/i, new DDSLoader() );
manager.addHandler( /\.tga$/i, new TGALoader() );
// Universal URL modifier for this load
manager.setURLModifier((reqUrl) => {
if (!reqUrl || reqUrl.startsWith('data:') || reqUrl.startsWith('blob:')) return reqUrl;
const cleanName = reqUrl.replace(/^.*[\\\/]/, '');
if (this.fileMap[cleanName]) return this.fileMap[cleanName];
if (this.fileMap[cleanName.toLowerCase()]) return this.fileMap[cleanName.toLowerCase()];
return this.emptyTexture;
});
let loader = null;
if (ext === 'glb' || ext === 'gltf') loader = new GLTFLoader(manager);
else if (ext === 'fbx') loader = new FBXLoader(manager);
else if (ext === 'obj') loader = new OBJLoader(manager);
if(!loader) { reject("Unknown format"); return; }
loader.load(url, (loaded) => {
let model = loaded.scene || loaded;
// Check if asset entry exists, if not create/update it
let assetObj = this.assets.find(a => a.name === name);
if(assetObj) {
assetObj.model = model;
} else {
assetObj = { name: name, model: model };
this.assets.push(assetObj);
}
this.renderAssetList();
resolve(assetObj);
}, undefined, (err) => {
console.error(err);
reject(err);
});
});
}
handleImportAssets(files) {
const modelsToLoad = [];
Array.from(files).forEach(file => {
const ext = file.name.split('.').pop().toLowerCase();
const url = URL.createObjectURL(file);
// 1. Store in FileMap for texture resolution
this.fileMap[file.name] = url;
this.fileMap[file.name.toLowerCase()] = url;
// 2. Read file as Base64 for Saving (Async)
const reader = new FileReader();
reader.onload = (e) => {
this.savedAssets[file.name] = e.target.result;
};
reader.readAsDataURL(file);
// 3. Queue for model loading
if (['glb', 'gltf', 'fbx', 'obj'].includes(ext)) {
modelsToLoad.push({ name: file.name, url: url, ext: ext });
}
});
if (modelsToLoad.length === 0) return;
let loadedCount = 0;
let lastAsset = null;
modelsToLoad.forEach(item => {
document.getElementById('status-bar').innerText = `Loading ${item.name}...`;
this.loadModel(item.name, item.url, item.ext).then((asset) => {
lastAsset = asset;
loadedCount++;
if (loadedCount === modelsToLoad.length) {
document.getElementById('status-bar').innerText = `Ready. Imported ${loadedCount} assets.`;
if (modelsToLoad.length === 1) {
this.currentAsset = lastAsset;
this.renderAssetList();
this.setTool('add');
}
}
}).catch(() => {
document.getElementById('status-bar').innerText = `Error loading ${item.name}`;
});
});
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.dataset.index = idx;
el.tabIndex = 0;
if(this.currentAsset === asset) el.classList.add('active');
el.draggable = true;
el.innerHTML = `<div class="asset-icon"></div> ${asset.name}`;
el.onclick = () => {
this.currentAsset = asset;
this.renderAssetList();
this.setTool('add');
};
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetIndex', idx);
});
list.appendChild(el);
});
}
instantiateAsset(asset, pos, fromDrag) {
let obj;
let assetName = "";
if(!asset || typeof asset === 'string') {
// Fallback placeholder
assetName = (typeof asset === 'string') ? asset : "Missing_Asset";
const geo = new THREE.BoxGeometry(this.voxelSize, this.voxelSize, this.voxelSize);
const mat = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true });
obj = new THREE.Mesh(geo, mat);
} else {
assetName = asset.name;
obj = asset.model.clone(true);
// Auto-Scale
obj.scale.set(1, 1, 1);
const box = new THREE.Box3().setFromObject(obj);
const size = new THREE.Vector3(); box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
let scale = 1;
if(maxDim > this.voxelSize) scale = this.voxelSize / maxDim;
obj.scale.set(scale, scale, scale);
}
const wrapper = new THREE.Group();
wrapper.add(obj);
wrapper.position.copy(pos);
const hitGeo = new THREE.BoxGeometry(this.voxelSize, this.voxelSize, this.voxelSize);
const hitMat = new THREE.MeshBasicMaterial({ visible: false });
const hitMesh = new THREE.Mesh(hitGeo, hitMat);
hitMesh.userData.isHitBox = true;
wrapper.add(hitMesh);
wrapper.userData = {
isPart: true,
assetName: assetName,
id: assetName.split('.')[0] + '_' + this.parts.length,
hitBox: hitMesh
};
this.scene.add(wrapper);
this.parts.push(wrapper);
if(!fromDrag) this.selectPart(wrapper);
}
handleViewportDrop(e) {
e.preventDefault();
document.getElementById('drop-zone-overlay').style.display = 'none';
const rect = this.renderer.domElement.getBoundingClientRect();
const mx = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const my = -((e.clientY - rect.top) / rect.height) * 2 + 1;
this.raycaster.setFromCamera(new THREE.Vector2(mx, my), this.camera);
const intersects = this.raycaster.intersectObject(this.planeGround);
if(intersects.length > 0) {
const p = intersects[0].point;
const sp = new THREE.Vector3(
Math.round(p.x / this.voxelSize) * this.voxelSize,
Math.round((p.y + this.voxelSize/2) / this.voxelSize) * this.voxelSize,
Math.round(p.z / this.voxelSize) * this.voxelSize
);
if(e.dataTransfer.files.length > 0) {
this.handleImportAssets(e.dataTransfer.files);
} else {
const idx = e.dataTransfer.getData('assetIndex');
if(idx !== null && idx !== undefined && idx !== "") {
const asset = this.assets[idx];
this.instantiateAsset(asset, sp, true);
}
}
}
}
onPointerMove(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);
if(this.isLapping && this.isDragging) {
const deltaX = e.clientX - this.dragStartMouse.x;
const deltaY = e.clientY - this.dragStartMouse.y;
const rotSpeed = 0.005;
const deltaQ = new THREE.Quaternion();
const qy = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0,1,0), -deltaX * rotSpeed);
deltaQ.multiply(qy);
const camRight = new THREE.Vector3(1,0,0).applyQuaternion(this.camera.quaternion);
const qx = new THREE.Quaternion().setFromAxisAngle(camRight, -deltaY * rotSpeed);
deltaQ.premultiply(qx);
this.lapsTargetQ.premultiply(deltaQ);
this.dragStartMouse.set(e.clientX, e.clientY);
return;
}
if(this.isDragging && this.selectedPart && ['move','rotate','scale'].includes(this.currentTool)) {
const deltaX = e.clientX - this.dragStartMouse.x;
const deltaY = e.clientY - this.dragStartMouse.y;
const isLocked = this.axisLock.x || this.axisLock.y || this.axisLock.z;
if(this.currentTool === 'move') {
if(isLocked) {
const mag = (deltaX - deltaY) * 0.05;
const base = this.dragStartVal;
const snap = (v) => Math.round(v / this.voxelSize) * this.voxelSize;
if(this.axisLock.x) this.selectedPart.position.x = snap(base.x + mag);
if(this.axisLock.y) this.selectedPart.position.y = snap(base.y + mag);
if(this.axisLock.z) this.selectedPart.position.z = snap(base.z + mag);
} else {
const intersects = this.raycaster.intersectObject(this.planeGround);
if(intersects.length > 0) {
const p = intersects[0].point;
this.selectedPart.position.x = Math.round(p.x / this.voxelSize) * this.voxelSize;
this.selectedPart.position.z = Math.round(p.z / this.voxelSize) * this.voxelSize;
}
}
} else if (this.currentTool === 'rotate') {
const mag = deltaX * 0.02;
const snap = Math.PI / 4;
if(this.axisLock.x) this.selectedPart.rotation.x = Math.round((this.dragStartVal.x + mag)/snap)*snap;
else if(this.axisLock.z) this.selectedPart.rotation.z = Math.round((this.dragStartVal.z + mag)/snap)*snap;
else this.selectedPart.rotation.y = Math.round((this.dragStartVal.y + mag)/snap)*snap;
} else if (this.currentTool === 'scale') {
const mag = -deltaY * 0.01 + 1;
this.selectedPart.scale.copy(this.dragStartVal).multiplyScalar(mag);
}
this.updateSelectionBox();
this.updatePropsUI();
return;
}
if (!this.currentTool.startsWith('cam-')) {
const intersectsGround = this.raycaster.intersectObject(this.planeGround);
if(intersectsGround.length > 0) {
const p = intersectsGround[0].point;
const ix = Math.round(p.x / this.voxelSize);
const iz = Math.round(p.z / this.voxelSize);
let maxY = 0;
this.parts.forEach(part => {
const dx = Math.abs(part.position.x - ix*this.voxelSize);
const dz = Math.abs(part.position.z - iz*this.voxelSize);
if (dx < 0.1 && dz < 0.1) {
maxY = Math.max(maxY, part.position.y + this.voxelSize/2);
}
});
const cy = (maxY === 0) ? this.voxelSize/2 : (maxY + this.voxelSize/2);
this.cursorPoint.position.set(ix*this.voxelSize, cy, iz*this.voxelSize);
document.getElementById('height-display').innerText = `Height: ${Math.round((cy - this.voxelSize/2)/this.voxelSize)} Y`;
}
}
}
onPointerDown(e) {
if(e.button !== 0) return;
this.isDragging = true;
this.dragStartMouse.set(e.clientX, e.clientY);
if (this.currentTool.startsWith('cam-')) {
if (this.currentTool === 'cam-laps') {
this.controls.enabled = false;
}
return;
}
const cursorGridPos = this.cursorPoint.position.clone();
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.parts, true);
let hitPart = null;
if(intersects.length > 0) {
let obj = intersects[0].object;
while(obj) {
if(obj.userData && obj.userData.isPart) {
hitPart = obj;
break;
}
obj = obj.parent;
}
}
if(this.currentTool === 'add') {
if(this.currentAsset) {
this.instantiateAsset(this.currentAsset, cursorGridPos);
} else {
alert("Select an asset first.");
}
return;
}
if(this.currentTool === 'select') {
this.selectPart(hitPart);
} else if (this.currentTool === 'erase') {
if(hitPart) this.deletePart(hitPart);
} else if (['move','rotate','scale'].includes(this.currentTool)) {
if(hitPart) {
this.selectPart(hitPart);
this.controls.enabled = false;
let startVal;
if(this.currentTool === 'move') startVal = hitPart.position.clone();
else if(this.currentTool === 'rotate') startVal = new THREE.Vector3(hitPart.rotation.x, hitPart.rotation.y, hitPart.rotation.z);
else startVal = hitPart.scale.clone();
this.dragStartVal.copy(startVal);
} else {
this.selectPart(null);
}
}
}
onPointerUp() {
this.isDragging = false;
this.controls.enabled = true;
}
onKeyDown(e) {
if(e.target.tagName === 'INPUT') return;
if(e.key === 'PageUp') { e.preventDefault(); this.camera.position.multiplyScalar(0.9); }
if(e.key === 'PageDown') { e.preventDefault(); this.camera.position.multiplyScalar(1.1); }
if(e.key === 'ArrowUp') { e.preventDefault(); this.cursorPoint.position.y += this.voxelSize; }
if(e.key === 'ArrowDown') { e.preventDefault(); this.cursorPoint.position.y -= this.voxelSize; }
if(e.key.toLowerCase() === 'c') {
if(this.selectedPart) {
this.clipboard = {
assetName: this.selectedPart.userData.assetName,
rot: this.selectedPart.rotation.clone(),
scl: this.selectedPart.scale.clone()
};
document.getElementById('status-bar').innerText = "Copied to clipboard";
}
}
if(e.key.toLowerCase() === 'v') {
if(this.clipboard) {
const asset = this.assets.find(a => a.name === this.clipboard.assetName);
this.instantiateAsset(asset || this.clipboard.assetName, this.cursorPoint.position);
const pasted = this.parts[this.parts.length-1];
pasted.rotation.copy(this.clipboard.rot);
pasted.scale.copy(this.clipboard.scl);
this.selectPart(pasted);
document.getElementById('status-bar').innerText = "Pasted";
}
}
if(e.key === 'Delete') {
if (document.activeElement && document.activeElement.classList.contains('asset-item')) {
const idx = parseInt(document.activeElement.dataset.index);
const asset = this.assets[idx];
if (asset) this.unloadAsset(asset);
return;
} else {
this.deleteSelection();
}
}
if(e.key.toLowerCase() === 'x') this.axisLock.x = true;
if(e.key.toLowerCase() === 'y') this.axisLock.y = true;
if(e.key.toLowerCase() === 'z') this.axisLock.z = true;
if (e.key === 'F1') { e.preventDefault(); this.viewCam('default'); }
if (e.key === 'F2') { e.preventDefault(); this.viewCam('top'); }
}
onKeyUp(e) {
if(e.key.toLowerCase() === 'x') this.axisLock.x = false;
if(e.key.toLowerCase() === 'y') this.axisLock.y = false;
if(e.key.toLowerCase() === 'z') this.axisLock.z = false;
}
setTool(tool) {
this.currentTool = tool;
const names = {
'select':'Select', 'add':'Add Part', 'erase':'Erase',
'move':'Move', 'rotate':'Rotate', 'scale':'Scale',
'cam-move':'Camera Pan', 'cam-rotate':'Camera Rotate', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps'
};
document.getElementById('mode-display').innerText = names[tool] || tool;
document.getElementById('mode-display').style.borderLeftColor = (tool === 'add' ? '#e74c3c' : '#007fd4');
this.isLapping = (tool === 'cam-laps');
if (this.isLapping) {
this.controls.enabled = false;
const relPos = this.camera.position.clone();
this.lapsRadius = relPos.length();
const vBase = new THREE.Vector3(0,0,1);
this.lapsQ.setFromUnitVectors(vBase, relPos.normalize());
this.lapsTargetQ.copy(this.lapsQ);
} else {
this.controls.enabled = true;
if (tool === 'cam-move') this.controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
else if (tool === 'cam-rotate') this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
else if (tool === 'cam-zoom') this.controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
else this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
}
}
selectPart(part) {
this.selectedPart = part;
if(part) {
this.cursorSelect.visible = true;
this.updateSelectionBox();
document.getElementById('part-props-container').classList.remove('hidden');
document.getElementById('container-props-container').classList.add('hidden');
this.updatePropsUI();
} else {
this.cursorSelect.visible = false;
document.getElementById('part-props-container').classList.add('hidden');
document.getElementById('container-props-container').classList.remove('hidden');
}
}
deletePart(part) {
if(!part) return;
this.scene.remove(part);
this.parts = this.parts.filter(p => p !== part);
if(this.selectedPart === part) this.selectPart(null);
}
deleteSelection() {
this.deletePart(this.selectedPart);
}
updateSelectionBox() {
if(!this.selectedPart) return;
const box = new THREE.Box3().setFromObject(this.selectedPart.userData.hitBox);
const size = new THREE.Vector3(); box.getSize(size);
const center = new THREE.Vector3(); box.getCenter(center);
this.cursorSelect.position.copy(center);
this.cursorSelect.scale.copy(size);
}
updatePropsUI() {
if(!this.selectedPart) return;
const p = this.selectedPart;
document.getElementById('p-id').value = p.userData.id;
document.getElementById('p-pos-x').value = p.position.x;
document.getElementById('p-pos-y').value = p.position.y;
document.getElementById('p-pos-z').value = p.position.z;
const toDeg = (r) => Math.round(r * 180/Math.PI);
document.getElementById('p-rot-x').value = toDeg(p.rotation.x);
document.getElementById('p-rot-y').value = toDeg(p.rotation.y);
document.getElementById('p-rot-z').value = toDeg(p.rotation.z);
document.getElementById('p-scl-x').value = p.scale.x;
document.getElementById('p-scl-y').value = p.scale.y;
document.getElementById('p-scl-z').value = p.scale.z;
}
applyPartProps() {
if(!this.selectedPart) return;
const p = this.selectedPart;
p.userData.id = document.getElementById('p-id').value;
const px = parseFloat(document.getElementById('p-pos-x').value);
const py = parseFloat(document.getElementById('p-pos-y').value);
const pz = parseFloat(document.getElementById('p-pos-z').value);
p.position.set(px, py, pz);
const toRad = (d) => d * Math.PI / 180;
const rx = toRad(parseFloat(document.getElementById('p-rot-x').value));
const ry = toRad(parseFloat(document.getElementById('p-rot-y').value));
const rz = toRad(parseFloat(document.getElementById('p-rot-z').value));
p.rotation.set(rx, ry, rz);
const sx = parseFloat(document.getElementById('p-scl-x').value);
const sy = parseFloat(document.getElementById('p-scl-y').value);
const sz = parseFloat(document.getElementById('p-scl-z').value);
p.scale.set(sx, sy, sz);
this.updateSelectionBox();
}
// --- File Ops with Embedded Assets ---
newFile() {
if(confirm("Clear scene?")) {
this.parts.forEach(p => this.scene.remove(p));
this.parts = [];
this.selectPart(null);
}
}
saveJSON(asNew) {
const data = {
container: this.containerData,
socketTypes: this.socketTypes,
grid: { voxelSize: this.voxelSize, dim: this.gridSize },
// Save Asset Data Library
assets: this.savedAssets,
parts: this.parts.map(p => ({
assetName: p.userData.assetName,
id: p.userData.id,
pos: p.position.toArray(),
rot: p.rotation.toArray(),
scl: p.scale.toArray()
}))
};
const blob = new Blob([JSON.stringify(data)], {type:'application/json'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
let name = this.containerData.name + '.json';
if(asNew) {
const input = prompt("Filename:", name);
if(!input) return;
name = input;
if(!name.endsWith('.json')) name += '.json';
}
a.download = name;
a.click();
}
async handleOpenJSON(e) {
const file = e.target.files[0];
if(!file) return;
document.getElementById('status-bar').innerText = "Loading JSON...";
const text = await file.text();
const data = JSON.parse(text);
// 1. Restore Assets
if (data.assets) {
document.getElementById('status-bar').innerText = "Restoring assets...";
for (const [name, dataURI] of Object.entries(data.assets)) {
this.savedAssets[name] = dataURI;
// Convert back to blob for Loading Manager
const res = await fetch(dataURI);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
this.fileMap[name] = url;
this.fileMap[name.toLowerCase()] = url;
// Re-load models if they are GLB/FBX/OBJ
const ext = name.split('.').pop().toLowerCase();
if (['glb','gltf','fbx','obj'].includes(ext)) {
try {
await this.loadModel(name, url, ext);
} catch(err) {
console.warn("Failed to restore model:", name, err);
}
}
}
}
// 2. Clear Scene
this.parts.forEach(p => this.scene.remove(p));
this.parts = [];
this.selectPart(null);
// 3. Restore Data
if(data.container) {
this.containerData = data.container;
document.getElementById('c-name').value = this.containerData.name;
document.getElementById('c-symmetry').value = this.containerData.symmetry;
this.containerData.sockets.forEach((s, i) => {
const sel = document.querySelector(`.c-socket[data-face="${i}"]`);
if(sel) sel.value = s;
});
}
if(data.socketTypes) {
this.socketTypes = data.socketTypes;
this.renderSocketUI();
}
if(data.parts) {
data.parts.forEach(pData => {
// Find Loaded Asset
const asset = this.assets.find(a => a.name === pData.assetName);
// Instantiate
this.instantiateAsset(asset || pData.assetName, new THREE.Vector3().fromArray(pData.pos));
// Apply Transforms
const newPart = this.parts[this.parts.length-1];
newPart.rotation.fromArray(pData.rot);
newPart.scale.fromArray(pData.scl);
newPart.userData.id = pData.id;
});
}
this.updateSocketVisuals();
document.getElementById('status-bar').innerText = "Ready.";
e.target.value = '';
}
exportModel(format) {
const exporter = format === 'glb' ? new GLTFExporter() : new OBJExporter();
const exportGroup = new THREE.Group();
this.parts.forEach(p => {
const wrapperClone = p.clone();
const toRemove = [];
wrapperClone.traverse(c => {
if (c.userData.isHitBox || (c.isMesh && c.material && c.material.visible === false)) {
toRemove.push(c);
}
});
toRemove.forEach(c => { if(c.parent) c.parent.remove(c); });
wrapperClone.traverse(c => {
if (c.isMesh && c.material) {
if (c.material.type !== 'MeshStandardMaterial' && c.material.type !== 'MeshBasicMaterial') {
const oldMat = c.material;
const newMat = new THREE.MeshStandardMaterial();
if(oldMat.color) newMat.color.copy(oldMat.color);
if(oldMat.map) newMat.map = oldMat.map;
c.material = newMat;
}
}
});
exportGroup.add(wrapperClone);
});
exportGroup.updateMatrixWorld(true);
if(format === 'glb') {
exporter.parse(exportGroup, (result) => {
const blob = new Blob([result], { type: 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'model.glb';
a.click();
}, (err) => { console.error(err); alert("Export failed."); }, { binary: true });
} else {
const result = exporter.parse(exportGroup);
const blob = new Blob([result], { type: 'text/plain' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'model.obj';
a.click();
}
}
// --- WFC Sockets ---
addSocketType() {
const name = prompt("New Socket Name:");
if(name) {
const id = name.toLowerCase().replace(/\s/g,'_');
this.socketTypes.push({ id, name });
this.renderSocketUI();
}
}
deleteSocketType() {
const sel = document.getElementById('socket-type-list');
const val = sel.value;
if(!val || val === 'empty' || val === 'wall') return;
if(confirm("Delete socket type?")) {
this.socketTypes = this.socketTypes.filter(t => t.id !== val);
this.renderSocketUI();
this.updateSocketVisuals();
}
}
renderSocketUI() {
const list = document.getElementById('socket-type-list');
list.innerHTML = '';
this.socketTypes.forEach(t => {
const opt = document.createElement('option');
opt.value = t.id; opt.innerText = t.name;
list.appendChild(opt);
});
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() {
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]);
}
const rx = (this.gridSize.x * this.voxelSize) / 2;
const ry = (this.gridSize.y * this.voxelSize) / 2;
const rz = (this.gridSize.z * this.voxelSize) / 2;
const centerY = ry;
const dirs = [
{v: new THREE.Vector3(rx,centerY,0), c:0}, {v: new THREE.Vector3(-rx,centerY,0), c:1},
{v: new THREE.Vector3(0,centerY*2,0), c:2}, {v: new THREE.Vector3(0,0,0), c:3},
{v: new THREE.Vector3(0,centerY,rz), c:4}, {v: new THREE.Vector3(0,centerY,-rz), c:5}
];
dirs.forEach((d, i) => {
const faceColor = this.faceColors[i];
const geo = new THREE.SphereGeometry(this.voxelSize * 0.4, 16, 16);
const mat = new THREE.MeshBasicMaterial({ color: faceColor, wireframe: true });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.copy(d.v);
mesh.userData.isSocketHelper = true;
this.scene.add(mesh);
});
}
viewCam(dir) {
const dist = 30;
switch(dir) {
case 'default': this.camera.position.set(15,15,15); break;
case 'top': this.camera.position.set(0, dist, 0); break;
case 'bottom': this.camera.position.set(0, -dist, 0); break;
case 'front': this.camera.position.set(0, 0, dist); break;
case 'back': this.camera.position.set(0, 0, -dist); break;
case 'left': this.camera.position.set(-dist, 0, 0); break;
case 'right': this.camera.position.set(dist, 0, 0); break;
}
this.camera.lookAt(0,0,0);
}
onResize() {
const c = document.getElementById('canvas-container');
this.camera.aspect = c.clientWidth / c.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(c.clientWidth, c.clientHeight);
}
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';
document.getElementById('labels-container').appendChild(div);
}
updateAxisGuides() {
const dynamics = Array.from(document.querySelectorAll('.axis-label[data-type="dynamic"]'));
dynamics.forEach(el => el.remove());
let pos = null;
if(this.selectedPart) pos = this.selectedPart.position;
else if(this.cursorPoint.visible) pos = this.cursorPoint.position;
if(!pos || (!this.axisLock.x && !this.axisLock.y && !this.axisLock.z)) {
this.axisGuideGroup.visible = false;
return;
}
this.axisGuideGroup.visible = true;
this.axisGuideGroup.position.copy(pos);
this.axisArrows.x.visible = this.axisLock.x;
this.axisArrows.y.visible = this.axisLock.y;
this.axisArrows.z.visible = this.axisLock.z;
if(this.axisLock.x) {
this.addAxisLabel(pos.clone().add(new THREE.Vector3(3,0,0)), "+X", "#aa0000");
this.addAxisLabel(pos.clone().add(new THREE.Vector3(-3,0,0)), "-X", "#aa0000");
}
if(this.axisLock.y) {
this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,3,0)), "+Y", "#00aa00");
this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,-3,0)), "-Y", "#00aa00");
}
if(this.axisLock.z) {
this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,0,3)), "+Z", "#0044aa");
this.addAxisLabel(pos.clone().add(new THREE.Vector3(0,0,-3)), "-Z", "#0044aa");
}
}
animate() {
requestAnimationFrame(() => this.animate());
if(this.isLapping) {
this.lapsQ.slerp(this.lapsTargetQ, 0.1);
const v = new THREE.Vector3(0, 0, this.lapsRadius);
v.applyQuaternion(this.lapsQ);
this.camera.position.copy(v);
this.camera.lookAt(0,0,0);
this.controls.update();
} else {
this.controls.update();
}
this.updateAxisGuides();
const width = this.renderer.domElement.clientWidth;
const height = this.renderer.domElement.clientHeight;
const labels = Array.from(document.querySelectorAll('.arrow-label, .axis-label'));
labels.forEach(el => {
const pos = JSON.parse(el.dataset.pos);
const v = new THREE.Vector3(pos.x, pos.y, pos.z);
v.project(this.camera);
if(v.z > 1) { el.style.display = 'none'; }
else {
el.style.display = 'block';
el.style.left = ((v.x+1)/2 * width) + 'px';
el.style.top = (-(v.y-1)/2 * height) + 'px';
}
});
this.renderer.render(this.scene, this.camera);
}
}
window.app = new EditorApp();
</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>WFC Map Editor Ultimate v2.0</title>
<style>
/* --- Base Styles based on Model Editor --- */
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;
justify-content: space-between;
}
.menu-group { display: flex; height: 100%; }
.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: 200px; 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: 280px; background: #252526; border-left: 1px solid #3e3e3e; display: flex; flex-direction: column; box-sizing: border-box;
}
.prop-scroll { flex: 1; overflow-y: auto; padding: 15px; }
.prop-group { margin-bottom: 20px; border-bottom: 1px solid #333; padding-bottom: 10px; }
.prop-label { display: block; font-size: 11px; color: #aaa; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }
/* Inputs */
input[type="text"], input[type="number"], select {
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;
}
input[type="text"] { text-align: left; }
input:focus { border-color: #007fd4; outline: none; }
.row { display: flex; gap: 4px; }
/* Palette List */
#palette-list { display: flex; flex-direction: column; gap: 2px; max-height: 200px; overflow-y: auto; margin-bottom: 10px; border: 1px solid #3e3e3e; padding: 2px; }
.palette-item {
padding: 6px; background: #333; cursor: pointer; font-size: 12px; display: flex; align-items: center; border: 1px solid transparent;
}
.palette-item:hover { background: #3e3e3e; color: #fff; }
.palette-item.active { background: #094771; border-color: #007fd4; color: #fff; }
.palette-icon { width: 10px; height: 10px; background: #569cd6; margin-right: 8px; border-radius: 2px; }
/* 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: #4fc1ff; padding: 4px 8px; border-radius: 4px; font-family: monospace; font-size: 14px; font-weight: bold; display: table;
}
/* 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; display: none;
}
.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; display: none;
}
/* Dialogs */
#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; }
/* Helpers */
.hidden { display: none !important; }
.face-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 2px; font-size: 11px; color:#aaa; }
.face-tag { width: 20px; font-weight: bold; text-align: center; }
</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-group">
<div class="menu-item">File
<div class="dropdown">
<div class="dropdown-item" id="menu-new">New Map</div>
<div class="dropdown-item" id="menu-open">Open Map JSON</div>
<div class="dropdown-item" id="menu-save">Save Map JSON</div>
<div class="separator"></div>
<div class="dropdown-item" id="menu-import-config">1. Import Config (JSON)...</div>
<div class="dropdown-item" id="menu-import-assets">2. Import Models (GLB/OBJ)...</div>
<div class="separator"></div>
<div class="dropdown-item" id="menu-export-glb">Export Scene GLB</div>
<div class="dropdown-item" id="menu-export-obj">Export Scene OBJ</div>
</div>
</div>
<div class="menu-item">Edit
<div class="dropdown">
<div class="dropdown-item" id="tool-select">Select (Esc)</div>
<div class="dropdown-item" id="tool-add">Add (WFC Solve)</div>
<div class="dropdown-item" id="tool-erase">Erase (Del)</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="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="app.viewCam('default')">Default (F1)</div>
<div class="separator"></div>
<div class="dropdown-item" onclick="app.viewCam('top')">Top (F2)</div>
<div class="dropdown-item" onclick="app.viewCam('bottom')">Bottom</div>
<div class="dropdown-item" onclick="app.viewCam('front')">Front</div>
<div class="dropdown-item" onclick="app.viewCam('back')">Back</div>
<div class="dropdown-item" onclick="app.viewCam('left')">Left</div>
<div class="dropdown-item" onclick="app.viewCam('right')">Right</div>
</div>
</div>
<div class="menu-item" id="menu-setting">Setting</div>
</div>
<div style="font-size:12px; color:#666; margin-right:10px;">WFC Map Editor v2.0</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">
<div class="prop-scroll">
<div class="prop-group">
<span class="prop-label">Container Palette</span>
<div id="palette-list">
<div style="padding:10px; color:#666; font-size:11px; text-align:center;">
No Containers.<br>Import Config & Models.
</div>
</div>
</div>
<div id="prop-selected" class="hidden">
<div class="prop-group">
<span class="prop-label" style="color:#4fc1ff">Selected Container</span>
<label class="prop-label">Module Name</label>
<input type="text" id="p-name" disabled>
<label class="prop-label">Grid Position (X, Y, Z)</label>
<div class="row">
<input type="number" id="p-pos-x" disabled>
<input type="number" id="p-pos-y" disabled>
<input type="number" id="p-pos-z" disabled>
</div>
<label class="prop-label">Rotation (0-3)</label>
<input type="number" id="p-rot" min="0" max="3" step="1">
<label class="prop-label" style="margin-top:10px">Sockets (Current Rot)</label>
<div id="p-sockets-list"></div>
</div>
</div>
<div id="prop-empty" style="color: #666; font-style: italic; text-align: center; margin-top: 20px;">
No container selected.
</div>
</div>
</div>
</div>
<input type="file" id="file-config" accept=".json" style="display:none">
<input type="file" id="file-assets" multiple accept=".glb,.gltf,.fbx,.obj,.png,.jpg" style="display:none">
<input type="file" id="file-map-json" accept=".json" style="display:none">
<div id="modal-overlay">
<div id="settings-dialog">
<h3>Settings</h3>
<div class="prop-group">
<span class="prop-label">Grid / Container Size</span>
<input type="number" id="set-voxel-size" value="1" step="0.1">
</div>
<div class="prop-group">
<span class="prop-label">Grid Dimensions (X, Y, Z)</span>
<div class="row">
<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>
<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';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { OBJExporter } from 'three/addons/exporters/OBJExporter.js';
// --- Data Classes ---
class MapCell {
constructor(x, y, z) {
this.x = x; this.y = y; this.z = z;
this.moduleName = null;
this.rotation = 0; // 0, 1, 2, 3 (x90 deg around Y)
this.mesh = null; // Visual representation
}
}
class ContainerConfig {
constructor(name, symmetry, sockets, parts) {
this.name = name;
this.symmetry = symmetry; // X, T, I, L, D
this.sockets = sockets; // [px, nx, py, ny, pz, nz]
this.parts = parts; // [{assetName, pos, rot, scl}]
}
}
// --- Editor Application ---
class EditorApp {
constructor() {
// State
this.cells = [];
this.configs = []; // Loaded Container Configurations
this.assets = {}; // Name -> THREE.Object3D
this.fileMap = {}; // Name -> Blob URL (for textures)
this.currentTool = 'select';
this.currentConfig = null; // Selected from palette
this.selectedCell = null;
this.gridSize = { x: 20, y: 20, z: 20 };
this.voxelSize = 1;
// Visuals
this.scene = null;
this.camera = null;
this.renderer = null;
this.controls = null;
// Helpers
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.gridGroup = new THREE.Group();
this.axisGuideGroup = new THREE.Group();
this.cursorTarget = null; // Red box for adding
this.cursorSelect = null; // Yellow box for selection
this.shadowPool = [];
// Interaction State
this.isDragging = false;
this.dragStartMouse = new THREE.Vector2();
this.axisLock = { x: false, y: false, z: false };
this.isLapping = false;
this.lapsQ = new THREE.Quaternion();
this.lapsTargetQ = new THREE.Quaternion();
this.lapsRadius = 30;
this.init();
}
init() {
this.initThree();
this.initUI();
this.updateEnvironment();
this.animate();
}
initThree() {
const container = document.getElementById('canvas-container');
this.scene = new THREE.Scene();
this.scene.background = new THREE.Color(0x1e1e1e);
this.camera = new THREE.PerspectiveCamera(50, container.clientWidth / container.clientHeight, 0.1, 1000);
this.camera.position.set(15, 15, 15);
this.camera.lookAt(0,0,0);
this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
this.renderer.setSize(container.clientWidth, container.clientHeight);
this.renderer.shadowMap.enabled = true;
container.appendChild(this.renderer.domElement);
this.controls = new OrbitControls(this.camera, this.renderer.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.1;
// Lights
this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const sun = new THREE.DirectionalLight(0xffffff, 0.8);
sun.position.set(10, 30, 20);
sun.castShadow = true;
this.scene.add(sun);
// Groups
this.scene.add(this.gridGroup);
this.scene.add(this.axisGuideGroup);
// Ground Plane for Raycast
const pgGeo = new THREE.PlaneGeometry(2000, 2000);
const pgMat = new THREE.MeshBasicMaterial({ visible: false });
this.planeGround = new THREE.Mesh(pgGeo, pgMat);
this.planeGround.rotation.x = -Math.PI / 2;
this.scene.add(this.planeGround);
this.initCursors();
this.initAxisGuides();
// Events
const cvs = this.renderer.domElement;
cvs.addEventListener('pointermove', (e) => this.onPointerMove(e));
cvs.addEventListener('pointerdown', (e) => this.onPointerDown(e));
cvs.addEventListener('pointerup', (e) => this.onPointerUp(e));
window.addEventListener('resize', () => this.onResize());
window.addEventListener('keydown', (e) => this.onKeyDown(e));
window.addEventListener('keyup', (e) => this.onKeyUp(e));
}
initCursors() {
// Target Cursor (Red transparent box for Add)
const tGeo = new THREE.BoxGeometry(1, 1, 1);
const tMat = new THREE.MeshBasicMaterial({ color: 0xff3333, transparent: true, opacity: 0.3, depthTest: false });
this.cursorTarget = new THREE.Mesh(tGeo, tMat);
this.scene.add(this.cursorTarget);
this.cursorTarget.visible = false;
// Selection Cursor (Yellow wireframe)
const sGeo = new THREE.BoxGeometry(1.05, 1.05, 1.05);
const sEdges = new THREE.EdgesGeometry(sGeo);
this.cursorSelect = new THREE.LineSegments(sEdges, new THREE.LineBasicMaterial({ color: 0xffff00, depthTest: false }));
this.scene.add(this.cursorSelect);
this.cursorSelect.visible = false;
}
initAxisGuides() {
const origin = new THREE.Vector3(0,0,0);
const len = 5;
const ax = new THREE.ArrowHelper(new THREE.Vector3(1,0,0), origin, len, 0xff0000, 1, 0.5);
const ay = new THREE.ArrowHelper(new THREE.Vector3(0,1,0), origin, len, 0x00ff00, 1, 0.5);
const az = new THREE.ArrowHelper(new THREE.Vector3(0,0,1), origin, len, 0x0088ff, 1, 0.5);
this.axisGuideGroup.add(ax, ay, az);
this.axisGuideGroup.visible = false;
}
initUI() {
// Menu Bindings
document.getElementById('menu-new').onclick = () => this.newMap();
document.getElementById('menu-import-config').onclick = () => document.getElementById('file-config').click();
document.getElementById('menu-import-assets').onclick = () => document.getElementById('file-assets').click();
document.getElementById('menu-save').onclick = () => this.saveMapJSON();
document.getElementById('menu-open').onclick = () => document.getElementById('file-map-json').click();
document.getElementById('menu-export-glb').onclick = () => this.exportScene('glb');
document.getElementById('menu-export-obj').onclick = () => this.exportScene('obj');
// Tool Bindings
const tools = ['select', 'add', 'erase', 'move', 'rotate', 'cam-move', 'cam-rotate', 'cam-zoom', 'cam-laps'];
tools.forEach(t => {
const el = document.getElementById('tool-'+t);
if(el) el.onclick = () => this.setTool(t);
});
// File Inputs
document.getElementById('file-config').onchange = (e) => this.handleImportConfig(e.target.files[0]);
document.getElementById('file-assets').onchange = (e) => this.handleImportAssets(e.target.files);
document.getElementById('file-map-json').onchange = (e) => this.handleOpenMap(e.target.files[0]);
// Settings
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 = () => {
this.gridSize.x = parseInt(document.getElementById('set-grid-x').value);
this.gridSize.y = parseInt(document.getElementById('set-grid-y').value);
this.gridSize.z = parseInt(document.getElementById('set-grid-z').value);
const newVoxel = parseFloat(document.getElementById('set-voxel-size').value);
if (newVoxel !== this.voxelSize) {
this.voxelSize = newVoxel;
this.refreshAllCells(); // Re-scale everything
}
this.updateEnvironment();
document.getElementById('modal-overlay').style.display = 'none';
};
// Property Inputs
document.getElementById('p-rot').onchange = (e) => {
if(this.selectedCell) {
this.selectedCell.rotation = parseInt(e.target.value);
this.refreshCellVisual(this.selectedCell);
this.updatePropsUI();
}
};
}
updateEnvironment() {
while(this.gridGroup.children.length > 0) this.gridGroup.remove(this.gridGroup.children[0]);
document.getElementById('labels-container').innerHTML = '';
const sizeX = this.gridSize.x * this.voxelSize;
const sizeZ = this.gridSize.z * this.voxelSize;
const floorGrid = new THREE.GridHelper(Math.max(sizeX, sizeZ), Math.max(this.gridSize.x, this.gridSize.z), 0x555555, 0x2a2a2a);
this.gridGroup.add(floorGrid);
const halfX = sizeX / 2; const halfZ = sizeZ / 2;
// Add labels
const addLbl = (pos, txt, col) => {
const el = document.createElement('div');
el.className = 'arrow-label';
el.innerText = txt; el.style.color = col;
el.dataset.pos = JSON.stringify(pos);
document.getElementById('labels-container').appendChild(el);
};
addLbl({x:halfX+2,y:0,z:0}, "X+", "#ff4444");
addLbl({x:0,y:0,z:halfZ+2}, "Z+", "#0088ff");
addLbl({x:0,y:sizeX+1,z:-halfZ}, "Y+", "#44ff44");
// Update cursors
this.cursorTarget.scale.set(this.voxelSize, this.voxelSize, this.voxelSize);
this.cursorSelect.scale.set(this.voxelSize*1.05, this.voxelSize*1.05, this.voxelSize*1.05);
}
// --- Logic: WFC & Cells ---
// Returns {px, nx, py, ny, pz, nz} sockets for a given module & rotation
getRotatedSockets(config, rot) {
// rot is 0..3 (x 90deg Y-axis clockwise)
// Original: 0:px, 1:nx, 2:py, 3:ny, 4:pz, 5:nz
// Standard Y-Rot 90: px->pz, pz->nx, nx->nz, nz->px
let s = [...config.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/ny rotate "content" but socket ID typically stays if isotropic
// For now assume top/bottom don't change by Y-rotation
s = next;
}
return s;
}
getNeighbors(x, y, z) {
const getC = (cx, cy, cz) => this.cells.find(c => c.x===cx && c.y===cy && c.z===cz);
return [
{ dir: 0, opp: 1, cell: getC(x+1, y, z) }, // px
{ dir: 1, opp: 0, cell: getC(x-1, y, z) }, // nx
{ dir: 2, opp: 3, cell: getC(x, y+1, z) }, // py
{ dir: 3, opp: 2, cell: getC(x, y-1, z) }, // ny
{ dir: 4, opp: 5, cell: getC(x, y, z+1) }, // pz
{ dir: 5, opp: 4, cell: getC(x, y, z-1) } // nz
];
}
solveWFC(x, y, z) {
// Find all loaded configs
let candidates = [];
// Add "Empty" as implicit option? Or explicit?
// For this editor, we assume user wants to place *something*.
if (this.configs.length === 0) {
alert("No configs loaded.");
return null;
}
// Expand all configs * all rotations
const allModules = [];
this.configs.forEach(conf => {
const rots = (conf.symmetry === 'X') ? [0] : (conf.symmetry === 'I' ? [0,1] : [0,1,2,3]);
rots.forEach(r => {
allModules.push({ config: conf, rotation: r, sockets: this.getRotatedSockets(conf, r) });
});
});
// Get Neighbors
const neighbors = this.getNeighbors(x, y, z);
// Filter
candidates = allModules.filter(mod => {
for (let n of neighbors) {
if (n.cell) {
// Neighbor exists. Check compatibility.
// Neighbor's socket at 'opp' must match my socket at 'dir'
// Get neighbor sockets
const nConfig = this.configs.find(c => c.name === n.cell.moduleName);
if (nConfig) {
const nSockets = this.getRotatedSockets(nConfig, n.cell.rotation);
if (nSockets[n.opp] !== mod.sockets[n.dir]) {
return false; // Mismatch
}
}
}
}
return true;
});
if (candidates.length === 0) return null;
return candidates[Math.floor(Math.random() * candidates.length)];
}
addCell(x, y, z, forcedModule = null, forcedRot = 0) {
// Check if occupied
const existing = this.cells.find(c => c.x===x && c.y===y && c.z===z);
if(existing) this.deleteCell(existing);
let chosen = null;
if (forcedModule) {
chosen = { config: forcedModule, rotation: forcedRot };
} else {
chosen = this.solveWFC(x, y, z);
}
if (!chosen) {
this.setStatus("WFC: No fitting module found!");
return;
}
const cell = new MapCell(x, y, z);
cell.moduleName = chosen.config.name;
cell.rotation = chosen.rotation;
this.cells.push(cell);
this.refreshCellVisual(cell);
this.setStatus(`Placed ${cell.moduleName} (Rot ${cell.rotation})`);
}
deleteCell(cell) {
if(!cell) return;
if(cell.mesh) this.scene.remove(cell.mesh);
this.cells = this.cells.filter(c => c !== cell);
if(this.selectedCell === cell) this.selectCell(null);
}
// --- Visuals ---
refreshCellVisual(cell) {
if(cell.mesh) this.scene.remove(cell.mesh);
const config = this.configs.find(c => c.name === cell.moduleName);
if(!config) return;
const wrapper = new THREE.Group();
config.parts.forEach(part => {
// Find asset
// Loose matching: part.assetName "wall.glb" vs keys "wall.glb", "wall", etc.
let assetObj = this.assets[part.assetName];
if(!assetObj) {
// try fuzzy
const key = Object.keys(this.assets).find(k => k.includes(part.assetName));
if(key) assetObj = this.assets[key];
}
if(assetObj) {
const clone = assetObj.clone();
// Internal Part Transform (from Container Editor)
if(part.pos) clone.position.fromArray(part.pos);
if(part.rot) clone.rotation.fromArray(part.rot);
if(part.scl) clone.scale.fromArray(part.scl);
// Auto-Fit Logic: Scale the internal content so the whole module fits voxelSize?
// Actually, Container Editor defines relative layout.
// We should scale the ROOT of the asset to fit voxelSize if needed.
// Assuming Assets were designed for unit size or we scale them here.
// Simplest: Scale the final group.
wrapper.add(clone);
} else {
// Placeholder
const box = new THREE.Mesh(new THREE.BoxGeometry(0.5,0.5,0.5), new THREE.MeshBasicMaterial({color:0xff0000}));
if(part.pos) box.position.fromArray(part.pos);
wrapper.add(box);
}
});
// Apply Module Rotation (WFC)
wrapper.rotation.y = -cell.rotation * (Math.PI / 2);
// Position in Grid
wrapper.position.set(
cell.x * this.voxelSize,
cell.y * this.voxelSize + this.voxelSize/2, // Pivot usually bottom center? Let's assume center.
cell.z * this.voxelSize
);
// Correction: If models are pivot-bottom, adjust Y.
// Let's assume pivot is center of voxel.
// Store ref
cell.mesh = wrapper;
cell.mesh.userData.cell = cell;
this.scene.add(wrapper);
}
refreshAllCells() {
// When voxel size changes
this.cells.forEach(c => this.refreshCellVisual(c));
}
// --- Asset Imports ---
handleImportConfig(file) {
if(!file) return;
const r = new FileReader();
r.onload = (e) => {
const data = JSON.parse(e.target.result);
// Container Editor exports { container:..., parts:..., assets:... } or just the container object?
// Let's handle the "WFC Container Editor" format provided in previous prompt.
// Format: { container: {name, symmetry, sockets}, parts: [...] }
let newConfig = null;
if(data.container && data.parts) {
newConfig = new ContainerConfig(data.container.name, data.container.symmetry, data.container.sockets, data.parts);
} else {
alert("Invalid Config JSON");
return;
}
// Remove old if exists
this.configs = this.configs.filter(c => c.name !== newConfig.name);
this.configs.push(newConfig);
this.renderPalette();
this.setStatus(`Imported Config: ${newConfig.name}`);
};
r.readAsText(file);
document.getElementById('file-config').value = '';
}
handleImportAssets(files) {
const manager = new THREE.LoadingManager();
// Texture blob mapping
Array.from(files).forEach(f => {
if(f.name.match(/\.(jpg|jpeg|png|dds|tga)$/i)) {
const url = URL.createObjectURL(f);
this.fileMap[f.name] = url;
}
});
manager.setURLModifier((url) => {
const name = url.replace(/^.*[\\\/]/, '');
if(this.fileMap[name]) return this.fileMap[name];
return url;
});
const loaders = { 'glb':new GLTFLoader(manager), 'gltf':new GLTFLoader(manager), 'fbx':new FBXLoader(manager), 'obj':new OBJLoader(manager) };
Array.from(files).forEach(f => {
const ext = f.name.split('.').pop().toLowerCase();
if(loaders[ext]) {
this.setStatus(`Loading ${f.name}...`);
const url = URL.createObjectURL(f);
loaders[ext].load(url, (res) => {
let obj = res.scene || res;
// Auto-Scale Logic: Fit to Unit (1.0)
const box = new THREE.Box3().setFromObject(obj);
const size = new THREE.Vector3(); box.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
if(maxDim > 0) {
const scale = (this.voxelSize) / maxDim; // Fit inside voxel
obj.scale.set(scale, scale, scale);
}
this.assets[f.name] = obj;
this.setStatus(`Loaded ${f.name}`);
});
}
});
document.getElementById('file-assets').value = '';
}
renderPalette() {
const list = document.getElementById('palette-list');
list.innerHTML = '';
if(this.configs.length === 0) {
list.innerHTML = '<div style="padding:10px;text-align:center;color:#666">No Configs</div>';
return;
}
this.configs.forEach(conf => {
const el = document.createElement('div');
el.className = 'palette-item';
if(this.currentConfig === conf) el.classList.add('active');
el.innerHTML = `<div class="palette-icon"></div> ${conf.name} [${conf.symmetry}]`;
el.onclick = () => {
this.currentConfig = conf;
this.renderPalette();
};
list.appendChild(el);
});
}
// --- Interaction ---
onPointerMove(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;
// Drag Logic
if(this.isDragging && !this.isLapping) {
if(this.currentTool === 'move' && this.selectedCell) {
const deltaX = e.clientX - this.dragStartMouse.x;
const deltaY = e.clientY - this.dragStartMouse.y;
if (this.axisLock.x || this.axisLock.y || this.axisLock.z) {
// Axis movement logic (simplified)
}
// For Map Editor, usually we just move grid pos
// Impl: Snap to grid
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObject(this.planeGround);
if(intersects.length > 0) {
const p = intersects[0].point;
const nx = Math.round(p.x / this.voxelSize);
const nz = Math.round(p.z / this.voxelSize);
if (nx !== this.selectedCell.x || nz !== this.selectedCell.z) {
this.selectedCell.x = nx;
this.selectedCell.z = nz;
this.refreshCellVisual(this.selectedCell);
this.updatePropsUI();
}
}
}
return;
}
if(this.isLapping) {
// Laps logic (same as model editor)
// ... omitted for brevity, basic cam laps implemented below
return;
}
// Hover Logic
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObject(this.planeGround);
if(intersects.length > 0) {
const p = intersects[0].point;
const ix = Math.round(p.x / this.voxelSize);
const iz = Math.round(p.z / this.voxelSize);
// Height finding
let iy = 0;
// Find max Y at this X,Z
this.cells.forEach(c => {
if(c.x===ix && c.z===iz && c.y >= iy) iy = c.y + 1;
});
const worldY = iy * this.voxelSize + this.voxelSize/2;
if(this.currentTool === 'add') {
this.cursorTarget.visible = true;
this.cursorTarget.position.set(ix*this.voxelSize, worldY, iz*this.voxelSize);
document.getElementById('height-display').innerText = `H: ${iy}`;
} else {
this.cursorTarget.visible = false;
// Select Highlight
const cellHits = this.raycaster.intersectObjects(this.scene.children, true);
// Find closest cell mesh
let hitCell = null;
for(let hit of cellHits) {
let obj = hit.object;
while(obj) {
if(obj.userData && obj.userData.cell) {
hitCell = obj.userData.cell;
break;
}
obj = obj.parent;
}
if(hitCell) break;
}
if(hitCell && (this.currentTool === 'select' || this.currentTool === 'erase')) {
// Move highlight to cell
// (Not implemented: separate hover highlight, reusing cursorSelect for selection)
}
}
}
}
onPointerDown(e) {
if(e.button !== 0) return;
this.isDragging = true;
this.dragStartMouse.set(e.clientX, e.clientY);
if(this.currentTool.startsWith('cam-')) {
// Controls handled by OrbitControls mostly
return;
}
this.raycaster.setFromCamera(this.mouse, this.camera);
if (this.currentTool === 'add') {
// Add Logic
const pos = this.cursorTarget.position;
const gx = Math.round(pos.x / this.voxelSize);
const gy = Math.round((pos.y - this.voxelSize/2) / this.voxelSize);
const gz = Math.round(pos.z / this.voxelSize);
if (e.shiftKey && this.currentConfig) {
// Force place
this.addCell(gx, gy, gz, this.currentConfig, 0);
} else {
// WFC Solve
this.addCell(gx, gy, gz);
}
return;
}
// Hit Test Cells
const hits = this.raycaster.intersectObjects(this.scene.children, true);
let clickedCell = null;
for(let hit of hits) {
let obj = hit.object;
while(obj) {
if(obj.userData && obj.userData.cell) {
clickedCell = obj.userData.cell;
break;
}
obj = obj.parent;
}
if(clickedCell) break;
}
if(this.currentTool === 'select') {
this.selectCell(clickedCell);
} else if (this.currentTool === 'erase') {
if(clickedCell) this.deleteCell(clickedCell);
}
}
onPointerUp() { this.isDragging = false; }
selectCell(cell) {
this.selectedCell = cell;
if(cell) {
this.cursorSelect.visible = true;
this.cursorSelect.position.copy(cell.mesh.position);
this.cursorSelect.rotation.copy(cell.mesh.rotation);
this.updatePropsUI();
document.getElementById('prop-selected').classList.remove('hidden');
document.getElementById('prop-empty').classList.add('hidden');
} else {
this.cursorSelect.visible = false;
document.getElementById('prop-selected').classList.add('hidden');
document.getElementById('prop-empty').classList.remove('hidden');
}
}
updatePropsUI() {
if(!this.selectedCell) return;
const c = this.selectedCell;
document.getElementById('p-name').value = c.moduleName;
document.getElementById('p-pos-x').value = c.x;
document.getElementById('p-pos-y').value = c.y;
document.getElementById('p-pos-z').value = c.z;
document.getElementById('p-rot').value = c.rotation;
// Show Sockets
const conf = this.configs.find(cfg => cfg.name === c.moduleName);
if(conf) {
const socks = this.getRotatedSockets(conf, c.rotation);
const list = document.getElementById('p-sockets-list');
list.innerHTML = '';
const labels = ['X+', 'X-', 'Y+', 'Y-', 'Z+', 'Z-'];
const colors = ['#f44','#f44', '#4f4','#4f4', '#44f','#44f'];
socks.forEach((s, i) => {
list.innerHTML += `<div class="face-row"><span class="face-tag" style="background:${colors[i]};color:#000">${labels[i]}</span> ${s}</div>`;
});
}
}
// --- Tooling ---
setTool(t) {
this.currentTool = t;
const names = {
'select':'Select', 'add':'Add (WFC)', 'erase':'Erase',
'move':'Move', 'rotate':'Rotate',
'cam-move':'Camera Pan', 'cam-rotate':'Camera Orbit', 'cam-zoom':'Camera Zoom', 'cam-laps':'Camera Laps'
};
document.getElementById('mode-display').innerText = names[t] || t;
document.getElementById('mode-display').style.borderLeftColor = (t === 'add' ? '#e74c3c' : '#007fd4');
this.cursorTarget.visible = (t === 'add');
if(t !== 'select') this.cursorSelect.visible = false;
// Controls Config
this.isLapping = (t === 'cam-laps');
this.controls.enabled = !this.isLapping;
if (t === 'cam-move') this.controls.mouseButtons.LEFT = THREE.MOUSE.PAN;
else if (t === 'cam-zoom') this.controls.mouseButtons.LEFT = THREE.MOUSE.DOLLY;
else this.controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
}
// --- Export ---
exportScene(fmt) {
// Combine content
const exportGroup = new THREE.Group();
this.cells.forEach(c => {
if(c.mesh) {
const clone = c.mesh.clone();
// Flatten hierarchy?
// Just add to group.
exportGroup.add(clone);
}
});
exportGroup.updateMatrixWorld(true);
if(fmt === 'obj') {
const res = new OBJExporter().parse(exportGroup);
this.download(new Blob([res], {type:'text/plain'}), 'map.obj');
} else {
new GLTFExporter().parse(exportGroup, (res) => {
this.download(new Blob([res], {type:'application/octet-stream'}), 'map.glb');
}, undefined, { binary: true });
}
}
saveMapJSON() {
const data = this.cells.map(c => ({
x: c.x, y: c.y, z: c.z,
module: c.moduleName,
rotation: c.rotation
}));
this.download(new Blob([JSON.stringify(data)], {type:'application/json'}), 'map_data.json');
}
handleOpenMap(file) {
const r = new FileReader();
r.onload = (e) => {
const data = JSON.parse(e.target.result);
this.newMap();
data.forEach(d => {
this.addCell(d.x, d.y, d.z, {name:d.module}, d.rotation);
});
};
r.readAsText(file);
document.getElementById('file-map-json').value = '';
}
newMap() {
this.cells.forEach(c => this.scene.remove(c.mesh));
this.cells = [];
this.selectCell(null);
}
download(blob, fname) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname;
a.click();
}
setStatus(msg) {
document.getElementById('status-bar').innerText = msg;
setTimeout(() => document.getElementById('status-bar').innerText = 'Ready', 3000);
}
viewCam(dir) {
const dist = 30;
const p = this.camera.position;
switch(dir) {
case 'default': p.set(15,15,15); break;
case 'top': p.set(0, dist, 0); break;
case 'bottom': p.set(0, -dist, 0); 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;
}
this.camera.lookAt(0,0,0);
this.controls.update();
}
onResize() {
const c = document.getElementById('canvas-container');
this.camera.aspect = c.clientWidth / c.clientHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(c.clientWidth, c.clientHeight);
}
animate() {
requestAnimationFrame(() => this.animate());
if(this.isLapping) {
// Simple auto-rotate or slerp logic
this.controls.autoRotate = true;
this.controls.update();
} else {
this.controls.autoRotate = false;
this.controls.update();
}
// Update axis guides pos
if(this.selectedCell) {
this.axisGuideGroup.visible = true;
this.axisGuideGroup.position.copy(this.selectedCell.mesh.position);
} else {
this.axisGuideGroup.visible = false;
}
// Update Labels
const width = this.renderer.domElement.clientWidth;
const height = this.renderer.domElement.clientHeight;
document.querySelectorAll('.arrow-label').forEach(el => {
const pos = JSON.parse(el.dataset.pos);
const v = new THREE.Vector3(pos.x, pos.y, pos.z);
v.project(this.camera);
if(v.z > 1) el.style.display = 'none';
else {
el.style.display = 'block';
el.style.left = ((v.x+1)/2 * width) + 'px';
el.style.top = (-(v.y-1)/2 * height) + 'px';
}
});
this.renderer.render(this.scene, this.camera);
}
// Stub for Keys
onKeyDown(e) {
if(e.key === 'Escape') this.setTool('select');
if(e.key === 'Delete') { if(this.selectedCell) this.deleteCell(this.selectedCell); }
}
onKeyUp(e) {}
}
window.app = new EditorApp();
</script>
</body>
</html>
