Files
WebGL-3d-Scene-Editor/index.html

585 lines
18 KiB
HTML
Raw Normal View History

2025-12-25 10:21:22 +01:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Three.js Texture Editor</title>
<style>
body { margin: 0; overflow: hidden; }
#texturePanel {
position: absolute;
top: 10px;
right: 10px;
background: #222;
color: #fff;
padding: 10px;
z-index: 10;
font-family: sans-serif;
display: none;
border-radius: 8px;
}
#texturePanel input {
margin: 4px 0;
}
</style>
</head>
<body>
<div id="materialPanel" style="position: absolute; top: 10px; right: 10px; background: #fff; padding: 10px; border-radius: 8px; width: 240px; z-index: 10;">
<h3>Materials</h3>
<input id="matName" placeholder="Material Name" style="width: 100%;" />
<input id="matColor" type="color" style="width: 100%; margin-top: 5px;" />
<button id="addMat" style="width: 100%; margin-top: 5px;">Add / Update</button>
<div id="materialList" style="margin-top: 10px; max-height: 150px; overflow-y: auto;"></div>
<hr>
<strong>Selected Material Properties</strong><br>
<label>Base Color: <input type="color" id="colorPicker"></label><br>
<label>Roughness: <input type="range" id="roughnessSlider" min="0" max="1" step="0.01"></label><br>
<label>Metalness: <input type="range" id="metalnessSlider" min="0" max="1" step="0.01"></label><br>
<label>Displacement: <input type="range" id="displacementSlider" min="0" max="0.1" step="0.001"></label><br>
<label>Opacity: <input type="range" id="opacitySlider" min="0" max="1" step="0.01"></label><br>
<strong>Upload Textures</strong><br>
<label>Color Map: <input type="file" id="mapInput"></label><br>
<label>Normal Map: <input type="file" id="normalInput"></label><br>
<label>Roughness Map: <input type="file" id="roughnessInput"></label><br>
<label>Metalness Map: <input type="file" id="metalnessInput"></label><br>
<label>Alpha Map: <input type="file" id="alphaInput"></label><br>
</div>
<button id="saveScene" style="position:fixed;bottom:80px;left:10px;">Save Scene</button>
<div id="addShapePanel" style="
position: fixed;
bottom: 10px;
left: 10px;
background: #222;
color: #fff;
padding: 10px;
border-radius: 8px;
font-family: sans-serif;
z-index: 10;
">
<label >Add Shape: </label>
<select id="shapeSelect">
<option value="">--Select--</option>
<option value="box">Box</option>
<option value="sphere">Sphere</option>
<option value="cylinder">Cylinder</option>
<option value="plane">Plane</option>
</select>
<button id="addShapeBtn">Add</button>
</div>
<script type="module">
import * as THREE from 'https://esm.sh/three@0.160.0';
import { OrbitControls } from 'https://esm.sh/three@0.160.0/examples/jsm/controls/OrbitControls.js';
import { TransformControls } from 'https://esm.sh/three@0.160.0/examples/jsm/controls/TransformControls.js';
import { EffectComposer } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/RenderPass.js';
import { SSAOPass } from 'https://esm.sh/three@0.160.0/examples/jsm/postprocessing/SSAOPass.js';
let scene, camera, renderer, composer, ssaoPass, controls, transformControls;
let pointLight, raycaster, selectedObject = null;
const mouse = new THREE.Vector2();
const selectable = [];
const sceneObjects = []; // Store shape, position, rotation
const draggableMeshes = [];
init();
animate();
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
camera.position.set(0, 1.5, 6);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.3;
renderer.setClearColor(0x151515);
document.body.appendChild(renderer.domElement);
controls = new OrbitControls(camera, renderer.domElement);
pointLight = new THREE.PointLight(0xffddaa, 18, 30, 1.5);
pointLight.position.set(0, 1.5, 3.5);
pointLight.castShadow = true;
pointLight.shadow.mapSize.set(2048, 2048);
pointLight.shadow.radius = 2;
scene.add(pointLight);
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.1), new THREE.MeshBasicMaterial({ color: 0xffddaa }));
pointLight.add(bulb);
scene.add(new THREE.HemisphereLight(0xfff0e0, 0x222244, 0.3));
const textureLoader = new THREE.TextureLoader();
const roomMaterial = new THREE.MeshPhysicalMaterial({
map: textureLoader.load('tiles/Tiles133A_1K-JPG_Color.jpg'),
normalMap: textureLoader.load('tiles/Tiles133A_1K-JPG_NormalGL.jpg'),
roughnessMap: textureLoader.load('tiles/Tiles133A_1K-JPG_Roughness.jpg'),
displacementMap: textureLoader.load('tiles/Tiles133A_1K-JPG_Displacement.jpg'),
side: THREE.BackSide,
metalness: 0.2,
roughness: 0.6,
displacementScale: 0.01
});
const room = new THREE.Mesh(new THREE.BoxGeometry(6, 4, 6), roomMaterial);
room.receiveShadow = true;
scene.add(room);
const box = new THREE.Mesh(
new THREE.BoxGeometry(2, 2, 2),
new THREE.MeshStandardMaterial({
color: 'rgb(34,139,34)',
metalness: 0.2,
roughness: 0.7
})
);
box.position.set(1.5, 0, 0);
box.castShadow = true;
box.receiveShadow = true;
selectable.push(box);
scene.add(box);
loadSceneFromStorage();
composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
ssaoPass = new SSAOPass(scene, camera, window.innerWidth, window.innerHeight);
ssaoPass.kernelRadius = 6;
ssaoPass.minDistance = 0.02;
ssaoPass.maxDistance = 0.15;
composer.addPass(ssaoPass);
raycaster = new THREE.Raycaster();
window.addEventListener('click', (event) => {
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(selectable, true); // use `selectable` array
if (intersects.length > 0) {
const obj = intersects[0].object;
if (obj.material && obj.material.isMaterial) {
selectedObject = obj;
transformControls.attach(obj);
document.getElementById('materialPanel').style.display = 'block';
attachMaterialControls(obj.material);
// Optional: show material name if it's in your materials array
const matIndex = materials.findIndex(m => m.material === obj.material);
if (matIndex >= 0) {
matNameInput.value = materials[matIndex].name;
matColorInput.value = `#${obj.material.color.getHexString()}`;
selectedMaterialIndex = matIndex;
}
}
}
});
transformControls = new TransformControls(camera, renderer.domElement);
transformControls.addEventListener('dragging-changed', e => controls.enabled = !e.value);
scene.add(transformControls);
const tools = ['translate', 'rotate', 'scale', 'none'];
tools.forEach(tool => {
const btn = document.createElement('button');
btn.textContent = tool.charAt(0).toUpperCase() + tool.slice(1);
btn.style.cssText = 'position:fixed;top:10px;margin:2px;padding:6px;';
btn.style.left = `${10 + tools.indexOf(tool) * 80}px`;
btn.onclick = () => {
if (tool === 'none') {
transformControls.detach();
} else {
transformControls.setMode(tool);
}
};
document.body.appendChild(btn);
});
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
composer.setSize(window.innerWidth, window.innerHeight);
});
window.addEventListener('mousemove', (e) => {
const x = (e.clientX / window.innerWidth) * 2 - 1;
const y = -(e.clientY / window.innerHeight) * 2 + 1;
pointLight.position.x = x * 3;
pointLight.position.y = y * 1 + 1.5;
});
// Texture Upload UI Setup
const texturePanel = document.getElementById('texturePanel');
const inputs = {
map: document.getElementById('mapInput'),
normalMap: document.getElementById('normalInput'),
roughnessMap: document.getElementById('roughnessInput'),
metalnessMap: document.getElementById('metalnessInput'),
alphaMap: document.getElementById('alphaInput')
};
const materials = [];
const matNameInput = document.getElementById("matName");
const matColorInput = document.getElementById("matColor");
const addMatBtn = document.getElementById("addMat");
const matList = document.getElementById("materialList");
function refreshMaterialList() {
matList.innerHTML = '';
materials.forEach((entry, index) => {
const div = document.createElement('div');
div.textContent = entry.name;
div.style.background = entry.material.color.getStyle();
div.style.color = '#fff';
div.style.padding = '5px';
div.style.marginBottom = '5px';
div.style.cursor = 'grab';
div.draggable = true;
// Drag & Drop
div.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('materialIndex', index);
});
// Click to edit
div.addEventListener('click', () => {
matNameInput.value = entry.name;
matColorInput.value = '#' + entry.material.color.getHexString();
selectedMaterialIndex = index;
if (selectedObject) {
selectedObject.material = entry.material.clone();
attachMaterialControls(entry.material);
}
});
matList.appendChild(div);
});
}
addMatBtn.addEventListener('click', () => {
const name = matNameInput.value.trim();
const color = matColorInput.value;
if (!name) return;
const hexColor = new THREE.Color(color);
let mat;
if (selectedMaterialIndex >= 0) {
// Update existing
mat = materials[selectedMaterialIndex].material;
mat.color = hexColor;
materials[selectedMaterialIndex].name = name;
} else {
// Add new
mat = new THREE.MeshStandardMaterial({ color: hexColor });
materials.push({ name, material: mat });
}
// Re-assign to selected object (optional)
if (selectedObject) {
selectedObject.material = mat;
attachMaterialControls(mat);
}
refreshMaterialList();
matNameInput.value = '';
selectedMaterialIndex = -1;
});
let selectedMaterialIndex = -1;
renderer.domElement.addEventListener('dragover', (e) => {
e.preventDefault();
});
renderer.domElement.addEventListener('drop', (e) => {
e.preventDefault();
const matIndex = e.dataTransfer.getData('materialIndex');
if (matIndex === '') return;
// Convert screen coords to NDC
const rect = renderer.domElement.getBoundingClientRect();
const mouse = new THREE.Vector2(
((e.clientX - rect.left) / rect.width) * 2 - 1,
-((e.clientY - rect.top) / rect.height) * 2 + 1
);
// Raycast
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster.intersectObjects(draggableMeshes, true);
console.log("intersects", intersects);
if (intersects.length > 0) {
const intersectedMesh = intersects[0].object;
intersectedMesh.material = materials[matIndex].material.clone();
}
});
const loader = new THREE.TextureLoader();
function applyTexture(mapType, file) {
console.log(selectedObject);
if (!selectedObject || !selectedObject.material) return;
const url = URL.createObjectURL(file);
const tex = new THREE.TextureLoader().load(url, () => {
tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
tex.repeat.set(1, 1);
selectedObject.material[mapType] = tex;
selectedObject.material.needsUpdate = true;
if (mapType === 'alphaMap') {
selectedObject.material.transparent = true;
selectedObject.material.alphaTest = 0.5;
}
});
}
for (const mapType in inputs) {
inputs[mapType].addEventListener('change', (e) => {
if (e.target.files.length > 0) {
applyTexture(mapType, e.target.files[0]);
}
});
}
}
const colorInput = document.getElementById('colorPicker');
const roughnessInput = document.getElementById('roughnessSlider');
const metalnessInput = document.getElementById('metalnessSlider');
const displacementInput = document.getElementById('displacementSlider');
const opacityInput = document.getElementById('opacitySlider');
function updateUIFromMaterial(mat) {
if (!mat) return;
if (mat.color) colorInput.value = `#${mat.color.getHexString()}`;
roughnessInput.value = mat.roughness ?? 0.5;
metalnessInput.value = mat.metalness ?? 0.5;
displacementInput.value = mat.displacementScale ?? 0;
opacityInput.value = mat.opacity ?? 1;
}
function attachMaterialControls(mat) {
updateUIFromMaterial(mat);
colorInput.oninput = () => {
mat.color.set(colorInput.value);
mat.needsUpdate = true;
};
roughnessInput.oninput = () => {
mat.roughness = parseFloat(roughnessInput.value);
mat.needsUpdate = true;
};
metalnessInput.oninput = () => {
mat.metalness = parseFloat(metalnessInput.value);
mat.needsUpdate = true;
};
displacementInput.oninput = () => {
mat.displacementScale = parseFloat(displacementInput.value);
mat.needsUpdate = true;
};
opacityInput.oninput = () => {
mat.opacity = parseFloat(opacityInput.value);
mat.transparent = mat.opacity < 1;
mat.needsUpdate = true;
};
}
function saveSceneToStorage() {
const data = sceneObjects.map(obj => ({
type: obj.type,
position: {
x: obj.mesh.position.x,
y: obj.mesh.position.y,
z: obj.mesh.position.z
},
rotation: {
x: obj.mesh.rotation.x,
y: obj.mesh.rotation.y,
z: obj.mesh.rotation.z
}
}));
console.log("save", data);
localStorage.setItem('savedScene', JSON.stringify(data));
}
// Tool to add primitive shapes
const shapeSelect = document.getElementById('shapeSelect');
const addShapeBtn = document.getElementById('addShapeBtn');
console.log("shapeSelect", shapeSelect);
addShapeBtn.addEventListener('click', () => {
const type = shapeSelect.value;
if (!type) return;
let geometry;
switch (type) {
case 'box': geometry = new THREE.BoxGeometry(1, 1, 1); break;
case 'sphere': geometry = new THREE.SphereGeometry(0.75, 32, 32); break;
case 'cylinder': geometry = new THREE.CylinderGeometry(0.5, 0.5, 1, 32); break;
case 'plane': geometry = new THREE.PlaneGeometry(2, 2); break;
}
const material = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.3, roughness: 0.7 });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(0, 0, 0);
mesh.castShadow = true;
mesh.receiveShadow = true;
draggableMeshes.push(mesh);
scene.add(mesh);
selectable.push(mesh);
transformControls.attach(mesh);
selectedObject = mesh;
if (texturePanel) texturePanel.style.display = 'block';
attachMaterialControls(mesh.material);
// ✅ Save object state
sceneObjects.push({
mesh,
type,
update() {
this.position = mesh.position.clone();
this.rotation = mesh.rotation.clone();
},
position: mesh.position.clone(),
rotation: mesh.rotation.clone()
});
saveSceneToStorage();
});
function loadSceneFromStorage() {
const saved = localStorage.getItem('savedScene');
if (!saved) return;
const objects = JSON.parse(saved);
for (const obj of objects) {
let geometry;
switch (obj.type) {
case 'box': geometry = new THREE.BoxGeometry(1, 1, 1); break;
case 'sphere': geometry = new THREE.SphereGeometry(0.75, 32, 32); break;
case 'cylinder': geometry = new THREE.CylinderGeometry(0.5, 0.5, 1, 32); break;
case 'plane': geometry = new THREE.PlaneGeometry(2, 2); break;
}
const material = new THREE.MeshStandardMaterial({ color: 0x888888, metalness: 0.3, roughness: 0.7 });
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(obj.position.x, obj.position.y, obj.position.z);
mesh.rotation.set(obj.rotation.x, obj.rotation.y, obj.rotation.z);
mesh.castShadow = true;
mesh.receiveShadow = true;
draggableMeshes.push(mesh);
scene.add(mesh);
selectable.push(mesh);
sceneObjects.push({
type: obj.type,
mesh,
position: mesh.position.clone(),
rotation: mesh.rotation.clone()
});
}
}
transformControls.addEventListener('objectChange', () => {
const moved = transformControls.object;
sceneObjects.forEach(obj => {
if (obj.mesh === moved) {
obj.position = moved.position.clone();
obj.rotation = moved.rotation.clone();
}
});
saveSceneToStorage();
});
document.getElementById('saveScene').addEventListener('click', () => {
const data = sceneObjects.map(obj => ({
type: obj.type,
position: obj.position,
rotation: {
x: obj.rotation.x,
y: obj.rotation.y,
z: obj.rotation.z
}
}));
console.log([JSON.stringify(data)]);
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'scene.json';
a.click();
URL.revokeObjectURL(url);
});
function animate() {
requestAnimationFrame(animate);
controls.update();
composer.render();
}
</script>
<script>
window.addEventListener('DOMContentLoaded', () => {
const shapeSelect = document.getElementById('shapeSelect');
const addShapeBtn = document.getElementById('addShapeBtn');
console.log("shapeSelect:", shapeSelect);
console.log("addShapeBtn:", addShapeBtn);
if (!addShapeBtn) {
console.error("❌ Button not found in DOM!");
return;
}
addShapeBtn.addEventListener('click', (e) => {
e.preventDefault();
console.log("✅ Button click detected");
});
});
</script>