606 lines
27 KiB
JavaScript
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()
|
|
};
|
|
} |