commit 351f77f16628ffec57dd11f1b3b0c5398c3466d1 Author: kaj dijkstra Date: Thu Dec 25 10:21:22 2025 +0100 First Commit. diff --git a/index.html b/index.html new file mode 100644 index 0000000..28b2bfc --- /dev/null +++ b/index.html @@ -0,0 +1,584 @@ + + + + + Three.js Texture Editor + + + +
+

Materials

+ + + +
+ +
+ Selected Material Properties
+
+
+
+
+
+ + Upload Textures
+
+
+
+
+
+
+ + + + + + + +
+ + + +
+ + + diff --git a/index2.html b/index2.html new file mode 100644 index 0000000..75944f9 --- /dev/null +++ b/index2.html @@ -0,0 +1,265 @@ + + + + + Three.js Texture Editor + + + + +
+ + + + +
+ +
+
+
+
Primitives
+
Cube
+
Sphere
+
Cylinder
+
+
+ + + +
+

PBR Material Panel

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ +
+
+ + + + diff --git a/material-editor.js b/material-editor.js new file mode 100644 index 0000000..45e191a --- /dev/null +++ b/material-editor.js @@ -0,0 +1,1276 @@ + 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(); \ No newline at end of file diff --git a/tiles/Tiles133A.png b/tiles/Tiles133A.png new file mode 100644 index 0000000..725d895 Binary files /dev/null and b/tiles/Tiles133A.png differ diff --git a/tiles/Tiles133A_1K-JPG.mtlx b/tiles/Tiles133A_1K-JPG.mtlx new file mode 100644 index 0000000..98a25a9 --- /dev/null +++ b/tiles/Tiles133A_1K-JPG.mtlx @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tiles/Tiles133A_1K-JPG.usdc b/tiles/Tiles133A_1K-JPG.usdc new file mode 100644 index 0000000..72ec1ae Binary files /dev/null and b/tiles/Tiles133A_1K-JPG.usdc differ diff --git a/tiles/Tiles133A_1K-JPG_AmbientOcclusion.jpg b/tiles/Tiles133A_1K-JPG_AmbientOcclusion.jpg new file mode 100644 index 0000000..b14dbbd Binary files /dev/null and b/tiles/Tiles133A_1K-JPG_AmbientOcclusion.jpg differ diff --git a/tiles/Tiles133A_1K-JPG_Color.jpg b/tiles/Tiles133A_1K-JPG_Color.jpg new file mode 100644 index 0000000..edc4022 Binary files /dev/null and b/tiles/Tiles133A_1K-JPG_Color.jpg differ diff --git a/tiles/Tiles133A_1K-JPG_Displacement.jpg b/tiles/Tiles133A_1K-JPG_Displacement.jpg new file mode 100644 index 0000000..f1de2d8 Binary files /dev/null and b/tiles/Tiles133A_1K-JPG_Displacement.jpg differ diff --git a/tiles/Tiles133A_1K-JPG_NormalDX.jpg b/tiles/Tiles133A_1K-JPG_NormalDX.jpg new file mode 100644 index 0000000..c06a08b Binary files /dev/null and b/tiles/Tiles133A_1K-JPG_NormalDX.jpg differ diff --git a/tiles/Tiles133A_1K-JPG_NormalGL.jpg b/tiles/Tiles133A_1K-JPG_NormalGL.jpg new file mode 100644 index 0000000..9e79eb0 Binary files /dev/null and b/tiles/Tiles133A_1K-JPG_NormalGL.jpg differ diff --git a/tiles/Tiles133A_1K-JPG_Roughness.jpg b/tiles/Tiles133A_1K-JPG_Roughness.jpg new file mode 100644 index 0000000..9a75ad0 Binary files /dev/null and b/tiles/Tiles133A_1K-JPG_Roughness.jpg differ