Files
gokayfem-ComfyUI-Texture-Si…/web/js/threeVisualizer.mjs
T
2026-07-28 18:42:52 +03:00

516 lines
16 KiB
JavaScript

import * as THREE from "three";
import { OrbitControls } from "../vendor/OrbitControls.mjs";
import { GLTFLoader } from "../vendor/GLTFLoader.mjs";
import { OBJLoader } from "../vendor/OBJLoader.mjs";
import { GLTFExporter } from "../vendor/GLTFExporter.mjs";
import { OBJExporter } from "../vendor/OBJExporter.mjs";
const SOURCE = "gokayfem.texture-simple";
const MAP_NAMES = [
"color",
"displacement",
"normal",
"ao",
"metalness",
"roughness",
"alpha",
];
const MATERIAL_SLOTS = {
color: "map",
displacement: "displacementMap",
normal: "normalMap",
ao: "aoMap",
metalness: "metalnessMap",
roughness: "roughnessMap",
alpha: "alphaMap",
};
const container = document.querySelector("#canvas-container");
const statusElement = document.querySelector("#status");
const errorElement = document.querySelector("#error");
const batchSelect = document.querySelector("#batch-select");
const meshSelect = document.querySelector("#mesh-select");
const meshFile = document.querySelector("#mesh-file");
const materialPanel = document.querySelector("#material-panel");
const renderer = new THREE.WebGLRenderer({
antialias: true,
preserveDrawingBuffer: true,
powerPreference: "high-performance",
});
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
container.append(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x252a31);
const camera = new THREE.PerspectiveCamera(42, 1, 0.01, 5000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.screenSpacePanning = true;
scene.add(new THREE.HemisphereLight(0xffffff, 0x263044, 2.4));
const keyLight = new THREE.DirectionalLight(0xffffff, 3);
keyLight.position.set(4, 7, 6);
keyLight.castShadow = true;
scene.add(keyLight);
const fillLight = new THREE.DirectionalLight(0x89a8ff, 1.2);
fillLight.position.set(-5, 2, -4);
scene.add(fillLight);
const previewMaterial = new THREE.MeshPhysicalMaterial({
color: 0xffffff,
roughness: 0.65,
metalness: 0,
side: THREE.DoubleSide,
});
previewMaterial.normalScale.set(1, 1);
let channel = null;
let viewUrl = null;
let output = Object.fromEntries(MAP_NAMES.map((name) => [name, []]));
let previewRoot = null;
let fittedBox = null;
let updateVersion = 0;
let animationFrame = null;
let disposed = false;
let textures = new Map();
const clock = new THREE.Clock();
function setStatus(message) {
statusElement.textContent = message;
statusElement.hidden = false;
errorElement.hidden = true;
}
function setError(error) {
console.error("[Texture Viewer]", error);
errorElement.textContent = error instanceof Error ? error.message : String(error);
errorElement.hidden = false;
statusElement.hidden = true;
}
function resize() {
const width = Math.max(container.clientWidth, 1);
const height = Math.max(container.clientHeight, 1);
renderer.setSize(width, height, false);
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
new ResizeObserver(resize).observe(container);
resize();
function prepareGeometry(geometry) {
if (geometry.attributes.uv && !geometry.attributes.uv1) {
geometry.setAttribute("uv1", geometry.attributes.uv.clone());
}
geometry.computeBoundingBox();
geometry.computeBoundingSphere();
return geometry;
}
function disposeOriginalMaterial(material) {
if (!material || material === previewMaterial) {
return;
}
for (const value of Object.values(material)) {
if (value?.isTexture) {
value.dispose();
}
}
material.dispose();
}
function applyPreviewMaterial(root) {
root.traverse((child) => {
if (!child.isMesh) {
return;
}
prepareGeometry(child.geometry);
const originals = Array.isArray(child.material)
? child.material
: [child.material];
originals.forEach(disposeOriginalMaterial);
child.material = previewMaterial;
child.castShadow = true;
child.receiveShadow = true;
});
}
function disposeRoot(root) {
root?.traverse((child) => {
child.geometry?.dispose();
if (Array.isArray(child.material)) {
child.material.forEach(disposeOriginalMaterial);
} else {
disposeOriginalMaterial(child.material);
}
});
}
function fitCamera() {
if (!previewRoot) {
return;
}
fittedBox = new THREE.Box3().setFromObject(previewRoot);
if (fittedBox.isEmpty()) {
return;
}
const sphere = fittedBox.getBoundingSphere(new THREE.Sphere());
const distance = Math.max(
sphere.radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)),
0.5,
);
camera.near = Math.max(distance / 1000, 0.001);
camera.far = Math.max(distance * 100, 100);
camera.updateProjectionMatrix();
camera.position.copy(
sphere.center.clone().add(new THREE.Vector3(0.75, 0.45, 1).normalize().multiplyScalar(distance * 1.25)),
);
controls.target.copy(sphere.center);
controls.minDistance = Math.max(sphere.radius * 0.05, 0.01);
controls.maxDistance = Math.max(sphere.radius * 20, 10);
controls.update();
}
function replacePreview(root) {
if (previewRoot) {
scene.remove(previewRoot);
disposeRoot(previewRoot);
}
previewRoot = root;
applyPreviewMaterial(previewRoot);
scene.add(previewRoot);
fitCamera();
}
function primitiveMesh(geometry) {
return new THREE.Mesh(prepareGeometry(geometry), previewMaterial);
}
function buildPrimitive(kind) {
const root = new THREE.Group();
if (kind === "cube") {
root.add(primitiveMesh(new THREE.BoxGeometry(3.5, 3.5, 3.5, 48, 48, 48)));
} else if (kind === "torus") {
root.add(primitiveMesh(new THREE.TorusGeometry(2.2, 0.8, 96, 192)));
} else if (kind === "plane") {
root.add(primitiveMesh(new THREE.PlaneGeometry(5, 5, 192, 192)));
} else if (kind === "showcase") {
const sphere = primitiveMesh(new THREE.SphereGeometry(1.7, 128, 96));
sphere.position.x = -4.2;
const cube = primitiveMesh(new THREE.BoxGeometry(2.8, 2.8, 2.8, 40, 40, 40));
const torus = primitiveMesh(new THREE.TorusGeometry(1.7, 0.65, 80, 160));
torus.position.x = 4.2;
root.add(sphere, cube, torus);
} else {
root.add(primitiveMesh(new THREE.SphereGeometry(2.4, 160, 112)));
}
replacePreview(root);
}
function imageUrl(descriptor) {
if (!viewUrl) {
throw new Error("The ComfyUI API URL has not been initialized.");
}
const url = new URL(viewUrl, window.location.origin);
url.search = new URLSearchParams({
filename: descriptor.filename,
subfolder: descriptor.subfolder ?? "",
type: descriptor.type ?? "temp",
}).toString();
return url.href;
}
function loadTexture(name, descriptor) {
return new Promise((resolve, reject) => {
new THREE.TextureLoader().load(
imageUrl(descriptor),
(texture) => {
texture.colorSpace = name === "color"
? THREE.SRGBColorSpace
: THREE.NoColorSpace;
texture.wrapS = THREE.RepeatWrapping;
texture.wrapT = THREE.RepeatWrapping;
texture.anisotropy = Math.min(
8,
renderer.capabilities.getMaxAnisotropy(),
);
resolve([name, texture]);
},
undefined,
() => reject(new Error(`Unable to load ${descriptor.filename}.`)),
);
});
}
function updateTextureRepeats() {
const repeatX = Number(document.querySelector("#repeat-x").value);
const repeatY = Number(document.querySelector("#repeat-y").value);
for (const texture of textures.values()) {
texture.repeat.set(repeatX, repeatY);
texture.needsUpdate = true;
}
}
function applyTextures(nextTextures) {
for (const texture of textures.values()) {
texture.dispose();
}
textures = nextTextures;
for (const [name, slot] of Object.entries(MATERIAL_SLOTS)) {
previewMaterial[slot] = textures.get(name) ?? null;
}
previewMaterial.transparent = textures.has("alpha");
previewMaterial.alphaTest = textures.has("alpha") ? 0.01 : 0;
previewMaterial.needsUpdate = true;
updateTextureRepeats();
}
async function showFrame(index) {
const requests = [];
for (const name of MAP_NAMES) {
const descriptors = output[name] ?? [];
const descriptor = descriptors[index] ?? descriptors[0];
if (descriptor) {
requests.push(loadTexture(name, descriptor));
}
}
const version = ++updateVersion;
if (!requests.length) {
applyTextures(new Map());
setStatus("No maps connected. Showing the base material.");
return;
}
setStatus(`Loading material frame ${index + 1}…`);
try {
const loaded = await Promise.all(requests);
if (version !== updateVersion || disposed) {
loaded.forEach(([, texture]) => texture.dispose());
return;
}
applyTextures(new Map(loaded));
statusElement.hidden = true;
} catch (error) {
if (version === updateVersion) {
setError(error);
}
}
}
function setOutput(nextOutput) {
output = Object.fromEntries(
MAP_NAMES.map((name) => [name, nextOutput?.[name] ?? []]),
);
const count = Math.max(
1,
...MAP_NAMES.map((name) => output[name].length),
);
batchSelect.replaceChildren();
for (let index = 0; index < count; index += 1) {
const option = document.createElement("option");
option.value = String(index);
option.textContent = `${index + 1} / ${count}`;
batchSelect.append(option);
}
batchSelect.disabled = count === 1;
batchSelect.value = "0";
void showFrame(0);
}
function ensureCustomOption() {
let option = meshSelect.querySelector('option[value="custom"]');
if (!option) {
option = document.createElement("option");
option.value = "custom";
option.textContent = "Custom mesh";
meshSelect.append(option);
}
meshSelect.value = "custom";
}
async function loadCustomMesh(file) {
const extension = file.name.split(".").pop()?.toLowerCase();
setStatus(`Loading ${file.name}…`);
let root;
if (extension === "glb") {
const buffer = await file.arrayBuffer();
const result = await new Promise((resolve, reject) => {
new GLTFLoader().parse(buffer, "", resolve, reject);
});
root = result.scene;
} else if (extension === "obj") {
root = new OBJLoader().parse(await file.text());
} else {
throw new Error("Choose a .glb or .obj mesh.");
}
ensureCustomOption();
replacePreview(root);
statusElement.hidden = true;
}
function updateMaterialSettings() {
previewMaterial.roughness = Number(document.querySelector("#roughness").value);
previewMaterial.metalness = Number(document.querySelector("#metalness").value);
previewMaterial.displacementScale = Number(
document.querySelector("#displacement").value,
);
const normalStrength = Number(
document.querySelector("#normal-strength").value,
);
previewMaterial.normalScale.set(normalStrength, normalStrength);
previewMaterial.aoMapIntensity = Number(
document.querySelector("#ao-strength").value,
);
updateTextureRepeats();
}
function download(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
async function exportMesh() {
if (!previewRoot) {
throw new Error("There is no mesh to export.");
}
const format = document.querySelector("#export-format").value;
if (format === "obj") {
const data = new OBJExporter().parse(previewRoot);
download(new Blob([data], { type: "text/plain" }), "texture-preview.obj");
return;
}
const binary = format === "glb";
const data = await new GLTFExporter().parseAsync(previewRoot, { binary });
const blob = binary
? new Blob([data], { type: "model/gltf-binary" })
: new Blob([JSON.stringify(data, null, 2)], {
type: "model/gltf+json",
});
download(blob, `texture-preview.${format}`);
}
function takeScreenshot() {
renderer.render(scene, camera);
renderer.domElement.toBlob((blob) => {
if (blob) {
download(blob, "texture-preview.png");
}
}, "image/png");
}
function animate() {
if (disposed) {
return;
}
animationFrame = requestAnimationFrame(animate);
const delta = Math.min(clock.getDelta(), 0.1);
if (
document.querySelector("#auto-rotate").checked
&& previewRoot
&& document.visibilityState === "visible"
) {
previewRoot.rotation.y += delta * 0.45;
}
if (document.visibilityState === "visible") {
controls.update();
renderer.render(scene, camera);
}
}
buildPrimitive("sphere");
animate();
batchSelect.addEventListener("change", () => {
void showFrame(Number(batchSelect.value));
});
meshSelect.addEventListener("change", () => {
if (meshSelect.value !== "custom") {
buildPrimitive(meshSelect.value);
}
});
document.querySelector("#load-mesh").addEventListener("click", () => {
meshFile.click();
});
meshFile.addEventListener("change", () => {
const [file] = meshFile.files;
if (file) {
void loadCustomMesh(file).catch(setError);
}
meshFile.value = "";
});
document.querySelector("#toggle-material").addEventListener("click", () => {
materialPanel.hidden = !materialPanel.hidden;
});
document.querySelector("#close-material").addEventListener("click", () => {
materialPanel.hidden = true;
});
document.querySelector("#reset-camera").addEventListener("click", fitCamera);
document.querySelector("#screenshot").addEventListener("click", takeScreenshot);
document.querySelector("#export-mesh").addEventListener("click", () => {
void exportMesh().catch(setError);
});
for (const selector of [
"#roughness",
"#metalness",
"#displacement",
"#normal-strength",
"#ao-strength",
"#repeat-x",
"#repeat-y",
]) {
document.querySelector(selector).addEventListener("input", updateMaterialSettings);
}
document.querySelector("#background").addEventListener("input", (event) => {
scene.background.set(event.target.value);
});
window.addEventListener("message", (event) => {
if (
event.origin !== window.location.origin
|| event.source !== window.parent
|| event.data?.source !== SOURCE
) {
return;
}
if (event.data.type === "connect") {
channel = event.data.channel;
window.parent.postMessage(
{ source: SOURCE, channel, type: "ready" },
window.location.origin,
);
return;
}
if (event.data.channel !== channel) {
return;
}
if (event.data.type === "initialize") {
viewUrl = event.data.viewUrl;
} else if (event.data.type === "update") {
setOutput(event.data.output);
} else if (event.data.type === "dispose") {
disposed = true;
updateVersion += 1;
cancelAnimationFrame(animationFrame);
for (const texture of textures.values()) {
texture.dispose();
}
textures.clear();
if (previewRoot) {
scene.remove(previewRoot);
disposeRoot(previewRoot);
}
previewMaterial.dispose();
controls.dispose();
renderer.dispose();
}
});