Files
2026-11-07 21:56:15 +05:00

606 lines
27 KiB
JavaScript

import { is3DFormat, getFilenameFromUrl } from "./utils.js";
import { SidebarOverlay } from "./overlay.js";
import { t } from "../core/i18n.js";
let cachedThreeLibs = null;
async function getThreeLibs() {
if (cachedThreeLibs) return cachedThreeLibs;
// Check if Three.js was already loaded on window by another ComfyUI 3D node
if (window.THREE) {
const OrbitControls = window.THREE.OrbitControls || window.OrbitControls;
const GLTFLoader = window.THREE.GLTFLoader || window.GLTFLoader;
if (OrbitControls && GLTFLoader) {
cachedThreeLibs = {
...window.THREE,
OrbitControls,
GLTFLoader,
OBJLoader: window.THREE.OBJLoader || window.OBJLoader,
STLLoader: window.THREE.STLLoader || window.STLLoader,
PLYLoader: window.THREE.PLYLoader || window.PLYLoader
};
return cachedThreeLibs;
}
}
const loadFromBase = async (base) => {
const [three, controls, gltf, obj, stl, ply] = await Promise.all([
import(`${base}three@0.170.0`),
import(`${base}three@0.170.0/examples/jsm/controls/OrbitControls.js`),
import(`${base}three@0.170.0/examples/jsm/loaders/GLTFLoader.js`),
import(`${base}three@0.170.0/examples/jsm/loaders/OBJLoader.js`).catch(() => null),
import(`${base}three@0.170.0/examples/jsm/loaders/STLLoader.js`).catch(() => null),
import(`${base}three@0.170.0/examples/jsm/loaders/PLYLoader.js`).catch(() => null)
]);
return { three, controls, gltf, obj, stl, ply };
};
try {
const base = "https://esm.sh/";
window.__COMFY_SIDEBAR_THREE_BASE = base;
const { three, controls, gltf, obj, stl, ply } = await loadFromBase(base);
cachedThreeLibs = {
...three,
OrbitControls: controls.OrbitControls || controls.default || controls,
GLTFLoader: gltf.GLTFLoader || gltf.default || gltf,
OBJLoader: obj?.OBJLoader || obj?.default || obj,
STLLoader: stl?.STLLoader || stl?.default || stl,
PLYLoader: ply?.PLYLoader || ply?.default || ply
};
return cachedThreeLibs;
} catch (err) {
console.error("Comfy Sidebar: Failed to load Three.js libraries:", err);
return null;
}
}
export function create3DViewer(baseSrc, onSwitchMedia = () => {}, onDestroy = () => {}, onUserClose = () => {}) {
const overlay = new SidebarOverlay({
className: "comfy-sidebar-3d-overlay-root",
onDestroy,
onUserClose
});
const header = document.createElement("div");
Object.assign(header.style, {
position: "absolute", top: "16px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "16px", zIndex: "30",
color: "var(--muted-foreground, var(--desc-color, #aaa))",
fontSize: "12px",
fontFamily: "monospace", pointerEvents: "none",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 90%, transparent)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(8px)",
boxShadow: "0 2px 8px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center"
});
const titleText = document.createElement("span");
titleText.textContent = getFilenameFromUrl(baseSrc) || t("3D Model");
header.appendChild(titleText);
overlay.container.appendChild(header);
const canvasContainer = document.createElement("div");
Object.assign(canvasContainer.style, {
width: "100%", height: "100%", position: "relative",
display: "flex", alignItems: "center", justifyContent: "center"
});
overlay.container.appendChild(canvasContainer);
const loadingSpinner = document.createElement("div");
Object.assign(loadingSpinner.style, {
position: "absolute", top: "50%", left: "50%", transform: "translate(-50%, -50%)",
color: "var(--primary, var(--p-primary-color, #ea580c))",
fontSize: "14px", fontFamily: "sans-serif", zIndex: "20",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 92%, transparent)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
padding: "10px 18px", borderRadius: "8px",
display: "flex", alignItems: "center", gap: "10px",
boxShadow: "0 8px 24px rgba(0,0,0,0.5)"
});
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> ${t("Loading 3D Asset...")}`;
overlay.container.appendChild(loadingSpinner);
const settingsPanel = document.createElement("div");
Object.assign(settingsPanel.style, {
position: "absolute", top: "60px", right: "20px", width: "230px",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 94%, transparent)",
backdropFilter: "blur(12px)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
borderRadius: "8px",
padding: "14px", zIndex: "35",
color: "var(--foreground, #e2e8f0)",
fontSize: "11px",
boxShadow: "0 8px 24px rgba(0,0,0,0.6)", display: "none", flexDirection: "column", gap: "12px"
});
const createFormGroup = (label, inputEl) => {
const row = document.createElement("div");
Object.assign(row.style, { display: "flex", flexDirection: "column", gap: "4px" });
const lbl = document.createElement("label");
lbl.textContent = label;
Object.assign(lbl.style, { color: "var(--muted-foreground, #94a3b8)", fontSize: "10px", textTransform: "uppercase", fontWeight: "bold" });
row.append(lbl, inputEl);
return row;
};
const createSelect = (options, value, onChange) => {
const sel = document.createElement("select");
Object.assign(sel.style, {
background: "var(--secondary, #252530)",
color: "var(--foreground, #eee)",
border: "1px solid var(--border, var(--border-color, #444))",
borderRadius: "4px", padding: "4px 6px", fontSize: "11px", outline: "none"
});
options.forEach(opt => {
const o = document.createElement("option");
o.value = opt.value;
o.textContent = opt.label;
if (opt.value === value) o.selected = true;
sel.appendChild(o);
});
sel.onchange = (e) => onChange(e.target.value);
return sel;
};
const toolbar = document.createElement("div");
Object.assign(toolbar.style, {
position: "absolute", bottom: "20px", left: "50%", transform: "translateX(-50%)",
display: "flex", gap: "8px", zIndex: "30",
background: "color-mix(in srgb, var(--card, var(--secondary, #181818)) 92%, transparent)",
padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(8px)",
boxShadow: "0 4px 12px rgba(0,0,0,0.6)",
border: "1px solid var(--border, var(--border-color, rgba(255, 255, 255, 0.15)))",
alignItems: "center"
});
const createToolbarBtn = (iconClass, label, activeDefault, onClick) => {
const btn = document.createElement("button");
let active = activeDefault;
btn.className = "comfy-sidebar-3d-tool-btn";
const updateBtnTheme = () => {
btn.style.background = active ? "var(--primary, var(--p-primary-color, #ea580c))" : "transparent";
btn.style.color = active ? "#ffffff" : "var(--muted-foreground, #aaa)";
btn.style.borderColor = active ? "var(--primary, var(--p-primary-color, #ea580c))" : "var(--border, var(--border-color, #444))";
};
Object.assign(btn.style, {
borderRadius: "4px", padding: "4px 10px", fontSize: "11px",
cursor: "pointer", display: "flex", alignItems: "center", gap: "6px",
transition: "all 0.15s ease", border: "1px solid"
});
updateBtnTheme();
btn.innerHTML = `<i class="${iconClass}"></i><span>${label}</span>`;
btn.onclick = (e) => {
e.stopPropagation();
active = !active;
updateBtnTheme();
onClick(active);
};
return btn;
};
let renderer, scene, camera, cameraPersp, cameraOrtho, controls, currentModel, gridHelper, animId;
let ambientLight, dirLight1, dirLight2;
let autoRotate = false, showWireframe = false, currentMaterialMode = "original", currentUpDirection = "y-up";
let lightMultiplier = 1.0;
let THREE = null;
const applyMaterialMode = () => {
if (!currentModel || !THREE) return;
currentModel.traverse((child) => {
if (child.isMesh) {
if (currentMaterialMode === "clay") {
child.material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.6, metalness: 0.1 });
} else if (currentMaterialMode === "normal") {
child.material = new THREE.MeshNormalMaterial();
} else {
child.material = child._originalMaterial || child.material;
}
if (Array.isArray(child.material)) {
child.material.forEach(m => m.wireframe = showWireframe);
} else if (child.material) {
child.material.wireframe = showWireframe;
}
}
});
};
const applyUpDirection = () => {
if (!currentModel || !THREE) return;
if (currentUpDirection === "z-up") {
currentModel.rotation.x = -Math.PI / 2;
currentModel.rotation.y = 0;
currentModel.rotation.z = 0;
} else if (currentUpDirection === "z-down") {
currentModel.rotation.x = Math.PI / 2;
currentModel.rotation.y = 0;
currentModel.rotation.z = 0;
} else {
currentModel.rotation.x = 0;
currentModel.rotation.y = 0;
currentModel.rotation.z = 0;
}
if (controls && camera) {
fitCameraToObject(camera, currentModel, controls, THREE);
if (gridHelper) gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
}
};
const downloadBlob = (blob, filename) => {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
};
const exportModel = async (format) => {
if (!currentModel) return;
const baseName = (titleText.textContent || "model").replace(/\.[^/.]+$/, "");
try {
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner"></span> ${t("Exporting {format}...", { format })}`;
const base = window.__COMFY_SIDEBAR_THREE_BASE || "https://esm.sh/";
if (format === "OBJ") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/OBJExporter.js`);
const OBJExporter = mod.OBJExporter || mod.default || mod;
const exporter = new OBJExporter();
const result = exporter.parse(currentModel);
const blob = new Blob([result], { type: "text/plain" });
downloadBlob(blob, `${baseName}.obj`);
} else if (format === "GLB") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/GLTFExporter.js`);
const GLTFExporter = mod.GLTFExporter || mod.default || mod;
const exporter = new GLTFExporter();
exporter.parse(currentModel, (gltf) => {
const blob = new Blob([gltf], { type: "application/octet-stream" });
downloadBlob(blob, `${baseName}.glb`);
}, (err) => { console.error(err); }, { binary: true });
} else if (format === "STL") {
const mod = await import(`${base}three@0.170.0/examples/jsm/exporters/STLExporter.js`);
const STLExporter = mod.STLExporter || mod.default || mod;
const exporter = new STLExporter();
const result = exporter.parse(currentModel, { binary: true });
const blob = new Blob([result], { type: "application/octet-stream" });
downloadBlob(blob, `${baseName}.stl`);
}
} catch (err) {
console.error("Comfy Sidebar: Export failed", err);
} finally {
loadingSpinner.style.display = "none";
}
};
const cleanup3D = () => {
if (animId) cancelAnimationFrame(animId);
if (controls) controls.dispose();
if (currentModel && scene) scene.remove(currentModel);
if (renderer) {
renderer.dispose();
renderer.domElement.remove();
}
};
overlay.addCleanup(cleanup3D);
const initSceneAndLoad = async (srcUrl) => {
loadingSpinner.style.display = "flex";
loadingSpinner.innerHTML = `<span class="pi pi-spin pi-spinner" style="font-size: 18px;"></span> ${t("Loading 3D Asset...")}`;
titleText.textContent = getFilenameFromUrl(srcUrl) || t("3D Model");
THREE = await getThreeLibs();
if (!THREE) {
loadingSpinner.innerHTML = `
<div style="text-align: center; max-width: 340px; line-height: 1.4; padding: 6px;">
<span style="color: #f87171; font-weight: bold; font-size: 13px;">${t("Failed to load 3D engine.")}</span><br>
<span style="font-size: 11px; color: #cbd5e1; display: inline-block; margin-top: 4px;">
${t("Unable to download 3D libraries. Make sure your computer is online, or check if <code>--disable-api-nodes</code> is blocking external network requests.")}
</span>
</div>
`;
return;
}
if (!renderer) {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x0e0e12);
const aspect = canvasContainer.clientWidth / canvasContainer.clientHeight;
cameraPersp = new THREE.PerspectiveCamera(45, aspect, 0.01, 1000);
cameraPersp.position.set(0, 1.5, 3.5);
const d = 2;
cameraOrtho = new THREE.OrthographicCamera(-d * aspect, d * aspect, d, -d, 0.01, 1000);
cameraOrtho.position.set(0, 1.5, 3.5);
camera = cameraPersp;
renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(canvasContainer.clientWidth, canvasContainer.clientHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.0;
canvasContainer.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
ambientLight = new THREE.AmbientLight(0xffffff, 1.2 * lightMultiplier);
scene.add(ambientLight);
dirLight1 = new THREE.DirectionalLight(0xffffff, 1.8 * lightMultiplier);
dirLight1.position.set(5, 10, 7);
scene.add(dirLight1);
dirLight2 = new THREE.DirectionalLight(0x90b0ff, 0.8 * lightMultiplier);
dirLight2.position.set(-5, -5, -5);
scene.add(dirLight2);
let gridPrimaryColor = 0xea580c;
try {
const tempDiv = document.createElement("div");
tempDiv.style.color = "var(--primary, var(--p-primary-color, #ea580c))";
document.body.appendChild(tempDiv);
const rgb = getComputedStyle(tempDiv).color.match(/\d+/g);
tempDiv.remove();
if (rgb && rgb.length >= 3) {
gridPrimaryColor = (Number(rgb[0]) << 16) + (Number(rgb[1]) << 8) + Number(rgb[2]);
}
} catch (e) {}
gridHelper = new THREE.GridHelper(10, 20, gridPrimaryColor, 0x22222a);
gridHelper.position.y = 0;
scene.add(gridHelper);
const bgColorInput = document.createElement("input");
bgColorInput.type = "color";
bgColorInput.value = "#0e0e12";
Object.assign(bgColorInput.style, { width: "100%", height: "26px", borderRadius: "4px", border: "none", cursor: "pointer" });
bgColorInput.oninput = (e) => { scene.background.set(e.target.value); };
const upDirSelect = createSelect([
{ value: "y-up", label: t("Original (Y-Up)") },
{ value: "z-up", label: t("Z-Up (+90°)") },
{ value: "z-down", label: t("Inverted (-90°)") }
], currentUpDirection, (val) => {
currentUpDirection = val;
applyUpDirection();
});
const matModeSelect = createSelect([
{ value: "original", label: t("Original / Textured") },
{ value: "clay", label: t("Clay / Studio Matte") },
{ value: "normal", label: t("Normals") }
], currentMaterialMode, (val) => {
currentMaterialMode = val;
applyMaterialMode();
});
const camTypeSelect = createSelect([
{ value: "perspective", label: t("Perspective") },
{ value: "orthographic", label: t("Orthographic") }
], "perspective", (val) => {
const prevPos = camera.position.clone();
const prevTarget = controls.target.clone();
if (val === "orthographic") camera = cameraOrtho;
else camera = cameraPersp;
camera.position.copy(prevPos);
controls.object = camera;
controls.target.copy(prevTarget);
controls.update();
});
const fovContainer = document.createElement("div");
Object.assign(fovContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
const fovSlider = document.createElement("input");
Object.assign(fovSlider, { type: "range", min: "15", max: "120", value: "45" });
fovSlider.style.flex = "1";
const fovVal = document.createElement("span");
fovVal.textContent = "45°";
fovSlider.oninput = (e) => {
const v = Number(e.target.value);
fovVal.textContent = `${v}°`;
cameraPersp.fov = v;
cameraPersp.updateProjectionMatrix();
};
fovContainer.append(fovSlider, fovVal);
const lightContainer = document.createElement("div");
Object.assign(lightContainer.style, { display: "flex", alignItems: "center", gap: "8px" });
const lightSlider = document.createElement("input");
Object.assign(lightSlider, { type: "range", min: "0.1", max: "3.0", step: "0.1", value: "1.0" });
lightSlider.style.flex = "1";
const lightVal = document.createElement("span");
lightVal.textContent = "1.0x";
lightSlider.oninput = (e) => {
lightMultiplier = Number(e.target.value);
lightVal.textContent = `${lightMultiplier.toFixed(1)}x`;
ambientLight.intensity = 1.2 * lightMultiplier;
dirLight1.intensity = 1.8 * lightMultiplier;
dirLight2.intensity = 0.8 * lightMultiplier;
};
lightContainer.append(lightSlider, lightVal);
settingsPanel.append(
createFormGroup(t("Background Color"), bgColorInput),
createFormGroup(t("Up Direction"), upDirSelect),
createFormGroup(t("Material Mode"), matModeSelect),
createFormGroup(t("Camera Type"), camTypeSelect),
createFormGroup(t("FOV"), fovContainer),
createFormGroup(t("Light Intensity"), lightContainer)
);
overlay.container.appendChild(settingsPanel);
const btnGrid = createToolbarBtn("pi pi-table", t("Grid"), true, (v) => { gridHelper.visible = v; });
const btnRotate = createToolbarBtn("pi pi-sync", t("Auto-Rotate"), false, (v) => { autoRotate = v; });
const btnWire = createToolbarBtn("pi pi-box", t("Wireframe"), false, (v) => {
showWireframe = v;
applyMaterialMode();
});
const btnResetCam = document.createElement("button");
Object.assign(btnResetCam.style, {
background: "transparent",
color: "var(--muted-foreground, #aaa)",
border: "1px solid var(--border, var(--border-color, #444))",
borderRadius: "4px", padding: "4px 10px", fontSize: "11px", cursor: "pointer",
display: "flex", alignItems: "center", gap: "6px", transition: "all 0.15s ease"
});
btnResetCam.innerHTML = `<i class="pi pi-compass"></i><span>${t("Reset View")}</span>`;
btnResetCam.onmouseenter = () => {
btnResetCam.style.borderColor = "var(--primary, var(--border-color, #fff))";
btnResetCam.style.color = "var(--foreground, #fff)";
};
btnResetCam.onmouseleave = () => {
btnResetCam.style.borderColor = "var(--border, var(--border-color, #444))";
btnResetCam.style.color = "var(--muted-foreground, #aaa)";
};
btnResetCam.onclick = (e) => {
e.stopPropagation();
if (currentModel) fitCameraToObject(camera, currentModel, controls, THREE);
};
const btnSettings = createToolbarBtn("pi pi-sliders-h", t("Settings"), false, (v) => {
settingsPanel.style.display = v ? "flex" : "none";
});
const exportSelect = createSelect([
{ value: "", label: t("Export As...") },
{ value: "OBJ", label: "OBJ (.obj)" },
{ value: "GLB", label: "GLB / GLTF" },
{ value: "STL", label: "STL (.stl)" }
], "", (val) => {
if (val) {
exportModel(val);
exportSelect.value = "";
}
});
exportSelect.style.background = "var(--secondary, var(--card, #1e293b))";
exportSelect.style.borderColor = "var(--primary, var(--p-primary-color, #ea580c))";
exportSelect.style.color = "var(--foreground, #fff)";
toolbar.append(btnGrid, btnWire, btnRotate, btnResetCam, btnSettings, exportSelect);
overlay.container.appendChild(toolbar);
const onResize = () => {
if (!canvasContainer || !renderer || !cameraPersp || !cameraOrtho) return;
const width = canvasContainer.clientWidth;
const height = canvasContainer.clientHeight;
const aspect = width / height;
cameraPersp.aspect = aspect;
cameraPersp.updateProjectionMatrix();
const d = 2;
cameraOrtho.left = -d * aspect;
cameraOrtho.right = d * aspect;
cameraOrtho.top = d;
cameraOrtho.bottom = -d;
cameraOrtho.updateProjectionMatrix();
renderer.setSize(width, height);
};
window.addEventListener("resize", onResize);
overlay.addCleanup(() => window.removeEventListener("resize", onResize));
const animate = () => {
animId = requestAnimationFrame(animate);
if (autoRotate && currentModel) currentModel.rotation.y += 0.008;
controls.update();
renderer.render(scene, camera);
};
animate();
}
if (currentModel) {
scene.remove(currentModel);
currentModel = null;
}
try {
const filename = getFilenameFromUrl(srcUrl);
const ext = (filename.split(".").pop() || "glb").toLowerCase();
let loadedObj = null;
if (ext === "obj" && THREE.OBJLoader) {
const loader = new THREE.OBJLoader();
loadedObj = await loader.loadAsync(srcUrl);
} else if (ext === "stl" && THREE.STLLoader) {
const loader = new THREE.STLLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.4, metalness: 0.2 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (ext === "ply" && THREE.PLYLoader) {
const loader = new THREE.PLYLoader();
const geometry = await loader.loadAsync(srcUrl);
const material = new THREE.MeshStandardMaterial({ color: 0xd1d5db, roughness: 0.4 });
loadedObj = new THREE.Mesh(geometry, material);
} else if (THREE.GLTFLoader) {
const loader = new THREE.GLTFLoader();
const gltfData = await loader.loadAsync(srcUrl);
loadedObj = gltfData.scene || gltfData.scenes?.[0];
}
if (loadedObj) {
currentModel = loadedObj;
currentModel.traverse((child) => {
if (child.isMesh && child.material) child._originalMaterial = child.material;
});
applyMaterialMode();
applyUpDirection();
scene.add(currentModel);
fitCameraToObject(camera, currentModel, controls, THREE);
gridHelper.position.y = new THREE.Box3().setFromObject(currentModel).min.y;
}
} catch (err) {
console.error("Comfy Sidebar: Failed to parse 3D model", err);
loadingSpinner.innerHTML = `<span style="color:#f87171;">${t("Failed to load 3D format")}</span>`;
return;
}
loadingSpinner.style.display = "none";
};
function fitCameraToObject(cam, obj, ctrl, THREE) {
const box = new THREE.Box3().setFromObject(obj);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
if (cam.isPerspectiveCamera) {
const fov = cam.fov * (Math.PI / 180);
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;
cam.position.set(center.x, center.y + size.y * 0.2, center.z + cameraZ);
} else if (cam.isOrthographicCamera) {
cam.position.set(center.x, center.y + size.y * 0.2, center.z + maxDim * 2);
}
cam.lookAt(center);
ctrl.target.copy(center);
ctrl.update();
}
initSceneAndLoad(baseSrc);
return {
is3D: true,
loadTarget(targetSrc) {
if (!is3DFormat(targetSrc)) {
overlay.destroy();
onSwitchMedia(targetSrc);
return;
}
initSceneAndLoad(targetSrc);
},
destroy: () => overlay.destroy()
};
}