Files
WebGL-3d-Scene-Editor/material-editor.js

1276 lines
34 KiB
JavaScript
Raw Normal View History

2025-12-25 10:21:22 +01:00
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';
class TextureEditorApp {
constructor() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 100);
const canvas = document.getElementById('threeCanvas');
this.renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true
});
this.composer = true;
this.ssaoPass = null;
this.controls = null;
this.transformControls = null;
this.pointLight = null;
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.selectable = [];
this.draggableMeshes = [];
this.sceneObjects = [];
this.selectedObject = null;
this.materials = [];
this.selectedMaterialIndex = -1;
this.init().then(() => {
this.animate();
});
}
async init() {
this.cacheUI();
await this.loadMaterialsFromStorage();
this.loadPrimitivesFromStorage();
this.initCamera();
this.initRenderer();
this.initLights();
this.initControls();
this.initPostProcessing();
this.initTransformControls();
this.initThumbnailPreview();
this.addEventListeners();
this.addRoom();
//this.addBigBox();
this.initMaterialPreview();
this.refreshMaterialList();
this.stabilizeResize();
}
stabilizeResize(attempts = 10) {
if (attempts <= 0) return;
this.onResize();
requestAnimationFrame(() => this.stabilizeResize(attempts - 1));
}
updatePreviewMaterial(mat) {
if (this.previewSphere) {
this.previewSphere.material = mat;
}
}
initMaterialPreview() {
this.previewScene = new THREE.Scene();
this.previewCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 10);
this.previewRenderer = new THREE.WebGLRenderer({
canvas: document.getElementById('materialPreview'),
alpha: true,
antialias: true
});
this.previewRenderer.setSize(200, 200);
this.previewRenderer.setClearColor(0x000000, 0);
//this.previewRenderer.toneMapping = THREE.ACESFilmicToneMapping;
//this.previewRenderer.toneMappingExposure = 1.3;
this.previewLight = new THREE.PointLight(0xffffff, 2);
this.previewLight.position.set(2, 2, 2);
this.previewScene.add(this.previewLight);
this.previewSphere = new THREE.Mesh(
new THREE.SphereGeometry(0.6, 32, 32),
new THREE.MeshStandardMaterial({ color: 0xffffff })
);
this.previewScene.add(this.previewSphere);
this.previewCamera.position.z = 2;
this.previewCamera.lookAt(0, 0, 0);
}
async loadTexture(loader, url) {
const texture = await new Promise((resolve) => {
loader.load(url, (tex) => {
// Check if it's a normal map based on filename
const isNormal = url.toLowerCase().includes('normal');
if (isNormal) {
tex.encoding = THREE.LinearEncoding;
tex.minFilter = THREE.LinearMipMapLinearFilter;
tex.magFilter = THREE.LinearFilter;
}
tex.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
resolve(tex);
}, undefined, () => resolve(null));
});
return texture;
}
async loadMaterialsFromStorage() {
const data = localStorage.getItem("materials");
if (!data) {
return;
}
const list = JSON.parse(data);
const loader = new THREE.TextureLoader();
for (const item of list) {
const material = new THREE.MeshStandardMaterial({
color: new THREE.Color(item.color),
metalness: item.metalness,
roughness: item.roughness,
displacementScale: item.displacementScale,
opacity: item.opacity,
transparent: item.opacity < 1.0
});
if (item.mapURL) {
material.map = await this.loadTexture(loader, item.mapURL);
}
if (item.normalMapURL) {
const normalMap = await this.loadTexture(loader, item.normalMapURL);
if (normalMap) {
normalMap.wrapS = THREE.RepeatWrapping;
normalMap.wrapT = THREE.RepeatWrapping;
normalMap.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
normalMap.generateMipmaps = true;
normalMap.minFilter = THREE.LinearMipMapLinearFilter;
normalMap.magFilter = THREE.LinearFilter;
normalMap.encoding = THREE.LinearEncoding;
normalMap.needsUpdate = true;
material.normalMap = normalMap;
material.normalScale = new THREE.Vector2( 0.5, 0.5 );
} else {
console.warn( "Failed to load normal map:", item.normalMapURL );
}
}
if (item.displacementMapURL) {
const displacementMap = await this.loadTexture(loader, item.displacementMapURL);
if (displacementMap) {
displacementMap.wrapS = THREE.RepeatWrapping;
displacementMap.wrapT = THREE.RepeatWrapping;
displacementMap.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
displacementMap.minFilter = THREE.LinearMipMapLinearFilter;
displacementMap.magFilter = THREE.LinearFilter;
displacementMap.generateMipmaps = true;
displacementMap.needsUpdate = true;
material.displacementMap = displacementMap;
material.displacementScale = item.displacementScale ?? 0.03;
}
}
if (item.roughnessMapURL) {
material.roughnessMap = await this.loadTexture(loader, item.roughnessMapURL);
}
if (item.metalnessMapURL) {
material.metalnessMap = await this.loadTexture(loader, item.metalnessMapURL);
}
if (item.alphaMapURL) {
material.alphaMap = await this.loadTexture(loader, item.alphaMapURL);
material.transparent = true;
}
material.needsUpdate = true;
this.materials.push({
name: item.name,
material: material,
mapURL: item.mapURL,
normalMapURL: item.normalMapURL,
roughnessMapURL: item.roughnessMapURL,
metalnessMapURL: item.metalnessMapURL,
alphaMapURL: item.alphaMapURL
});
}
this.refreshMaterialList();
}
saveMaterialsToStorage(mat) {
this.updatePreviewMaterial(mat);
const list = this.materials.map(({ name, material, mapURL, normalMapURL, roughnessMapURL, metalnessMapURL, alphaMapURL, displacementMapURL }) => {
const obj = {
name: name,
color: "#" + material.color.getHexString(),
metalness: material.metalness,
roughness: material.roughness,
displacementScale: material.displacementScale,
opacity: material.opacity,
mapURL: mapURL || null,
normalMapURL: normalMapURL || null,
roughnessMapURL: roughnessMapURL || null,
metalnessMapURL: metalnessMapURL || null,
alphaMapURL: alphaMapURL || null,
displacementMapURL: displacementMapURL || null
};
return obj;
});
console.log("saved material count:", list.length);
localStorage.setItem("materials", JSON.stringify(list));
}
refreshMaterialList() {
this.matList.innerHTML = '';
this.materials.forEach(async (entry, index) => {
const div = document.createElement('div');
div.style.padding = '5px';
div.style.marginBottom = '10px';
div.style.border = '1px solid #333';
div.style.background = '#111';
div.style.color = '#fff';
div.style.cursor = 'grab';
div.draggable = true;
div.dataset.index = index;
const img = document.createElement('img');
img.width = 216;
img.height = 216;
img.style.display = 'block';
img.style.margin = '0 auto 5px auto';
img.src = ''; // set later
const label = document.createElement('div');
label.textContent = entry.name;
label.style.textAlign = 'center';
div.appendChild(img);
div.appendChild(label);
div.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('materialIndex', index);
});
div.addEventListener('click', () => {
this.matNameInput.value = entry.name;
this.colorInput.value = '#' + entry.material.color.getHexString();
this.selectedMaterialIndex = index;
this.updatePreviewMaterial(entry.material);
if (this.selectedObject) {
this.selectedObject.material = entry.material;
this.attachMaterialControls(entry.material);
this.updateUIFromMaterial(entry.material);
}
});
this.matList.appendChild(div);
// Render preview AFTER textures are ready
const dataURL = await this.renderThumbnail(entry.material);
img.src = dataURL;
});
}
adjustImageBrightness(img, brightnessFactor = 0.75) {
return new Promise((resolve) => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
img.onload = () => {
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
data[i] *= brightnessFactor; // R
data[i + 1] *= brightnessFactor; // G
data[i + 2] *= brightnessFactor; // B
}
ctx.putImageData(imageData, 0, 0);
resolve(canvas.toDataURL());
};
img.src = img.src; // trigger reload
});
}
async waitForTextures(material) {
const maps = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'alphaMap'];
const promises = [];
maps.forEach(key => {
const tex = material[key];
if (tex) {
if (tex.image) {
// If image exists and is complete, resolve immediately
if (tex.image.complete || (tex.image.readyState && tex.image.readyState === 4)) {
// already loaded
return;
}
// Listen for load event if image exists but not loaded
promises.push(
new Promise(resolve => {
tex.image.addEventListener('load', () => resolve());
tex.image.addEventListener('error', () => resolve());
})
);
} else {
// If no image (likely a DataTexture or procedural), wait for onUpdate callback
promises.push(
new Promise(resolve => {
tex.onUpdate = () => {
resolve();
tex.onUpdate = null;
};
})
);
}
}
});
await Promise.all(promises);
}
initThumbnailPreview() {
this.thumbRenderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
this.thumbRenderer.setSize( 256, 256 );
this.thumbRenderer.setClearColor(0x000000, 0);
this.thumbScene = new THREE.Scene();
this.thumbCamera = new THREE.PerspectiveCamera(45, 1, 0.1, 10);
this.thumbCamera.position.z = 2;
this.thumbCamera.lookAt(0, 0, 0);
const light = new THREE.DirectionalLight(0xffffff, 1.5);
light.position.set(4, 4, 4);
this.thumbScene.add(light);
// Add ambient light to soften shadows & prevent complete black areas
this.thumbScene.add(new THREE.AmbientLight(0xffffff, 0.3));
this.thumbComposer = new EffectComposer(this.thumbRenderer);
this.thumbComposer.addPass(new RenderPass(this.thumbScene, this.thumbCamera));
this.thumbSsaoPass = new SSAOPass(this.thumbScene, this.thumbCamera, 64, 64);
this.thumbSsaoPass.kernelRadius = 6;
this.thumbSsaoPass.minDistance = 0.02;
this.thumbSsaoPass.maxDistance = 0.15;
this.thumbComposer.addPass(this.thumbSsaoPass);
}
async renderThumbnail(material) {
// Clone the material to avoid modifying the original
const mat = material.clone();
// Ensure all texture maps are loaded
await this.waitForTextures(mat);
// Mark material for update after textures are loaded
mat.needsUpdate = true;
// Remove old mesh if any
if (this.thumbMesh) {
this.thumbScene.remove(this.thumbMesh);
this.thumbMesh.geometry.dispose();
this.thumbMesh.material.dispose();
this.thumbMesh = null;
}
// Recreate mesh after textures are loaded and material is valid
this.thumbMesh = new THREE.Mesh(new THREE.SphereGeometry(0.6, 32, 32), mat);
this.thumbScene.add(this.thumbMesh);
// Force material update in renderer
this.thumbRenderer.outputColorSpace = THREE.SRGBColorSpace;
this.thumbRenderer.toneMapping = THREE.ACESFilmicToneMapping;
this.thumbRenderer.toneMappingExposure = 1.3;
this.thumbRenderer.physicallyCorrectLights = true;
this.thumbRenderer.clear();
this.thumbComposer.render();
// Return snapshot
const dataURL = this.thumbRenderer.domElement.toDataURL();
// Remove mesh again if this is purely for snapshot (optional)
this.thumbScene.remove(this.thumbMesh);
this.thumbMesh.geometry.dispose();
this.thumbMesh.material.dispose();
this.thumbMesh = null;
return dataURL;
}
promptAddMaterial() {
const matName = prompt("Enter new material name:");
if (!matName || matName.trim() === "") return;
const name = matName.trim();
const material = new THREE.MeshStandardMaterial({ color: 0xffffff });
this.materials.push({ name: name, material: material });
this.selectedMaterialIndex = this.materials.length - 1;
this.matNameInput.value = name;
this.updatePreviewMaterial(material);
this.refreshMaterialList();
this.saveMaterialsToStorage(material);
}
cacheUI() {
/*
this.loadMaterialFromFolder("Marble002_2K-JPG", "/textures");
this.loadMaterialFromFolder("Terrazzo015_2K-JPG", "/textures");
this.loadMaterialFromFolder("DiamondPlate001_2K-JPG", "/textures");
this.loadMaterialFromFolder("Marble003_2K-JPG", "/textures");
this.loadMaterialFromFolder("Tiles040_2K-JPG", "/textures");
this.loadMaterialFromFolder("DiamondPlate005B_2K-JPG", "/textures");
this.loadMaterialFromFolder("PavingStones072_2K-JPG", "/textures");
this.loadMaterialFromFolder("Tiles041_2K-JPG", "/textures");
this.loadMaterialFromFolder("DiamondPlate005D_2K-JPG", "/textures");
this.loadMaterialFromFolder("PavingStones075_2K-JPG", "/textures");
this.loadMaterialFromFolder("Tiles044_2K-JPG", "/textures");
this.loadMaterialFromFolder("Gravel022_1K-JPG", "/textures");
this.loadMaterialFromFolder("PavingStones085_2K-JPG.usdc", "/textures");
this.loadMaterialFromFolder("Tiles105_2K-JPG", "/textures");
this.loadMaterialFromFolder("Tiles108_2K-JPG", "/textures");
this.loadMaterialFromFolder("Gravel023_1K-JPG", "/textures");
this.loadMaterialFromFolder("Plastic012A_1K-JPG", "/textures");
this.loadMaterialFromFolder("WoodFloor046_2K-JPG", "/textures");
this.loadMaterialFromFolder("Leather034A_2K-JPG", "/textures");
this.loadMaterialFromFolder("Plastic015A_1K-JPG", "/textures");
this.loadMaterialFromFolder("Leather034C_2K-JPG", "/textures");
*/
this.matNameInput = document.getElementById("matName");
this.saveMaterialBtn = document.getElementById("saveMaterial"); // existing save button
this.addMaterialBtn = document.getElementById("addMaterialBtn"); // new add button
this.matList = document.getElementById("materialList");
this.colorInput = document.getElementById('colorPicker');
this.roughnessInput = document.getElementById('roughnessSlider');
this.metalnessInput = document.getElementById('metalnessSlider');
this.displacementInput = document.getElementById('displacementSlider');
this.opacityInput = document.getElementById('opacitySlider');
this.inputs = {
map: document.getElementById('mapInput'),
normalMap: document.getElementById('normalInput'),
roughnessMap: document.getElementById('roughnessInput'),
metalnessMap: document.getElementById('metalnessInput'),
alphaMap: document.getElementById('alphaInput')
};
this.saveMaterialBtn.addEventListener('click', () => this.addOrUpdateMaterial());
if (this.addMaterialBtn) {
this.addMaterialBtn.addEventListener("click", () => this.promptAddMaterial());
}
for (const mapType in this.inputs) {
this.inputs[mapType].addEventListener('change', (e) => {
if (e.target.files.length > 0) {
this.applyTexture(mapType, e.target.files[0]);
}
});
this.inputs[mapType].addEventListener('dragover', (e) => {
e.preventDefault();
});
this.inputs[mapType].addEventListener('drop', (e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) {
this.applyTexture(mapType, file);
this.inputs[mapType].files = e.dataTransfer.files; // Optional: visually update the input
}
});
}
this.colorInput.addEventListener('change', () => {
if (this.selectedMaterialIndex >= 0) {
const mat = this.materials[this.selectedMaterialIndex].material;
mat.color.set(this.colorInput.value);
this.refreshMaterialList();
this.saveMaterialsToStorage(mat); // ✅ save after color change
}
});
this.roughnessInput.addEventListener('change', () => {
if (this.selectedMaterialIndex >= 0) {
const mat = this.materials[this.selectedMaterialIndex].material;
mat.roughness = parseFloat(this.roughnessInput.value);
this.saveMaterialsToStorage(mat); // ✅ save after change
}
});
this.metalnessInput.addEventListener('change', () => {
if (this.selectedMaterialIndex >= 0) {
const mat = this.materials[this.selectedMaterialIndex].material;
mat.metalness = parseFloat(this.metalnessInput.value);
this.saveMaterialsToStorage(mat); // ✅ save after change
}
});
this.displacementInput.addEventListener('change', () => {
if (this.selectedMaterialIndex >= 0) {
const mat = this.materials[this.selectedMaterialIndex].material;
mat.displacementScale = parseFloat(this.displacementInput.value);
this.saveMaterialsToStorage(mat); // ✅ save after change
}
});
this.opacityInput.addEventListener('change', () => {
if (this.selectedMaterialIndex >= 0) {
const mat = this.materials[this.selectedMaterialIndex].material;
mat.opacity = parseFloat(this.opacityInput.value);
mat.transparent = mat.opacity < 1;
this.saveMaterialsToStorage(mat); // ✅ save after change
}
});
this.primitiveList = document.getElementById('primitiveList');
Array.from(this.primitiveList.querySelectorAll('[draggable="true"]')).forEach((el) => {
el.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('primitiveType', el.dataset.primitive);
});
});
document.body.addEventListener('dragover', (e) => e.preventDefault());
document.body.addEventListener('drop', this.onDropMaterial.bind(this));
}
savePrimitivesToStorage() {
this.primitivesData = [];
console.log("save transform");
this.sceneObjects.forEach((obj) => {
if (!obj.userData.isPrimitive) return;
const data = {
type: obj.userData.type,
position: { x: obj.position.x, y: obj.position.y, z: obj.position.z },
rotation: { x: obj.rotation.x, y: obj.rotation.y, z: obj.rotation.z },
scale: { x: obj.scale.x, y: obj.scale.y, z: obj.scale.z },
materialName: null
};
// Find material name from your materials list
for (let i = 0; i < this.materials.length; i++) {
if (obj.material === this.materials[i].material) {
data.materialName = this.materials[i].name;
break;
}
}
this.primitivesData.push(data);
});
console.log(this.primitivesData);
localStorage.setItem('primitives', JSON.stringify(this.primitivesData));
}
loadPrimitivesFromStorage() {
const saved = localStorage.getItem('primitives');
if (!saved) return;
try {
const list = JSON.parse(saved);
list.forEach((data) => {
let geometry = null;
switch(data.type) {
case 'cube':
geometry = new THREE.BoxGeometry(1, 1, 1);
break;
case 'sphere':
geometry = new THREE.SphereGeometry(0.5, 32, 32);
break;
case 'cone':
geometry = new THREE.ConeGeometry(0.5, 1, 32);
break;
case 'cone':
geometry = new THREE.ConeGeometry(0.5, 1, 32);
break;
case 'cylinder':
geometry = new THREE.CylinderGeometry(0.5, 0.5, 1, 32);
break;
// Add more types if you want
}
if (!geometry) return;
// Find material by name or fallback to default
let mat = this.materials.find(m => m.name === data.materialName);
if (mat) {
mat = mat.material;
} else {
mat = new THREE.MeshStandardMaterial({ color: '#888888' });
}
const mesh = new THREE.Mesh(geometry, mat);
mesh.position.set(data.position.x, data.position.y, data.position.z);
mesh.rotation.set(data.rotation.x, data.rotation.y, data.rotation.z);
mesh.scale.set(data.scale.x, data.scale.y, data.scale.z);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.userData.isPrimitive = true;
mesh.userData.type = data.type;
this.scene.add(mesh);
this.selectable.push(mesh);
this.sceneObjects.push(mesh);
});
} catch (e) {
console.warn('Failed to load primitives', e);
}
}
onDropPrimitive(event) {
event.preventDefault();
console.log("drop primitive");
const primitiveType = event.dataTransfer.getData('primitiveType');
if (!primitiveType) return;
const canvas = this.renderer.domElement;
const rect = canvas.getBoundingClientRect();
this.mouse.x = ( ( event.clientX - rect.left ) / rect.width ) * 2 - 1;
this.mouse.y = - ( ( event.clientY - rect.top ) / rect.height ) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
// Intersect with floor/room to find drop position
const intersects = this.raycaster.intersectObjects(this.selectable, true);
let position = new THREE.Vector3(0, 0, 0);
if (intersects.length > 0) {
position.copy(intersects[0].point);
} else {
// If no intersection, drop at default height and camera forward direction
position.set(0, 0, 0);
}
let geometry;
switch (primitiveType) {
case 'cube':
geometry = new THREE.BoxGeometry(1, 1, 1);
break;
case 'sphere':
geometry = new THREE.SphereGeometry(0.5, 16, 16);
break;
case 'cylinder':
geometry = new THREE.CylinderGeometry(0.5, 0.5, 1, 16);
break;
default:
return;
}
const material = new THREE.MeshStandardMaterial({ color: 0x7777ff, metalness: 0.3, roughness: 0.6 });
const mesh = new THREE.Mesh(geometry, material);
mesh.userData.isPrimitive = true;
mesh.userData.type = primitiveType; // or 'sphere', 'cone', etc.
mesh.position.copy(position);
mesh.castShadow = true;
mesh.receiveShadow = true;
mesh.name = primitiveType;
this.sceneObjects.push(mesh);
this.scene.add(mesh);
this.selectable.push(mesh);
}
onDropMaterial(event) {
event.preventDefault();
const materialIndex = event.dataTransfer.getData('materialIndex');
if (materialIndex === '') return;
const index = parseInt(materialIndex);
if (isNaN(index) || !this.materials[index]) return;
const canvas = this.renderer.domElement;
const rect = canvas.getBoundingClientRect();
this.mouse.x = ( ( event.clientX - rect.left ) / rect.width ) * 2 - 1;
this.mouse.y = - ( ( event.clientY - rect.top ) / rect.height ) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.selectable, true);
if (intersects.length > 0) {
const obj = intersects[0].object;
obj.material = this.materials[index].material;
this.attachMaterialControls(obj.material);
this.savePrimitivesToStorage();
}
}
addOrUpdateMaterial() {
const name = this.matNameInput.value.trim();
const color = this.colorInput.value;
if (!name) return;
const hexColor = new THREE.Color(color);
let mat;
if (this.selectedMaterialIndex >= 0) {
mat = this.materials[this.selectedMaterialIndex].material;
mat.color = hexColor;
this.materials[this.selectedMaterialIndex].name = name;
} else {
mat = new THREE.MeshStandardMaterial({ color: hexColor });
this.materials.push({ name, material: mat });
}
if (this.selectedObject) {
this.selectedObject.material = mat;
this.attachMaterialControls(mat);
}
this.saveMaterialsToStorage(mat);
this.matNameInput.value = '';
this.selectedMaterialIndex = -1;
this.refreshMaterialList();
}
applyTexture(mapType, fileOrUrl) {
const textureLoader = new THREE.TextureLoader();
if (typeof fileOrUrl === 'string') {
// Normal URL (e.g. '/tiles/texture.jpg')
textureLoader.load(fileOrUrl, (texture) => {
this._applyLoadedTexture(mapType, texture, fileOrUrl);
});
return;
}
// It's a File - convert to Base64 first
const reader = new FileReader();
reader.onload = (event) => {
const base64url = event.target.result;
textureLoader.load(base64url, (texture) => {
this._applyLoadedTexture(mapType, texture, base64url);
});
};
reader.readAsDataURL(fileOrUrl);
}
_applyLoadedTexture(mapType, texture, url) {
if (this.selectedMaterialIndex < 0) return;
const matWrapper = this.materials[this.selectedMaterialIndex];
matWrapper.material[mapType] = texture;
matWrapper.material.needsUpdate = true;
matWrapper[mapType + 'URL'] = url;
this.saveMaterialsToStorage(matWrapper.material);
// If you want to also update UI sliders etc, call a method here if you have one
}
setTextureOnMaterial(mapType, texture, url) {
if (this.selectedMaterialIndex < 0) return;
const matEntry = this.materials[this.selectedMaterialIndex];
const mat = matEntry.material;
mat[mapType] = texture;
mat.needsUpdate = true;
// Save URL if texture is from a path (null if local file blob)
if (url) {
matEntry[mapType + 'URL'] = url;
} else {
matEntry[mapType + 'URL'] = null;
}
this.saveMaterialsToStorage(mat);
// If a mesh is selected, update its material reference
if (this.selectedObject) {
this.selectedObject.material = mat;
}
}
updateUIFromMaterial(material) {
this.colorInput.value = '#' + material.color.getHexString();
this.roughnessInput.value = material.roughness;
this.metalnessInput.value = material.metalness;
this.displacementInput.value = material.displacementScale || 0;
this.opacityInput.value = material.opacity || 1;
// If needed, add code to update texture preview thumbnails or inputs too
}
attachMaterialControls(mat) {
this.updateUIFromMaterial(mat);
this.colorInput.oninput = () => {
mat.color.set(this.colorInput.value);
mat.needsUpdate = true;
this.refreshMaterialList();
};
this.roughnessInput.oninput = () => {
mat.roughness = parseFloat(this.roughnessInput.value);
mat.needsUpdate = true;
};
this.metalnessInput.oninput = () => {
mat.metalness = parseFloat(this.metalnessInput.value);
mat.needsUpdate = true;
};
this.displacementInput.oninput = () => {
mat.displacementScale = parseFloat(this.displacementInput.value);
mat.needsUpdate = true;
};
this.opacityInput.oninput = () => {
mat.opacity = parseFloat(this.opacityInput.value);
mat.transparent = mat.opacity < 1;
mat.needsUpdate = true;
};
}
initCamera() {
this.camera.position.set(0, 1.5, 6);
this.camera.lookAt(0, 0, 0);
}
initRenderer() {
//this.renderer.setSize(window.innerWidth, window.innerHeight);
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
//this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 1.3;
this.renderer.setClearColor(0x151515);
//document.body.appendChild(this.renderer.domElement);
}
initLights() {
this.pointLight = new THREE.PointLight(0xffddaa, 18, 30, 1.5);
this.pointLight.position.set(0, 1.5, 3.5);
this.pointLight.castShadow = true;
this.pointLight.shadow.mapSize.set(2048, 2048);
this.pointLight.shadow.radius = 2;
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.1), new THREE.MeshBasicMaterial({ color: 0xffddaa }));
this.pointLight.add(bulb);
this.scene.add(this.pointLight);
this.scene.add(new THREE.AmbientLight(0xffffff, 0.6));
this.scene.add(new THREE.HemisphereLight(0xfff0e0, 0x222244, 0.3));
}
initControls() {
this.controls = new OrbitControls( this.camera, this.renderer.domElement );
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.05;
this.controls.zoomSpeed = 0.02; // Not too small
this.controls.minDistance = 4;
this.controls.maxDistance = 10;
this.controls.enableZoom = true;
this.controls.mouseButtons = {
LEFT: THREE.MOUSE.ROTATE,
MIDDLE: THREE.MOUSE.DOLLY,
RIGHT: THREE.MOUSE.PAN
};
}
initPostProcessing() {
this.composer = new EffectComposer(this.renderer);
const renderPass = new RenderPass(this.scene, this.camera);
this.composer.addPass(renderPass);
this.ssaoPass = new SSAOPass(this.scene, this.camera, window.innerWidth, window.innerHeight);
this.ssaoPass.kernelRadius = 6;
this.ssaoPass.minDistance = 0.02;
this.ssaoPass.maxDistance = 0.15;
this.composer.addPass(this.ssaoPass);
this.composer.renderTarget1.depthTexture = new THREE.DepthTexture();
this.composer.renderTarget1.depthTexture.type = THREE.UnsignedShortType;
this.ssaoPass.depthTexture = this.composer.renderTarget1.depthTexture;
}
async loadMaterialFromFolder(name, folderPath) {
const loader = new THREE.TextureLoader();
const mapPaths = {
mapURL: `${folderPath}/${name}/${name}_Color.jpg`,
normalMapURL: `${folderPath}/${name}/${name}_NormalGL.jpg`,
roughnessMapURL: `${folderPath}/${name}/${name}_Roughness.jpg`,
displacementMapURL: `${folderPath}/${name}/${name}_Displacement.jpg`,
alphaMapURL: `${folderPath}/${name}/${name}_Alpha.jpg`,
aoMapURL: `${folderPath}/${name}/${name}_AmbientOcclusion.jpg`
};
console.log(mapPaths);
const material = new THREE.MeshStandardMaterial({
color: 0xffffff,
metalness: 0.2,
roughness: 0.6,
displacementScale: 0.03
});
for (const [key, url] of Object.entries(mapPaths)) {
try {
const texture = await new Promise((resolve) => {
loader.load(url, resolve, undefined, () => resolve(null));
});
if (texture) {
const mapType = key.replace("URL", ""); // mapURL → map
material[mapType] = texture;
}
} catch (err) {
console.warn("Failed to load texture:", url);
}
}
material.needsUpdate = true;
this.materials.push({
name: name,
material: material,
...mapPaths
});
this.selectedMaterialIndex = this.materials.length - 1;
console.log("material", this.materials);
this.refreshMaterialList();
this.saveMaterialsToStorage(material);
}
initTransformControls() {
this.transformControls = new TransformControls(this.camera, this.renderer.domElement);
this.transformControls.addEventListener('dragging-changed', (e) => {
this.controls.enabled = !e.value;
});
this.transformControls.addEventListener('objectChange', () => {
this.savePrimitivesToStorage();
});
this.scene.add(this.transformControls);
document.getElementById('translateBtn').onclick = () => this.transformControls.setMode('translate');
document.getElementById('rotateBtn').onclick = () => this.transformControls.setMode('rotate');
document.getElementById('scaleBtn').onclick = () => this.transformControls.setMode('scale');
document.getElementById('noneBtn').onclick = () => this.transformControls.detach();
}
addEventListeners() {
window.addEventListener('resize', this.onResize.bind(this));
window.addEventListener('mousemove', this.onMouseMove.bind(this));
this.renderer.domElement.addEventListener('click', this.onClick.bind(this));
this.renderer.domElement.addEventListener('dragover', (e) => e.preventDefault());
this.renderer.domElement.addEventListener('drop', this.onDropPrimitive.bind(this));
window.addEventListener('resize', this.onResize.bind(this));
window.addEventListener('mousemove', this.onMouseMove.bind(this));
this.renderer.domElement.addEventListener('click', this.onClick.bind(this));
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
//this.renderer.setSize(window.innerWidth, window.innerHeight);
const canvas = document.getElementById("threeCanvas");
const rightPanel = document.getElementById("rightPanel");
const pixelRatio = window.devicePixelRatio || 1;
const width = canvas.clientWidth;
const height = canvas.clientHeight;
console.log(canvas, rightPanel, width-rightPanel.clientWidth);
this.renderer.setSize( width-rightPanel.clientWidth, height, false );
this.renderer.setPixelRatio( pixelRatio );
if (this.composer) {
this.composer.setSize(width-rightPanel.clientWidth, height);
this.ssaoPass.setSize(width-rightPanel.clientWidth, height);
}
if (this.composer) this.composer.setSize(window.innerWidth, window.innerHeight);
}
onMouseMove(event) {
const canvas = this.renderer.domElement;
const rect = canvas.getBoundingClientRect();
this.mouse.x = ( ( event.clientX - rect.left ) / rect.width ) * 2 - 1;
this.mouse.y = - ( ( event.clientY - rect.top ) / rect.height ) * 2 + 1;
this.raycaster.setFromCamera( this.mouse, this.camera );
this.pointLight.position.x = this.mouse.x * 3;
this.pointLight.position.y = this.mouse.y * 1 + 1.5;
}
onClick(event) {
const canvas = this.renderer.domElement;
const rect = canvas.getBoundingClientRect();
this.mouse.x = ( ( event.clientX - rect.left ) / rect.width ) * 2 - 1;
this.mouse.y = - ( ( event.clientY - rect.top ) / rect.height ) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.selectable, true);
if (intersects.length > 0) {
this.selectedObject = intersects[0].object;
this.transformControls.attach(this.selectedObject);
}
}
addRoom() {
const textureLoader = new THREE.TextureLoader();
const material = 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(16, 14, 16), material);
room.receiveShadow = true;
this.scene.add(room);
}
addBigBox() {
const boxMaterial = new THREE.MeshStandardMaterial({
color: 'rgb(34,139,34)',
metalness: 0.2,
roughness: 0.7
});
const box = new THREE.Mesh(new THREE.BoxGeometry(2, 2, 2), boxMaterial);
box.position.set(1.5, 0, 0);
box.castShadow = true;
box.receiveShadow = true;
this.selectable.push(box);
this.scene.add(box);
}
animate() {
requestAnimationFrame(this.animate.bind(this));
this.controls.update();
if (this.composer) {
this.composer.render();
} else {
this.renderer.render(this.scene, this.camera);
}
if (this.previewRenderer && this.previewScene && this.previewCamera) {
this.previewRenderer.render(this.previewScene, this.previewCamera);
}
}
}
new TextureEditorApp();