585 lines
18 KiB
HTML
585 lines
18 KiB
HTML
<!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>
|