Files
MajoorWaldi-ComfyUI-Majoor-…/ui/features/viewer/model3dRenderer.impl.ts
T

1423 lines
56 KiB
TypeScript

import { buildViewerResourceURL } from "../../api/endpoints.js";
import { APP_CONFIG } from "../../app/config.js";
import { EVENTS } from "../../app/events.js";
import { t } from "../../app/i18n.js";
import {
applyModel3DMaterialMode as _applyMaterialMode,
buildRenderableModel3DObject as _buildRenderableObject,
computeModel3DObjectFrame as _computeObjectFrame,
disposeModel3DTempMaterials as _disposeTempMaterials,
findModel3DHasSkeleton as _findHasSkeleton,
fitModel3DOrthographicCamera as _fitOrthographicCamera,
fitModel3DPerspectiveCamera as _fitPerspectiveCamera,
getModel3DAnimations as _getModelAnimations,
hasModel3DMeshMaterials as _hasMeshMaterials,
storeModel3DOriginalMaterials as _storeOriginalMaterials,
} from "./model3dCore.js";
import {
createModel3DLoaderInstance as _createLoaderInstance,
loadModel3DContent as _loadModelContent,
loadModel3DThreeDeps as _loadThreeDeps,
} from "./model3dLoaders.js";
import {
createModel3DAnimationBar as _createAnimationBar,
createModel3DSettingsPanel as _createSettingsPanel,
createModel3DViewportButton as _createViewportButton,
drawModel3DMessage as _drawModelMessage,
formatModel3DAnimTime as _formatAnimTime,
setModel3DViewportButtonActive as _setViewportButtonActive,
stopModel3DEvent as _stopEvent,
} from "./model3dSupport.js";
export const MODEL3D_EXT_TO_LOADER = Object.freeze({
".gltf": "gltf",
".glb": "gltf",
".obj": "obj",
".fbx": "fbx",
".stl": "stl",
".ply": "ply",
".splat": "splat",
".ksplat": "splat",
".spz": "splat",
});
export const MODEL3D_EXTS = new Set(Object.keys(MODEL3D_EXT_TO_LOADER));
export const PREVIEWABLE_MODEL3D_LOADERS = new Set(["gltf", "obj", "fbx", "stl", "ply"]);
const MODEL3D_VIEWER_DEFAULT_BG = "#282828";
const MODEL3D_DEFAULT_CONTROL_HINT = t("model3d.controlHint", "Rotate: left drag Pan: right drag Zoom: wheel");
const MODEL3D_VIEW_MODES = Object.freeze({
PERSPECTIVE: "perspective",
ORTHOGRAPHIC: "orthographic",
});
const MODEL3D_MATERIAL_MODES = Object.freeze({
ORIGINAL: "original",
NORMAL: "normal",
DEPTH: "depth",
WIREFRAME: "wireframe",
POINTCLOUD: "pointcloud",
});
// Euler rotations [x, y, z] to apply to modelGroup so the specified axis points up
const MODEL3D_UP_DIR_EULER = Object.freeze({
original: [0, 0, 0],
"+y": [0, 0, 0],
"-y": [Math.PI, 0, 0],
"+z": [-Math.PI / 2, 0, 0],
"-z": [Math.PI / 2, 0, 0],
"+x": [0, 0, Math.PI / 2],
"-x": [0, 0, -Math.PI / 2],
});
const MODEL3D_ANIMATION_SPEEDS = [0.25, 0.5, 1.0, 1.5, 2.0];
const MODEL3D_ANIM_BAR_HEIGHT_PX = 44;
const MODEL3D_DEFAULT_FOV = 75;
const MODEL3D_DEFAULT_LIGHT_INTENSITY = 1.0;
const MODEL3D_LIGHT_SCALE = Object.freeze({
ambient: 0.5,
main: 0.8,
fill: 0.3,
back: 0.3,
rim: 0.3,
bottom: 0.2,
});
// --- Utility helpers -------------------------------------------------------
function _extOf(asset: any) {
try {
const ext = String(asset?.ext || "")
.trim()
.toLowerCase();
if (ext) return ext.startsWith(".") ? ext : `.${ext}`;
} catch (e: any) {
console.debug?.(e);
}
try {
const name = String(asset?.filename || asset?.filepath || asset?.path || "").trim();
const idx = name.lastIndexOf(".");
return idx >= 0 ? name.slice(idx).toLowerCase() : "";
} catch (e: any) {
console.debug?.(e);
}
return "";
}
function _isAbsoluteOrSpecialUrl(url: any) {
const value = String(url || "").trim();
if (!value) return false;
// Root-relative (/path), protocol-relative (//host), or scheme (http:, data:, blob:)
// Root-relative URLs must NOT be rewritten through the viewer resource endpoint.
return value.startsWith("/") || /^[a-z][\w+\-.]*:/i.test(value);
}
function _resolveLoaderFromAsset(asset: any) {
const explicit = String(asset?.loader || asset?.viewer_info?.loader || "")
.trim()
.toLowerCase();
if (explicit) return explicit;
return (MODEL3D_EXT_TO_LOADER as Record<string, any>)[_extOf(asset)] || "";
}
// --- Settings panel --------------------------------------------------------
export function isModel3DAsset(asset: any): boolean {
const kind = String(asset?.kind || "").toLowerCase();
if (kind === "model3d") return true;
return MODEL3D_EXTS.has(_extOf(asset));
}
/** Returns the loader type string (e.g. "gltf", "obj", "fbx") for the given asset. */
export function resolveModel3DLoader(asset: any): string | null {
return _resolveLoaderFromAsset(asset) || "gltf";
}
/** Returns the default mouse-control hint string shown in the 3D viewport. */
export function getModel3DDefaultControlHint(): string {
return MODEL3D_DEFAULT_CONTROL_HINT;
}
/** Returns the WebGL canvas element inside a 3D host element, or null if not found. */
export function findModel3DCanvas(rootEl: any) {
try {
if (rootEl?.classList?.contains?.("mjr-model3d-render-canvas")) return rootEl;
} catch (e: any) {
console.debug?.(e);
}
try {
return rootEl?.querySelector?.(".mjr-model3d-render-canvas") || null;
} catch {
return null;
}
}
/** Returns true if the given DOM element is inside a 3D model host (used to suppress panzoom). */
export function isModel3DInteractionTarget(target: any) {
try {
return !!target?.closest?.(
".mjr-model3d-host, .mjr-viewer-model3d-host, .mjr-mfv-model3d-host",
);
} catch {
return false;
}
}
/**
* Returns an OrbitControls mouseButtons config mapping Left->ROTATE, Middle->DOLLY, Right->PAN.
* @param {MouseEvent|null} eventLike - unused, reserved for future gesture detection
* @param {{ROTATE:number,DOLLY:number,PAN:number}} MOUSE_ENUM - THREE.MOUSE enum
*/
export function buildModel3DMouseButtons(eventLike: any, MOUSE_ENUM: any) {
const M = MOUSE_ENUM || { ROTATE: 0, DOLLY: 1, PAN: 2 };
void eventLike;
return { LEFT: M.ROTATE, MIDDLE: M.DOLLY, RIGHT: M.PAN };
}
// --- Main element factory --------------------------------------------------
/**
* Creates and returns a fully self-contained 3D viewer host element.
*
* The returned div contains a WebGL canvas, status overlay, toolbar, settings
* panel, and animation bar. Three.js is loaded lazily on first call.
*
* @param {object} asset - Asset dict with at least `filename` and optionally `id`, `ext`
* @param {string} url - Absolute or root-relative URL to the 3D model file
* @param {object} [options]
* @param {string} [options.hostClassName] - CSS class for the host div
* @param {string} [options.hostStyle] - Inline CSS for the host div
* @param {string} [options.canvasClassName] - CSS class for the WebGL canvas
* @param {string} [options.canvasStyle] - Inline CSS for the WebGL canvas
* @param {boolean} [options.disableViewerTransform=false] - Disable panzoom on canvas
* @param {string} [options.hintText] - Override the control hint text
* @param {Function} [options.scheduleOverlayRedraw] - Callback to trigger overlay repaints
* @param {Function} [options.onReady] - Called once the model is loaded and rendered
* @returns {HTMLDivElement} The 3D viewer host element (append to DOM to activate)
*/
export function createModel3DMediaElement(asset: any, url: any, options: Record<string, any> = {}) {
const shouldPauseDuringExecution =
options?.pauseDuringExecution == null
? !!APP_CONFIG?.VIEWER_PAUSE_DURING_EXECUTION
: !!options.pauseDuringExecution;
const host = document.createElement("div");
host.className = options.hostClassName || "mjr-model3d-host mjr-viewer-model3d-host";
host.style.cssText =
options.hostStyle ||
[
"width:100%",
"height:100%",
"display:flex",
"align-items:stretch",
"justify-content:stretch",
"position:relative",
"overflow:hidden",
"border-radius:12px",
"background:radial-gradient(circle at top, rgba(76,175,80,0.10), rgba(0,0,0,0) 42%), linear-gradient(180deg, rgba(40,40,40,0.98), rgba(19,22,28,0.99))",
"box-shadow:inset 0 0 0 1px rgba(255,255,255,0.06)",
].join(";");
host.setAttribute("data-capture-wheel", "true");
host.tabIndex = -1;
// Canvas (WebGL target)
const canvas = document.createElement("canvas");
canvas.className = options.canvasClassName || "mjr-viewer-media mjr-model3d-render-canvas";
canvas.style.cssText =
options.canvasStyle ||
[
"display:block",
"width:100%",
"height:100%",
"max-width:100%",
"max-height:100%",
"outline:none",
"touch-action:none",
].join(";");
canvas.tabIndex = 0;
(canvas as any)._mjrDisableViewerTransform = options.disableViewerTransform !== false;
try {
if (asset?.id != null) canvas.dataset.mjrAssetId = String(asset.id);
} catch (e: any) {
console.debug?.(e);
}
host.appendChild(canvas);
// Status overlay canvas
const statusCanvas = document.createElement("canvas");
statusCanvas.className = "mjr-model3d-status";
statusCanvas.style.cssText = [
"position:absolute",
"inset:0",
"display:block",
"width:100%",
"height:100%",
"pointer-events:none",
"z-index:1",
].join(";");
host.appendChild(statusCanvas);
// Format badge
const ext = _extOf(asset).replace(".", "").toUpperCase();
if (ext) {
const badge = document.createElement("div");
badge.className = "mjr-model3d-badge";
badge.textContent = ext;
badge.style.cssText = [
"position:absolute",
"top:10px",
"left:10px",
"padding:4px 8px",
"border-radius:999px",
"font:700 10px system-ui,sans-serif",
"letter-spacing:0.08em",
"color:#fff",
"background:rgba(76,175,80,0.9)",
"pointer-events:none",
"z-index:2",
].join(";");
host.appendChild(badge);
}
// Viewport toolbar (top-right)
const viewportToolbar = document.createElement("div");
viewportToolbar.className = "mjr-model3d-toolbar";
viewportToolbar.style.cssText = [
"position:absolute",
"top:10px",
"right:10px",
"display:flex",
"align-items:center",
"gap:6px",
"flex-wrap:wrap",
"pointer-events:none",
"z-index:5",
].join(";");
const viewportTools = document.createElement("div");
viewportTools.style.cssText =
"display:flex;align-items:center;gap:6px;flex-wrap:wrap;pointer-events:auto;";
const resetViewBtn = _createViewportButton(t("model3d.reset", "Reset"), t("model3d.resetView", "Reset 3D view"));
const gridBtn = _createViewportButton(t("model3d.grid", "Grid"), t("model3d.toggleGrid", "Toggle grid"));
const cameraModeBtn = _createViewportButton(t("model3d.persp", "Persp"), t("model3d.switchCamera", "Switch perspective / orthographic"));
const settingsBtn = _createViewportButton(t("model3d.settings", "Settings"), t("model3d.settings", "Settings"));
viewportTools.append(resetViewBtn, gridBtn, cameraModeBtn, settingsBtn);
viewportToolbar.appendChild(viewportTools);
host.appendChild(viewportToolbar);
// Control hint (bottom-right)
const hint = document.createElement("div");
hint.className = "mjr-model3d-hint";
hint.textContent = options.hintText || MODEL3D_DEFAULT_CONTROL_HINT;
hint.style.cssText = [
"position:absolute",
"right:10px",
"bottom:10px",
"padding:5px 10px",
"border-radius:999px",
"font:500 11px system-ui,sans-serif",
"color:rgba(255,255,255,0.75)",
"background:rgba(0,0,0,0.35)",
"backdrop-filter:blur(6px)",
"pointer-events:none",
"z-index:2",
"transition:bottom 0.2s",
].join(";");
host.appendChild(hint);
// Settings panel
const settingsDom = _createSettingsPanel({
defaultBgColor: MODEL3D_VIEWER_DEFAULT_BG,
defaultFov: MODEL3D_DEFAULT_FOV,
defaultLightIntensity: MODEL3D_DEFAULT_LIGHT_INTENSITY,
hasSkeleton: false, // updated after load
});
host.appendChild(settingsDom.panel);
// Animation bar
const animDom = _createAnimationBar();
host.appendChild(animDom.bar);
// -- State --------------------------------------------------------------
let destroyed = false;
let resizeObserver: any = null;
let threeLib: any = null;
let renderer: any = null;
let scene: any = null;
let activeCamera: any = null;
let perspectiveCamera: any = null;
let orthographicCamera: any = null;
let controls: any = null;
let rafId: any = null;
let renderFrameFn: any = null;
let runtimePaused = false;
let statusState: any = null;
let interactionCleanup: any = null;
let gridHelper: any = null;
let modelGroup: any = null; // Group wrapping loadedObject - used for up-direction rotation
let loadedObject: any = null;
let objectFrame: any = null;
let orthographicFrustumHeight = 0;
let viewportMode: string = MODEL3D_VIEW_MODES.PERSPECTIVE;
// Material modes
let originalMaterials = new Map(); // uuid -> original material
const tempMaterials = new Set(); // disposable materials created by modes
// Skeleton
let skeletonHelper: any = null;
let hasSkeleton = false;
// Lights
let ambientLight: any = null;
let lightObjects: any[] = []; // [mainDir, backDir, fillRight, fillLeft, bottomDir]
let lightIntensity = MODEL3D_DEFAULT_LIGHT_INTENSITY;
// Animation
let animationMixer: any = null;
let animationClock: any = null;
let animationClips: any[] = [];
let animationActions: any[] = [];
let currentAnimIdx = 0;
let animSpeed = 1.0;
let isAnimPlaying = false;
let isSeeking = false;
// Settings panel visibility
let settingsPanelVisible = false;
const modelName = String(asset?.filename || "3D model");
const scheduleOverlayRedraw = () => {
try {
options.scheduleOverlayRedraw?.();
} catch (e: any) {
console.debug?.(e);
}
};
// -- Status helpers ------------------------------------------------------
const setStatus = (title: any, hintText = "", accent = "#4CAF50") => {
statusState = { title, hintText, accent };
statusCanvas.style.display = "block";
_drawModelMessage(statusCanvas, title, hintText, accent);
};
const clearStatus = () => {
statusState = null;
statusCanvas.style.display = "none";
};
const syncNaturalSize = () => {
try {
const rect = host.getBoundingClientRect();
(canvas as any)._mjrNaturalW = Math.max(1, Math.round(rect.width || canvas.width || 1));
(canvas as any)._mjrNaturalH = Math.max(1, Math.round(rect.height || canvas.height || 1));
} catch (e: any) {
console.debug?.(e);
}
try {
if (statusState)
_drawModelMessage(
statusCanvas,
statusState.title,
statusState.hintText,
statusState.accent,
);
} catch (e: any) {
console.debug?.(e);
}
};
// -- Camera helpers ------------------------------------------------------
const updateOrthographicProjection = (width: any, height: any) => {
if (!orthographicCamera || !(orthographicFrustumHeight > 0)) return;
const aspect = Math.max(0.0001, (Number(width) || 1) / Math.max(1, Number(height) || 1));
const fw = orthographicFrustumHeight * aspect;
orthographicCamera.left = -fw / 2;
orthographicCamera.right = fw / 2;
orthographicCamera.top = orthographicFrustumHeight / 2;
orthographicCamera.bottom = -orthographicFrustumHeight / 2;
orthographicCamera.updateProjectionMatrix();
};
const fitLoadedObject = () => {
if (!modelGroup || !controls || !objectFrame) return;
if (viewportMode === MODEL3D_VIEW_MODES.ORTHOGRAPHIC) {
const rect = host.getBoundingClientRect();
orthographicFrustumHeight =
_fitOrthographicCamera(
orthographicCamera,
controls,
objectFrame,
Math.max(0.0001, (rect.width || 1) / Math.max(1, rect.height || 1)),
) || orthographicFrustumHeight;
activeCamera = orthographicCamera;
} else {
_fitPerspectiveCamera(threeLib, perspectiveCamera, controls, objectFrame);
activeCamera = perspectiveCamera;
}
try {
controls.object = activeCamera;
controls.update();
} catch (e: any) {
console.debug?.(e);
}
};
const setViewportMode = (nextMode: any, { refit = true }: Record<string, any> = {}) => {
viewportMode =
nextMode === MODEL3D_VIEW_MODES.ORTHOGRAPHIC
? MODEL3D_VIEW_MODES.ORTHOGRAPHIC
: MODEL3D_VIEW_MODES.PERSPECTIVE;
activeCamera =
viewportMode === MODEL3D_VIEW_MODES.ORTHOGRAPHIC
? orthographicCamera
: perspectiveCamera;
try {
if (controls) controls.object = activeCamera;
} catch (e: any) {
console.debug?.(e);
}
if (refit) {
fitLoadedObject();
} else {
try {
activeCamera?.updateProjectionMatrix?.();
} catch (e: any) {
console.debug?.(e);
}
try {
controls?.update?.();
} catch (e: any) {
console.debug?.(e);
}
}
syncViewportButtons();
scheduleOverlayRedraw();
};
// -- Light helpers --------------------------------------------------------
const applyLightIntensity = (val: any) => {
lightIntensity = Math.max(0, Math.min(10, Number(val) || 0));
try {
if (ambientLight) ambientLight.intensity = MODEL3D_LIGHT_SCALE.ambient * lightIntensity;
} catch (e: any) {
console.debug?.(e);
}
// lightObjects order: [main, back, fillRight, fillLeft, bottom]
const scales = [
MODEL3D_LIGHT_SCALE.main,
MODEL3D_LIGHT_SCALE.back,
MODEL3D_LIGHT_SCALE.fill,
MODEL3D_LIGHT_SCALE.fill,
MODEL3D_LIGHT_SCALE.bottom,
];
lightObjects.forEach((light, i) => {
try {
if (light)
light.intensity = (scales[i] ?? MODEL3D_LIGHT_SCALE.fill) * lightIntensity;
} catch (e: any) {
console.debug?.(e);
}
});
};
// -- Material mode helpers ------------------------------------------------
const applyMaterialMode = (mode: any) => {
if (!loadedObject || !threeLib) return;
_disposeTempMaterials(tempMaterials);
_applyMaterialMode(threeLib, loadedObject, mode, originalMaterials, tempMaterials);
scheduleOverlayRedraw();
};
// -- Up direction helpers -------------------------------------------------
const applyUpDirection = (dir: any) => {
if (!modelGroup) return;
const euler = (MODEL3D_UP_DIR_EULER as Record<string, any>)[dir] || [0, 0, 0];
try {
modelGroup.rotation.set(euler[0], euler[1], euler[2]);
modelGroup.updateMatrixWorld(true);
} catch (e: any) {
console.debug?.(e);
}
if (threeLib) objectFrame = _computeObjectFrame(threeLib, modelGroup);
fitLoadedObject();
};
// -- Skeleton helpers -----------------------------------------------------
const setSkeletonVisible = (visible: any) => {
if (!skeletonHelper) return;
try {
skeletonHelper.visible = Boolean(visible);
} catch (e: any) {
console.debug?.(e);
}
};
// -- Animation helpers ----------------------------------------------------
const syncAnimBar = () => {
if (!animationMixer || animationClips.length === 0) return;
const action = animationActions[currentAnimIdx];
if (!action) return;
const clip = animationClips[currentAnimIdx];
const duration = clip?.duration || 0;
const current = action.time || 0;
try {
animDom.timeLbl.textContent = `${_formatAnimTime(current)} / ${_formatAnimTime(duration)}`;
} catch (e: any) {
console.debug?.(e);
}
if (!isSeeking && duration > 0) {
try {
const sliderMax = Number(animDom.progressSlider.max) || 1000;
animDom.progressSlider.value = String(Math.round((current / duration) * sliderMax));
} catch (e: any) {
console.debug?.(e);
}
}
};
const playAnimation = (idx: any) => {
if (!animationMixer || !animationClips.length) return;
const clip = animationClips[idx];
if (!clip) return;
// Stop all current actions
animationActions.forEach((a: any) => {
try {
a?.stop();
} catch (e: any) {
console.debug?.(e);
}
});
const action = animationMixer.clipAction(clip);
action.timeScale = animSpeed;
action.clampWhenFinished = false;
action.loop = threeLib?.LoopRepeat ?? 2201;
action.play();
animationActions[idx] = action;
currentAnimIdx = idx;
isAnimPlaying = true;
animDom.playBtn.textContent = t("model3d.pause", "Pause");
// Scale progress slider steps to clip duration for consistent seek granularity
// (~10ms steps, min 100 ticks, max 10000 ticks)
try {
const steps = Math.max(100, Math.min(10000, Math.round((clip.duration || 1) * 100)));
animDom.progressSlider.max = String(steps);
} catch (_: any) {}
};
const togglePlayPause = () => {
if (!animationMixer || !animationClips.length) return;
if (isAnimPlaying) {
animationMixer.timeScale = 0;
isAnimPlaying = false;
animDom.playBtn.textContent = t("model3d.play", "Play");
} else {
animationMixer.timeScale = animSpeed;
if (!animationActions[currentAnimIdx]?.isRunning?.()) {
playAnimation(currentAnimIdx);
} else {
isAnimPlaying = true;
animDom.playBtn.textContent = t("model3d.pause", "Pause");
}
}
};
const setupAnimations = (clips: any, object: any) => {
if (!clips || clips.length === 0 || !threeLib) return;
animationClips = clips;
animationMixer = new threeLib.AnimationMixer(object);
animationClock = new threeLib.Clock();
animationActions = new Array(clips.length).fill(null);
// Populate animation selector
animDom.animSel.innerHTML = "";
clips.forEach((clip: any, i: any) => {
const opt = document.createElement("option");
opt.value = String(i);
opt.textContent = clip.name || `Animation ${i + 1}`;
animDom.animSel.appendChild(opt);
});
animDom.animSel.style.display = clips.length > 1 ? "" : "none";
animDom.bar.style.display = "flex";
hint.style.bottom = `${MODEL3D_ANIM_BAR_HEIGHT_PX + 10}px`; // clear the animation bar
// Play first animation
playAnimation(0);
// Bind animation bar events
animDom.playBtn.addEventListener("click", (e: any) => {
_stopEvent(e);
togglePlayPause();
});
animDom.speedSel.addEventListener("change", () => {
animSpeed = Number(animDom.speedSel.value) || 1.0;
animationMixer.timeScale = isAnimPlaying ? animSpeed : 0;
const action = animationActions[currentAnimIdx];
if (action) action.timeScale = animSpeed;
});
animDom.animSel.addEventListener("change", () => {
const idx = Number(animDom.animSel.value) || 0;
playAnimation(idx);
});
animDom.progressSlider.addEventListener("pointerdown", () => {
isSeeking = true;
});
animDom.progressSlider.addEventListener("pointerup", () => {
isSeeking = false;
const action = animationActions[currentAnimIdx];
const clip = animationClips[currentAnimIdx];
if (action && clip) {
const sliderMax = Number(animDom.progressSlider.max) || 1000;
const t = (Number(animDom.progressSlider.value) / sliderMax) * clip.duration;
action.time = Math.max(0, Math.min(t, clip.duration));
}
});
animDom.progressSlider.addEventListener("input", () => {
const action = animationActions[currentAnimIdx];
const clip = animationClips[currentAnimIdx];
if (action && clip && isSeeking) {
const sliderMax = Number(animDom.progressSlider.max) || 1000;
const t = (Number(animDom.progressSlider.value) / sliderMax) * clip.duration;
action.time = Math.max(0, Math.min(t, clip.duration));
animDom.timeLbl.textContent = `${_formatAnimTime(t)} / ${_formatAnimTime(clip.duration)}`;
}
});
};
// -- Viewport button sync -------------------------------------------------
const syncViewportButtons = () => {
_setViewportButtonActive(gridBtn, Boolean(gridHelper?.visible));
_setViewportButtonActive(cameraModeBtn, viewportMode === MODEL3D_VIEW_MODES.ORTHOGRAPHIC);
cameraModeBtn.textContent =
viewportMode === MODEL3D_VIEW_MODES.ORTHOGRAPHIC ? t("model3d.ortho", "Ortho") : t("model3d.persp", "Persp");
_setViewportButtonActive(settingsBtn, settingsPanelVisible);
};
// -- Settings panel wiring ------------------------------------------------
settingsDom.bgInput.addEventListener("input", () => {
const col = settingsDom.bgInput.value;
try {
if (scene) scene.background = new (threeLib?.Color || Object)(col);
} catch (e: any) {
console.debug?.(e);
}
});
settingsDom.materialSel.addEventListener("change", () => {
applyMaterialMode(settingsDom.materialSel.value);
});
settingsDom.upSel.addEventListener("change", () => {
applyUpDirection(settingsDom.upSel.value);
});
if (settingsDom.skeletonToggle) {
settingsDom.skeletonToggle.addEventListener("change", () => {
setSkeletonVisible(settingsDom.skeletonToggle.checked);
});
}
settingsDom.fovSlider.addEventListener("input", () => {
const fov = Math.max(
1,
Math.min(179, Number(settingsDom.fovSlider.value) || MODEL3D_DEFAULT_FOV),
);
try {
if (perspectiveCamera) {
perspectiveCamera.fov = fov;
perspectiveCamera.updateProjectionMatrix();
}
} catch (e: any) {
console.debug?.(e);
}
});
settingsDom.lightSlider.addEventListener("input", () => {
applyLightIntensity(Number(settingsDom.lightSlider.value));
});
// -- Event blocking (prevent bubbling to outer viewer/panzoom) -------------
// NOTE: panzoom.js already guards pointer/mouse events via isModel3DInteractionTarget()
// in its capture-phase listeners (panzoom.js:460), so we only need to block wheel,
// contextmenu, and drag events here. Pointer/mouse events must NOT be stopped on host
// because OrbitControls registers pointermove/pointerup on document via the canvas.
const installInteractionShell = () => {
const registrations: any[] = [];
const bind = (el: any, name: string, handler: any, opts?: any) => {
el.addEventListener(name, handler, opts);
registrations.push(() => el.removeEventListener(name, handler, opts));
};
// Focus canvas on pointer down (bubble, no stop - OrbitControls needs these)
bind(host, "pointerdown", () => {
try {
canvas.focus?.();
} catch (e: any) {
console.debug?.(e);
}
});
// Block wheel from reaching panzoom zoom
bind(host, "wheel", (event: any) => _stopEvent(event));
// Block context menu and drag
bind(host, "contextmenu", (event: any) => _stopEvent(event, { preventDefault: true }));
bind(host, "dragstart", (event: any) => _stopEvent(event, { preventDefault: true }));
bind(host, "dragover", (event: any) => _stopEvent(event, { preventDefault: true }));
bind(host, "dragleave", (event: any) => _stopEvent(event));
bind(host, "drop", (event: any) => _stopEvent(event, { preventDefault: true }));
return () => {
for (const c of registrations) {
try {
c();
} catch (e: any) {
console.debug?.(e);
}
}
};
};
// -- Destroy --------------------------------------------------------------
const destroy = () => {
destroyed = true;
try {
window.removeEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
} catch (_: any) {}
try {
(host as any)._mjr3D = null;
} catch (_: any) {}
try {
if (rafId != null) cancelAnimationFrame(rafId);
} catch (e: any) {
console.debug?.(e);
}
rafId = null;
try {
interactionCleanup?.();
} catch (e: any) {
console.debug?.(e);
}
interactionCleanup = null;
try {
resizeObserver?.disconnect?.();
} catch (e: any) {
console.debug?.(e);
}
resizeObserver = null;
try {
controls?.dispose?.();
} catch (e: any) {
console.debug?.(e);
}
controls = null;
// Dispose animation mixer, actions, and clips
try {
animationMixer?.stopAllAction?.();
animationMixer?.uncacheRoot?.(animationMixer.getRoot?.());
} catch (e: any) {
console.debug?.(e);
}
try {
for (const action of animationActions) {
try {
animationMixer?.uncacheAction?.(action);
} catch (_: any) {}
}
} catch (e: any) {
console.debug?.(e);
}
try {
for (const clip of animationClips) {
try {
clip?.dispose?.();
} catch (_: any) {}
}
} catch (e: any) {
console.debug?.(e);
}
animationMixer = null;
animationActions = [];
animationClips = [];
// Dispose temp materials
_disposeTempMaterials(tempMaterials);
// Dispose skeleton helper
try {
if (skeletonHelper) {
scene?.remove?.(skeletonHelper);
skeletonHelper.dispose?.();
}
} catch (e: any) {
console.debug?.(e);
}
skeletonHelper = null;
// Traverse and dispose all geometries, materials, and textures in the main scene
try {
scene?.traverse?.((obj: any) => {
try {
obj.geometry?.dispose?.();
} catch (_: any) {}
try {
const materials = Array.isArray(obj.material)
? obj.material
: obj.material
? [obj.material]
: [];
for (const mat of materials) {
try {
if (mat) {
for (const key of Object.keys(mat)) {
try {
const val = mat[key];
if (
val &&
typeof val === "object" &&
typeof val.dispose === "function" &&
val.isTexture
) {
val.dispose();
}
} catch (_: any) {}
}
mat.dispose?.();
}
} catch (_: any) {}
}
} catch (_: any) {}
});
} catch (e: any) {
console.debug?.(e);
}
// Dispose axis gizmo scene
try {
if ((host as any)._mjrAxisScene) {
(host as any)._mjrAxisScene.traverse?.((obj: any) => {
try {
obj.geometry?.dispose?.();
} catch (_: any) {}
try {
obj.material?.dispose?.();
} catch (_: any) {}
});
(host as any)._mjrAxisScene = null;
}
} catch (e: any) {
console.debug?.(e);
}
try {
renderer?.dispose?.();
} catch (e: any) {
console.debug?.(e);
}
renderer = null;
scene = null;
activeCamera = null;
perspectiveCamera = null;
orthographicCamera = null;
loadedObject = null;
modelGroup = null;
objectFrame = null;
renderFrameFn = null;
try {
const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
if (gl) gl.getExtension("WEBGL_lose_context")?.loseContext?.();
} catch (e: any) {
console.debug?.(e);
}
};
const pauseRendering = () => {
runtimePaused = true;
try {
if (rafId != null) cancelAnimationFrame(rafId);
} catch (e: any) {
console.debug?.(e);
}
rafId = null;
};
const resumeRendering = () => {
runtimePaused = false;
if (destroyed || rafId != null || typeof renderFrameFn !== "function") return;
try {
rafId = requestAnimationFrame(renderFrameFn);
} catch (e: any) {
console.debug?.(e);
}
};
const onRuntimeStatus = (event: any) => {
if (!shouldPauseDuringExecution) return;
const activePromptId = String(event?.detail?.active_prompt_id || "").trim();
if (activePromptId) {
pauseRendering();
return;
}
resumeRendering();
};
const proc = {
setParams: () => {},
destroy,
captureCanvas: () => canvas,
pause: pauseRendering,
resume: resumeRendering,
};
(canvas as any)._mjrProc = proc;
try {
if (shouldPauseDuringExecution) {
window.addEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
if (String((window as any).__MJR_EXECUTION_RUNTIME__?.active_prompt_id || "").trim()) {
runtimePaused = true;
}
}
} catch (e: any) {
console.debug?.(e);
}
setStatus("Preparing 3D preview", modelName);
// -- Async init ------------------------------------------------------------
Promise.resolve()
.then(async () => {
const loaderType = resolveModel3DLoader(asset);
if (!PREVIEWABLE_MODEL3D_LOADERS.has(loaderType as string)) {
setStatus(
"3D preview unavailable",
`${(loaderType as string).toUpperCase()} is not supported in the embedded viewer.`,
);
hint.style.display = "none";
// Show a download fallback so the user can still access the file
if (url) {
try {
const dl = document.createElement("a");
dl.href = url;
dl.download = String(asset?.filename || "model");
dl.textContent = t("model3d.downloadFile", "Download {file}", { file: String(asset?.filename || "file") });
dl.style.cssText = [
"position:absolute",
"bottom:18px",
"left:50%",
"transform:translateX(-50%)",
"padding:7px 16px",
"border-radius:8px",
"font:600 12px system-ui,sans-serif",
"color:#fff",
"background:rgba(76,175,80,0.85)",
"text-decoration:none",
"pointer-events:auto",
"z-index:3",
].join(";");
host.appendChild(dl);
} catch (_: any) {}
}
return;
}
try {
const deps = await _loadThreeDeps();
if (destroyed) return;
const THREE = deps.THREE;
threeLib = THREE;
// URL modifier for relative resources (e.g. GLTF textures)
const manager = new THREE.LoadingManager();
manager.setURLModifier((requestedUrl: any) => {
if (!requestedUrl || _isAbsoluteOrSpecialUrl(requestedUrl)) return requestedUrl;
const rewritten = buildViewerResourceURL(asset, requestedUrl);
if (rewritten) return rewritten;
try {
return new URL(requestedUrl, url).href;
} catch {
return requestedUrl;
}
});
// Renderer
renderer = new THREE.WebGLRenderer({
canvas,
antialias: true,
alpha: true,
preserveDrawingBuffer: true,
});
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.setPixelRatio(Math.min(2, window.devicePixelRatio || 1));
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(MODEL3D_VIEWER_DEFAULT_BG);
// Cameras
perspectiveCamera = new THREE.PerspectiveCamera(
MODEL3D_DEFAULT_FOV,
16 / 9,
0.01,
10000,
);
orthographicCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -10000, 10000);
activeCamera = perspectiveCamera;
// Controls - attach to canvas (like ComfyUI's ControlsManager uses renderer.domElement)
// so OrbitControls' internal pointermove/pointerup on ownerDocument are not blocked.
controls = new deps.OrbitControls(activeCamera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.rotateSpeed = 0.82;
controls.zoomSpeed = 1.0;
controls.panSpeed = 0.9;
controls.screenSpacePanning = true;
controls.mouseButtons = buildModel3DMouseButtons(null, THREE.MOUSE);
interactionCleanup = installInteractionShell();
// -- 6-light setup (ComfyUI-style) -------------------------------
ambientLight = new THREE.AmbientLight(
0xffffff,
MODEL3D_LIGHT_SCALE.ambient * lightIntensity,
);
scene.add(ambientLight);
const mainLight = new THREE.DirectionalLight(
0xffffff,
MODEL3D_LIGHT_SCALE.main * lightIntensity,
);
mainLight.position.set(0, 10, 10);
const backLight = new THREE.DirectionalLight(
0xffffff,
MODEL3D_LIGHT_SCALE.back * lightIntensity,
);
backLight.position.set(-10, 0, -10);
const fillRight = new THREE.DirectionalLight(
0xe8f0ff,
MODEL3D_LIGHT_SCALE.fill * lightIntensity,
);
fillRight.position.set(10, 5, -5);
const fillLeft = new THREE.DirectionalLight(
0xfff0e8,
MODEL3D_LIGHT_SCALE.fill * lightIntensity,
);
fillLeft.position.set(-10, 5, 5);
const bottomLight = new THREE.DirectionalLight(
0xffffff,
MODEL3D_LIGHT_SCALE.bottom * lightIntensity,
);
bottomLight.position.set(0, -10, 0);
lightObjects = [mainLight, backLight, fillRight, fillLeft, bottomLight];
lightObjects.forEach((l: any) => scene.add(l));
// Grid
gridHelper = new THREE.GridHelper(20, 20, 0x4d5560, 0x2f3742);
scene.add(gridHelper);
// Resize handler
const resize = () => {
if (destroyed || !renderer || !activeCamera) return;
const rect = host.getBoundingClientRect();
const w = Math.max(1, Math.round(rect.width || canvas.clientWidth || 1));
const h = Math.max(1, Math.round(rect.height || canvas.clientHeight || 1));
renderer.setSize(w, h, false);
if (perspectiveCamera) {
perspectiveCamera.aspect = w / h;
perspectiveCamera.updateProjectionMatrix();
}
updateOrthographicProjection(w, h);
syncNaturalSize();
scheduleOverlayRedraw();
};
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => resize());
resizeObserver.observe(host);
}
// Load model
const loader = _createLoaderInstance(deps, loaderType as string, manager);
if (!loader) {
setStatus(
"3D loader unavailable",
`${(loaderType as string).toUpperCase()} loader could not be created.`,
);
hint.style.display = "none";
return;
}
// OBJ: try to load companion MTL first (like ComfyUI does)
if (loaderType === "obj" && deps.MTLLoader) {
try {
const mtlUrl = url.replace(/\.obj(\?.*)?$/i, (m: any, q: any) => `.mtl${q || ""}`);
const mtlLoader = new deps.MTLLoader(manager);
const materials: any = await new Promise((res: any) => {
mtlLoader.load(mtlUrl, res, undefined, () => res(null));
});
if (materials && !destroyed) {
materials.preload();
loader.setMaterials(materials);
}
} catch (e: any) {
console.debug?.("[MJR 3D] MTL load skipped:", e?.message);
}
}
const loaded = await _loadModelContent(loader, loaderType as string, url);
if (destroyed) return;
const object = _buildRenderableObject(THREE, loaderType, loaded);
if (!object) {
setStatus("Empty 3D scene", "The loader returned no renderable object.");
hint.style.display = "none";
return;
}
// Normalize model: scale to ~5 units and floor it (matches ComfyUI SceneModelManager)
try {
const normBox = new THREE.Box3().setFromObject(object);
if (!normBox.isEmpty()) {
const normSize = normBox.getSize(new THREE.Vector3());
const normCenter = normBox.getCenter(new THREE.Vector3());
const maxDim = Math.max(normSize.x, normSize.y, normSize.z, 0.001);
const scale = 5 / maxDim;
object.scale.multiplyScalar(scale);
// Re-compute after scaling
normBox.setFromObject(object);
normBox.getCenter(normCenter);
// Center X/Z, floor Y
object.position.set(-normCenter.x, -normBox.min.y, -normCenter.z);
}
} catch (e: any) {
console.debug?.("[MJR 3D] normalize skipped:", e);
}
// Wrap in a group so up-direction can rotate without affecting camera framing directly
loadedObject = object;
modelGroup = new THREE.Group();
modelGroup.add(object);
scene.add(modelGroup);
// Store original materials
originalMaterials = _storeOriginalMaterials(object);
// Skeleton
hasSkeleton = _findHasSkeleton(object);
if (hasSkeleton) {
skeletonHelper = new THREE.SkeletonHelper(object);
skeletonHelper.visible = false;
scene.add(skeletonHelper);
// Rebuild settings panel with skeleton toggle
const newSettings = _createSettingsPanel({
defaultBgColor: MODEL3D_VIEWER_DEFAULT_BG,
defaultFov: MODEL3D_DEFAULT_FOV,
defaultLightIntensity: MODEL3D_DEFAULT_LIGHT_INTENSITY,
hasSkeleton: true,
});
host.replaceChild(newSettings.panel, settingsDom.panel);
Object.assign(settingsDom, newSettings);
// Re-wire events for new panel
settingsDom.bgInput.addEventListener("input", () => {
try {
if (scene)
scene.background = new THREE.Color(settingsDom.bgInput.value);
} catch (e: any) {
console.debug?.(e);
}
});
settingsDom.materialSel.addEventListener("change", () => {
applyMaterialMode(settingsDom.materialSel.value);
});
settingsDom.upSel.addEventListener("change", () => {
applyUpDirection(settingsDom.upSel.value);
});
if (settingsDom.skeletonToggle) {
settingsDom.skeletonToggle.addEventListener("change", () => {
setSkeletonVisible(settingsDom.skeletonToggle.checked);
});
}
settingsDom.fovSlider.addEventListener("input", () => {
const fov = Math.max(
1,
Math.min(
179,
Number(settingsDom.fovSlider.value) || MODEL3D_DEFAULT_FOV,
),
);
try {
if (perspectiveCamera) {
perspectiveCamera.fov = fov;
perspectiveCamera.updateProjectionMatrix();
}
} catch (e: any) {
console.debug?.(e);
}
});
settingsDom.lightSlider.addEventListener("input", () => {
applyLightIntensity(Number(settingsDom.lightSlider.value));
});
}
// Fit model + place grid at its base
objectFrame = _computeObjectFrame(THREE, modelGroup);
if (objectFrame) {
gridHelper.position.y = objectFrame.box.min.y;
}
viewportMode = MODEL3D_VIEW_MODES.PERSPECTIVE;
fitLoadedObject();
resize();
clearStatus();
syncViewportButtons();
// Animations
const clips = _getModelAnimations(loaded, loaderType);
if (clips.length > 0) setupAnimations(clips, object);
// -- Axis gizmo (bottom-left corner) -----------------------------
const axisScene = new THREE.Scene();
(host as any)._mjrAxisScene = axisScene;
const axisCamera = new THREE.PerspectiveCamera(50, 1, 0.1, 100);
axisCamera.position.set(0, 0, 3.2);
axisCamera.lookAt(0, 0, 0);
const AXIS_SIZE = 96; // px
// Build axis lines + sphere tips
const axisDefs = [
{ dir: [1, 0, 0], color: 0xff3333 }, // X = red
{ dir: [0, 1, 0], color: 0x33ff33 }, // Y = green
{ dir: [0, 0, 1], color: 0x5588ff }, // Z = blue
];
const sphereGeo = new THREE.SphereGeometry(0.1, 12, 8);
const centerGeo = new THREE.SphereGeometry(0.08, 10, 6);
const centerMat = new THREE.MeshBasicMaterial({ color: 0xcccccc });
axisScene.add(new THREE.Mesh(centerGeo, centerMat));
for (const { dir, color } of axisDefs) {
const mat = new THREE.LineBasicMaterial({ color });
const pts = [
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(dir[0], dir[1], dir[2]),
];
const lineGeo = new THREE.BufferGeometry().setFromPoints(pts);
axisScene.add(new THREE.Line(lineGeo, mat));
const tipMat = new THREE.MeshBasicMaterial({ color });
const tip = new THREE.Mesh(sphereGeo, tipMat);
tip.position.set(dir[0], dir[1], dir[2]);
axisScene.add(tip);
}
// Expose controls/camera on host so compare-mode sync can find them
(host as any)._mjr3D = {
controls,
get camera() {
return activeCamera;
},
};
try {
options.onReady?.({
canvas,
host,
object: modelGroup,
renderer,
camera: activeCamera,
controls,
});
} catch (e: any) {
console.debug?.(e);
}
scheduleOverlayRedraw();
// -- Render loop --------------------------------------------------
const renderFrame = () => {
if (destroyed || runtimePaused || !renderer || !scene || !activeCamera) return;
try {
controls?.update?.();
if (animationMixer && animationClock) {
const delta = animationClock.getDelta();
if (isAnimPlaying) animationMixer.update(delta);
syncAnimBar();
}
// Main scene
renderer.setScissorTest(false);
renderer.setViewport(0, 0, canvas.width, canvas.height);
renderer.render(scene, activeCamera);
// Axis gizmo - bottom-left corner
const px = Math.min(2, window.devicePixelRatio || 1);
const gizmoSize = Math.round(AXIS_SIZE * px);
renderer.setScissorTest(true);
renderer.setScissor(0, 0, gizmoSize, gizmoSize);
renderer.setViewport(0, 0, gizmoSize, gizmoSize);
renderer.setClearColor(0x000000, 0);
renderer.clear(true, true, false);
// Sync axis camera orientation with main camera
const q = activeCamera.quaternion;
axisCamera.position.set(0, 0, 3.2).applyQuaternion(q);
axisCamera.quaternion.copy(q);
renderer.render(axisScene, axisCamera);
// Restore state
renderer.setScissorTest(false);
try {
renderer.setClearColor(scene.background || 0x000000, 1);
} catch (_: any) {}
syncNaturalSize();
} catch (e: any) {
console.debug?.(e);
}
if (!runtimePaused) {
rafId = requestAnimationFrame(renderFrame);
}
};
renderFrameFn = renderFrame;
// -- Viewport button events ---------------------------------------
const stopClick = (e: any) => _stopEvent(e, { preventDefault: true });
resetViewBtn.addEventListener("click", (e: any) => {
stopClick(e);
fitLoadedObject();
syncViewportButtons();
});
gridBtn.addEventListener("click", (e: any) => {
stopClick(e);
if (gridHelper) gridHelper.visible = !gridHelper.visible;
syncViewportButtons();
scheduleOverlayRedraw();
});
cameraModeBtn.addEventListener("click", (e: any) => {
stopClick(e);
setViewportMode(
viewportMode === MODEL3D_VIEW_MODES.PERSPECTIVE
? MODEL3D_VIEW_MODES.ORTHOGRAPHIC
: MODEL3D_VIEW_MODES.PERSPECTIVE,
);
});
settingsBtn.addEventListener("click", (e: any) => {
stopClick(e);
settingsPanelVisible = !settingsPanelVisible;
settingsDom.panel.style.display = settingsPanelVisible ? "block" : "none";
syncViewportButtons();
});
if (!runtimePaused) {
renderFrame();
}
} catch (error: any) {
console.warn("[MJR 3D] preview init failed", error);
setStatus(
"Failed to load 3D preview",
String(error?.message || "Three.js initialization failed."),
);
hint.style.display = "none";
}
})
.catch((err: any) => {
if (!destroyed) {
console.warn("[MJR 3D] preview init unhandled error", err);
try {
setStatus(
"Failed to load 3D preview",
String(err?.message || "Initialization failed."),
);
} catch (_: any) {}
try {
hint.style.display = "none";
} catch (_: any) {}
}
});
return host;
}