import { is3DFormat, getFilenameFromUrl } from "./utils.js"; import { SidebarOverlay } from "./overlay.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 { let loaded = null; // 1. Try local Python proxy on 'self' (bypasses CSP & --disable-api-nodes) try { loaded = await loadFromBase("/comfy-sidebar/three-proxy/"); window.__COMFY_SIDEBAR_THREE_BASE = "/comfy-sidebar/three-proxy/"; } catch (proxyErr) { // 2. Fall back to direct CDN if proxy is unreachable console.warn("Comfy Sidebar: Proxy unavailable, trying direct CDN...", proxyErr); loaded = await loadFromBase("https://esm.sh/"); window.__COMFY_SIDEBAR_THREE_BASE = "https://esm.sh/"; } const { three, controls, gltf, obj, stl, ply } = loaded; 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 = () => {}) { const overlay = new SidebarOverlay({ className: "comfy-sidebar-3d-overlay-root", onDestroy }); 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: "#aaa", fontSize: "12px", fontFamily: "monospace", pointerEvents: "none", background: "rgba(10,10,10,0.75)", padding: "6px 14px", borderRadius: "6px", backdropFilter: "blur(4px)", boxShadow: "0 2px 6px rgba(0,0,0,0.4)", maxWidth: "85%", textAlign: "center" }); const titleText = document.createElement("span"); titleText.textContent = getFilenameFromUrl(baseSrc) || "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: "#60a5fa", fontSize: "14px", fontFamily: "sans-serif", zIndex: "20", background: "rgba(0,0,0,0.8)", padding: "10px 18px", borderRadius: "8px", display: "flex", alignItems: "center", gap: "10px" }); loadingSpinner.innerHTML = ` 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: "rgba(18, 18, 24, 0.9)", backdropFilter: "blur(8px)", border: "1px solid rgba(255, 255, 255, 0.12)", borderRadius: "8px", padding: "14px", zIndex: "35", color: "#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: "#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: "#252530", color: "#eee", border: "1px solid #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: "rgba(20,20,25,0.85)", padding: "6px 12px", borderRadius: "8px", backdropFilter: "blur(6px)", boxShadow: "0 4px 12px rgba(0,0,0,0.6)", border: "1px solid rgba(255,255,255,0.1)", alignItems: "center" }); const createToolbarBtn = (iconClass, label, activeDefault, onClick) => { const btn = document.createElement("button"); let active = activeDefault; btn.className = "comfy-sidebar-3d-tool-btn"; Object.assign(btn.style, { background: active ? "#3b82f6" : "transparent", color: active ? "#fff" : "#aaa", border: "1px solid " + (active ? "#3b82f6" : "#444"), borderRadius: "4px", padding: "4px 10px", fontSize: "11px", cursor: "pointer", display: "flex", alignItems: "center", gap: "6px", transition: "all 0.15s ease" }); btn.innerHTML = `${label}`; btn.onclick = (e) => { e.stopPropagation(); active = !active; btn.style.background = active ? "#3b82f6" : "transparent"; btn.style.color = active ? "#fff" : "#aaa"; btn.style.borderColor = active ? "#3b82f6" : "#444"; 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 = ` Exporting ${format}...`; const base = window.__COMFY_SIDEBAR_THREE_BASE || "/comfy-sidebar/three-proxy/"; 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 = ` Loading 3D Asset...`; titleText.textContent = getFilenameFromUrl(srcUrl) || "3D Model"; THREE = await getThreeLibs(); if (!THREE) { loadingSpinner.innerHTML = `
--disable-api-nodes is enabled in your startup arguments, or vendor Three.js locally.