Files
MajoorWaldi-ComfyUI-Majoor-…/js/features/viewer/model3dRenderer.impl.js
T
Ewald a2053d0cba refactor(v2): plan V2 complet — splits JS, tests frontend, durcissement securite
Chantier F - Typage Python
- ruff S101 déplacé en per-file-ignores (pyproject.toml)
- pyrightconfig.json : mode standard confirmé

Chantier G - Assertions & bare-except
- 16 blocs except documentés dans config.py
- --cov-branch activé, seuil coverage 60→68%

Chantier H - Cache settings
- Stratégie documentée dans settings.py
- _resolve_index_dir() logguée dans config.py

Chantier I - Split gros fichiers JS
- client.js 1509L → client.js 406L + clientAuth.js + clientOps.js + fetchUtils.js
- toolbar.js 927L → toolbar.js 458L + toolbarActions.js + toolbarControls.js
- FloatingViewer.js → FloatingViewer.impl.js + floatingViewer*(8 modules)
- Viewer_impl.js → ViewerRuntime.js + viewerShell.js + viewerThemeStyles.js + viewerOverlayDismiss.js
- model3dRenderer.js → model3dRenderer.impl.js + model3dCore.js + model3dLoaders.js + model3dSupport.js
- dialogs.js → dialogs.js + DialogTemplates.js

Chantier J - Tests frontend (Phases 1-2)
- use_drag_drop.vitest.mjs : 5 tests
- use_grid_state.vitest.mjs : 18 tests
- security_client_headers, security_deep_merge, security_runtime_state, runtime_store, dialog_templates
- Total: 80 fichiers test / 350 tests vitest

Chantier K - Pinia migration
- runtimeState.js entièrement migré vers Pinia
- getOptionalRuntimeStore.js + runtimeEnrichmentState.js ajoutés

Chantier L - Durcissement sécurité
- tests/security/test_rate_limit.py : 8 tests rate-limit (state machine complète)
- clientAuth.js : docstring sessionStorage security boundary
- three.js 0.183.2 audité : 0 CVE (Snyk)

Chantier M - Simplification config
- deps.py, tool_detect.py, utils.py nettoyés

docs: PLAN_REFACTO_V2_LONG_TERME.md ajouté et clôturé
.gitignore: coverage/ ajouté
2026-04-11 09:38:41 +02:00

1414 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { buildViewerResourceURL } from "../../api/endpoints.js";
import { APP_CONFIG } from "../../app/config.js";
import { EVENTS } from "../../app/events.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 = "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) {
try {
const ext = String(asset?.ext || "")
.trim()
.toLowerCase();
if (ext) return ext.startsWith(".") ? ext : `.${ext}`;
} catch (e) {
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) {
console.debug?.(e);
}
return "";
}
function _isAbsoluteOrSpecialUrl(url) {
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) {
const explicit = String(asset?.loader || asset?.viewer_info?.loader || "")
.trim()
.toLowerCase();
if (explicit) return explicit;
return MODEL3D_EXT_TO_LOADER[_extOf(asset)] || "";
}
// ─── Settings panel ────────────────────────────────────────────────────────
export function isModel3DAsset(asset) {
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) {
return _resolveLoaderFromAsset(asset) || "gltf";
}
/** Returns the default mouse-control hint string shown in the 3D viewport. */
export function getModel3DDefaultControlHint() {
return MODEL3D_DEFAULT_CONTROL_HINT;
}
/** Returns the WebGL canvas element inside a 3D host element, or null if not found. */
export function findModel3DCanvas(rootEl) {
try {
if (rootEl?.classList?.contains?.("mjr-model3d-render-canvas")) return rootEl;
} catch (e) {
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) {
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, MOUSE_ENUM) {
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, url, options = {}) {
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._mjrDisableViewerTransform = options.disableViewerTransform !== false;
try {
if (asset?.id != null) canvas.dataset.mjrAssetId = String(asset.id);
} catch (e) {
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("Reset", "Reset 3D view");
const gridBtn = _createViewportButton("Grid", "Toggle grid");
const cameraModeBtn = _createViewportButton("Persp", "Switch perspective / orthographic");
const settingsBtn = _createViewportButton("⚙", "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 = null;
let threeLib = null;
let renderer = null;
let scene = null;
let activeCamera = null;
let perspectiveCamera = null;
let orthographicCamera = null;
let controls = null;
let rafId = null;
let renderFrameFn = null;
let runtimePaused = false;
let statusState = null;
let interactionCleanup = null;
let gridHelper = null;
let modelGroup = null; // Group wrapping loadedObject – used for up-direction rotation
let loadedObject = null;
let objectFrame = null;
let orthographicFrustumHeight = 0;
let viewportMode = 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 = null;
let hasSkeleton = false;
// Lights
let ambientLight = null;
let lightObjects = []; // [mainDir, backDir, fillRight, fillLeft, bottomDir]
let lightIntensity = MODEL3D_DEFAULT_LIGHT_INTENSITY;
// Animation
let animationMixer = null;
let animationClock = null;
let animationClips = [];
let animationActions = [];
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) {
console.debug?.(e);
}
};
// ── Status helpers ──────────────────────────────────────────────────────
const setStatus = (title, 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._mjrNaturalW = Math.max(1, Math.round(rect.width || canvas.width || 1));
canvas._mjrNaturalH = Math.max(1, Math.round(rect.height || canvas.height || 1));
} catch (e) {
console.debug?.(e);
}
try {
if (statusState)
_drawModelMessage(
statusCanvas,
statusState.title,
statusState.hintText,
statusState.accent,
);
} catch (e) {
console.debug?.(e);
}
};
// ── Camera helpers ──────────────────────────────────────────────────────
const updateOrthographicProjection = (width, height) => {
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) {
console.debug?.(e);
}
};
const setViewportMode = (nextMode, { refit = true } = {}) => {
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) {
console.debug?.(e);
}
if (refit) {
fitLoadedObject();
} else {
try {
activeCamera?.updateProjectionMatrix?.();
} catch (e) {
console.debug?.(e);
}
try {
controls?.update?.();
} catch (e) {
console.debug?.(e);
}
}
syncViewportButtons();
scheduleOverlayRedraw();
};
// ── Light helpers ────────────────────────────────────────────────────────
const applyLightIntensity = (val) => {
lightIntensity = Math.max(0, Math.min(10, Number(val) || 0));
try {
if (ambientLight) ambientLight.intensity = MODEL3D_LIGHT_SCALE.ambient * lightIntensity;
} catch (e) {
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) {
console.debug?.(e);
}
});
};
// ── Material mode helpers ────────────────────────────────────────────────
const applyMaterialMode = (mode) => {
if (!loadedObject || !threeLib) return;
_disposeTempMaterials(tempMaterials);
_applyMaterialMode(threeLib, loadedObject, mode, originalMaterials, tempMaterials);
scheduleOverlayRedraw();
};
// ── Up direction helpers ─────────────────────────────────────────────────
const applyUpDirection = (dir) => {
if (!modelGroup) return;
const euler = MODEL3D_UP_DIR_EULER[dir] || [0, 0, 0];
try {
modelGroup.rotation.set(euler[0], euler[1], euler[2]);
modelGroup.updateMatrixWorld(true);
} catch (e) {
console.debug?.(e);
}
if (threeLib) objectFrame = _computeObjectFrame(threeLib, modelGroup);
fitLoadedObject();
};
// ── Skeleton helpers ─────────────────────────────────────────────────────
const setSkeletonVisible = (visible) => {
if (!skeletonHelper) return;
try {
skeletonHelper.visible = Boolean(visible);
} catch (e) {
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) {
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) {
console.debug?.(e);
}
}
};
const playAnimation = (idx) => {
if (!animationMixer || !animationClips.length) return;
const clip = animationClips[idx];
if (!clip) return;
// Stop all current actions
animationActions.forEach((a) => {
try {
a?.stop();
} catch (e) {
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 = "⏸";
// 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 (_) {}
};
const togglePlayPause = () => {
if (!animationMixer || !animationClips.length) return;
if (isAnimPlaying) {
animationMixer.timeScale = 0;
isAnimPlaying = false;
animDom.playBtn.textContent = "▶";
} else {
animationMixer.timeScale = animSpeed;
if (!animationActions[currentAnimIdx]?.isRunning?.()) {
playAnimation(currentAnimIdx);
} else {
isAnimPlaying = true;
animDom.playBtn.textContent = "⏸";
}
}
};
const setupAnimations = (clips, object) => {
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, i) => {
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) => {
_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 ? "Ortho" : "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) {
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) {
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 = [];
const bind = (el, name, handler, opts) => {
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) {
console.debug?.(e);
}
});
// Block wheel from reaching panzoom zoom
bind(host, "wheel", (event) => _stopEvent(event));
// Block context menu and drag
bind(host, "contextmenu", (event) => _stopEvent(event, { preventDefault: true }));
bind(host, "dragstart", (event) => _stopEvent(event, { preventDefault: true }));
bind(host, "dragover", (event) => _stopEvent(event, { preventDefault: true }));
bind(host, "dragleave", (event) => _stopEvent(event));
bind(host, "drop", (event) => _stopEvent(event, { preventDefault: true }));
return () => {
for (const c of registrations) {
try {
c();
} catch (e) {
console.debug?.(e);
}
}
};
};
// ── Destroy ──────────────────────────────────────────────────────────────
const destroy = () => {
destroyed = true;
try {
window.removeEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
} catch (_) {}
try {
host._mjr3D = null;
} catch (_) {}
try {
if (rafId != null) cancelAnimationFrame(rafId);
} catch (e) {
console.debug?.(e);
}
rafId = null;
try {
interactionCleanup?.();
} catch (e) {
console.debug?.(e);
}
interactionCleanup = null;
try {
resizeObserver?.disconnect?.();
} catch (e) {
console.debug?.(e);
}
resizeObserver = null;
try {
controls?.dispose?.();
} catch (e) {
console.debug?.(e);
}
controls = null;
// Dispose animation mixer, actions, and clips
try {
animationMixer?.stopAllAction?.();
animationMixer?.uncacheRoot?.(animationMixer.getRoot?.());
} catch (e) {
console.debug?.(e);
}
try {
for (const action of animationActions) {
try {
animationMixer?.uncacheAction?.(action);
} catch (_) {}
}
} catch (e) {
console.debug?.(e);
}
try {
for (const clip of animationClips) {
try {
clip?.dispose?.();
} catch (_) {}
}
} catch (e) {
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) {
console.debug?.(e);
}
skeletonHelper = null;
// Traverse and dispose all geometries, materials, and textures in the main scene
try {
scene?.traverse?.((obj) => {
try {
obj.geometry?.dispose?.();
} catch (_) {}
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 (_) {}
}
mat.dispose?.();
}
} catch (_) {}
}
} catch (_) {}
});
} catch (e) {
console.debug?.(e);
}
// Dispose axis gizmo scene
try {
if (host._mjrAxisScene) {
host._mjrAxisScene.traverse?.((obj) => {
try {
obj.geometry?.dispose?.();
} catch (_) {}
try {
obj.material?.dispose?.();
} catch (_) {}
});
host._mjrAxisScene = null;
}
} catch (e) {
console.debug?.(e);
}
try {
renderer?.dispose?.();
} catch (e) {
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) {
console.debug?.(e);
}
};
const pauseRendering = () => {
runtimePaused = true;
try {
if (rafId != null) cancelAnimationFrame(rafId);
} catch (e) {
console.debug?.(e);
}
rafId = null;
};
const resumeRendering = () => {
runtimePaused = false;
if (destroyed || rafId != null || typeof renderFrameFn !== "function") return;
try {
rafId = requestAnimationFrame(renderFrameFn);
} catch (e) {
console.debug?.(e);
}
};
const onRuntimeStatus = (event) => {
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._mjrProc = proc;
try {
if (shouldPauseDuringExecution) {
window.addEventListener(EVENTS.RUNTIME_STATUS, onRuntimeStatus);
if (String(window?.__MJR_EXECUTION_RUNTIME__?.active_prompt_id || "").trim()) {
runtimePaused = true;
}
}
} catch (e) {
console.debug?.(e);
}
setStatus("Preparing 3D preview", modelName);
// ── Async init ────────────────────────────────────────────────────────────
Promise.resolve()
.then(async () => {
const loaderType = resolveModel3DLoader(asset);
if (!PREVIEWABLE_MODEL3D_LOADERS.has(loaderType)) {
setStatus(
"3D preview unavailable",
`${loaderType.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 = `Download ${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 (_) {}
}
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) => {
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) => 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, manager);
if (!loader) {
setStatus(
"3D loader unavailable",
`${loaderType.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, q) => `.mtl${q || ""}`);
const mtlLoader = new deps.MTLLoader(manager);
const materials = await new Promise((res) => {
mtlLoader.load(mtlUrl, res, undefined, () => res(null));
});
if (materials && !destroyed) {
materials.preload();
loader.setMaterials(materials);
}
} catch (e) {
console.debug?.("[MJR 3D] MTL load skipped:", e?.message);
}
}
const loaded = await _loadModelContent(loader, loaderType, 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) {
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) {
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) {
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._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._mjr3D = {
controls,
get camera() {
return activeCamera;
},
};
try {
options.onReady?.({
canvas,
host,
object: modelGroup,
renderer,
camera: activeCamera,
controls,
});
} catch (e) {
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 (_) {}
syncNaturalSize();
} catch (e) {
console.debug?.(e);
}
if (!runtimePaused) {
rafId = requestAnimationFrame(renderFrame);
}
};
renderFrameFn = renderFrame;
// ── Viewport button events ───────────────────────────────────────
const stopClick = (e) => _stopEvent(e, { preventDefault: true });
resetViewBtn.addEventListener("click", (e) => {
stopClick(e);
fitLoadedObject();
syncViewportButtons();
});
gridBtn.addEventListener("click", (e) => {
stopClick(e);
if (gridHelper) gridHelper.visible = !gridHelper.visible;
syncViewportButtons();
scheduleOverlayRedraw();
});
cameraModeBtn.addEventListener("click", (e) => {
stopClick(e);
setViewportMode(
viewportMode === MODEL3D_VIEW_MODES.PERSPECTIVE
? MODEL3D_VIEW_MODES.ORTHOGRAPHIC
: MODEL3D_VIEW_MODES.PERSPECTIVE,
);
});
settingsBtn.addEventListener("click", (e) => {
stopClick(e);
settingsPanelVisible = !settingsPanelVisible;
settingsDom.panel.style.display = settingsPanelVisible ? "block" : "none";
syncViewportButtons();
});
if (!runtimePaused) {
renderFrame();
}
} catch (error) {
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) => {
if (!destroyed) {
console.warn("[MJR 3D] preview init unhandled error", err);
try {
setStatus(
"Failed to load 3D preview",
String(err?.message || "Initialization failed."),
);
} catch (_) {}
try {
hint.style.display = "none";
} catch (_) {}
}
});
return host;
}