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();